refactor: 코드 분리 + 성능 최적화 + 모바일 개선
tkqc 5개 페이지 인라인 JS/CSS를 외부 파일로 추출 (HTML 82% 감소) tkuser index.html을 CSS 1개 + JS 10개 모듈로 분리 (3283→1155줄) - 공통 유틸 추출: issue-helpers, photo-modal, toast - 공통 CSS 확장: tkqc-common.css (모바일 반응형 포함) - 모바일 하단 네비게이션 추가 (mobile-bottom-nav.js) - nginx: JS/CSS 1시간 캐싱 + gzip 압축 활성화 - Tailwind CDN preload, 캐시버스터 통일 (?v=20260213) - 카메라 capture="environment" 추가 - tkuser Dockerfile에 static/ 디렉토리 복사 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,53 +6,17 @@
|
|||||||
<title>부적합 사항 조회 - 작업보고서</title>
|
<title>부적합 사항 조회 - 작업보고서</title>
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS -->
|
||||||
|
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
|
||||||
<!-- 공통 스타일 -->
|
<!-- 공통 스타일 -->
|
||||||
<link rel="stylesheet" href="/static/css/tkqc-common.css">
|
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=20260213">
|
||||||
|
|
||||||
<!-- Custom Styles -->
|
<!-- 페이지 전용 스타일 -->
|
||||||
<style>
|
<link rel="stylesheet" href="/static/css/issue-view.css?v=20260213">
|
||||||
.line-clamp-2 {
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link {
|
|
||||||
color: #6b7280;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link:hover {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link.active {
|
|
||||||
background-color: #334155;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-fade-in {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-fade-in.visible {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||||
@@ -142,929 +106,14 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
<script src="/static/js/date-utils.js?v=20260213"></script>
|
||||||
<script src="/static/js/core/permissions.js?v=20251025"></script>
|
<script src="/static/js/core/permissions.js?v=20260213"></script>
|
||||||
<script src="/static/js/components/common-header.js?v=20251025"></script>
|
<script src="/static/js/components/common-header.js?v=20260213"></script>
|
||||||
<script src="/static/js/core/page-manager.js?v=20251025"></script>
|
<script src="/static/js/core/page-manager.js?v=20260213"></script>
|
||||||
<script>
|
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
|
||||||
let currentUser = null;
|
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
|
||||||
let issues = [];
|
<script src="/static/js/utils/toast.js?v=20260213"></script>
|
||||||
let projects = []; // 프로젝트 데이터 캐시
|
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
|
||||||
let currentRange = 'week'; // 기본값: 이번 주
|
<script src="/static/js/pages/issue-view.js?v=20260213"></script>
|
||||||
|
|
||||||
// 애니메이션 함수들
|
|
||||||
function animateHeaderAppearance() {
|
|
||||||
console.log('🎨 헤더 애니메이션 시작');
|
|
||||||
|
|
||||||
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
|
|
||||||
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
|
|
||||||
|
|
||||||
if (headerElement) {
|
|
||||||
headerElement.classList.add('header-fade-in');
|
|
||||||
setTimeout(() => {
|
|
||||||
headerElement.classList.add('visible');
|
|
||||||
console.log('✨ 헤더 페이드인 완료');
|
|
||||||
|
|
||||||
// 헤더 애니메이션 완료 후 본문 애니메이션
|
|
||||||
setTimeout(() => {
|
|
||||||
animateContentAppearance();
|
|
||||||
}, 200);
|
|
||||||
}, 50);
|
|
||||||
} else {
|
|
||||||
// 헤더를 찾지 못했으면 바로 본문 애니메이션
|
|
||||||
console.log('⚠️ 헤더 요소를 찾지 못함 - 본문 애니메이션 시작');
|
|
||||||
animateContentAppearance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 본문 컨텐츠 애니메이션
|
|
||||||
function animateContentAppearance() {
|
|
||||||
console.log('🎨 본문 컨텐츠 애니메이션 시작');
|
|
||||||
|
|
||||||
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
|
|
||||||
const contentElements = document.querySelectorAll('.content-fade-in');
|
|
||||||
|
|
||||||
contentElements.forEach((element, index) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
element.classList.add('visible');
|
|
||||||
console.log(`✨ 컨텐츠 ${index + 1} 페이드인 완료`);
|
|
||||||
}, index * 100); // 100ms씩 지연
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 로드 후 초기화 함수
|
|
||||||
async function initializeIssueView() {
|
|
||||||
const token = TokenManager.getToken();
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await AuthAPI.getCurrentUser();
|
|
||||||
currentUser = user;
|
|
||||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
|
||||||
|
|
||||||
// 공통 헤더 초기화
|
|
||||||
await window.commonHeader.init(user, 'issues_view');
|
|
||||||
|
|
||||||
// 헤더 초기화 후 부드러운 애니메이션 시작
|
|
||||||
setTimeout(() => {
|
|
||||||
animateHeaderAppearance();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// 사용자 역할에 따른 페이지 제목 설정
|
|
||||||
updatePageTitle(user);
|
|
||||||
|
|
||||||
// 페이지 접근 권한 체크 (부적합 조회 페이지)
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!canAccessPage('issues_view')) {
|
|
||||||
alert('부적합 조회 페이지에 접근할 권한이 없습니다.');
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('인증 실패:', error);
|
|
||||||
TokenManager.removeToken();
|
|
||||||
TokenManager.removeUser();
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 네비게이션은 공통 헤더에서 처리됨
|
|
||||||
|
|
||||||
// 프로젝트 로드
|
|
||||||
await loadProjects();
|
|
||||||
|
|
||||||
// 기본 날짜 설정 (이번 주)
|
|
||||||
setDefaultDateRange();
|
|
||||||
|
|
||||||
// 기본값: 이번 주 데이터 로드
|
|
||||||
await loadIssues();
|
|
||||||
setDateRange('week');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미지 모달 표시
|
|
||||||
function showImageModal(imagePath) {
|
|
||||||
if (!imagePath) return;
|
|
||||||
|
|
||||||
// 모달 HTML 생성
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50';
|
|
||||||
modal.onclick = () => modal.remove();
|
|
||||||
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="relative max-w-4xl max-h-[90vh]">
|
|
||||||
<img src="${imagePath}" class="max-w-full max-h-[90vh] object-contain rounded-lg">
|
|
||||||
<button
|
|
||||||
onclick="this.parentElement.parentElement.remove()"
|
|
||||||
class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full hover:bg-red-600"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 네비게이션은 공통 헤더에서 처리됨
|
|
||||||
|
|
||||||
// 기본 날짜 범위 설정
|
|
||||||
function setDefaultDateRange() {
|
|
||||||
const today = new Date();
|
|
||||||
const weekStart = new Date(today);
|
|
||||||
weekStart.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
|
||||||
|
|
||||||
// 날짜 입력 필드에 기본값 설정
|
|
||||||
document.getElementById('startDateInput').value = formatDateForInput(weekStart);
|
|
||||||
document.getElementById('endDateInput').value = formatDateForInput(today);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜를 input[type="date"] 형식으로 포맷
|
|
||||||
function formatDateForInput(date) {
|
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 필터 적용
|
|
||||||
function applyDateFilter() {
|
|
||||||
const startDate = document.getElementById('startDateInput').value;
|
|
||||||
const endDate = document.getElementById('endDateInput').value;
|
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
alert('시작날짜와 끝날짜를 모두 선택해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date(startDate) > new Date(endDate)) {
|
|
||||||
alert('시작날짜는 끝날짜보다 이전이어야 합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 필터 적용
|
|
||||||
filterIssues();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 역할에 따른 페이지 제목 업데이트
|
|
||||||
function updatePageTitle(user) {
|
|
||||||
const titleElement = document.getElementById('pageTitle');
|
|
||||||
const descriptionElement = document.getElementById('pageDescription');
|
|
||||||
|
|
||||||
if (user.role === 'admin') {
|
|
||||||
titleElement.innerHTML = `
|
|
||||||
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
|
||||||
전체 부적합 조회
|
|
||||||
`;
|
|
||||||
descriptionElement.textContent = '모든 사용자가 등록한 부적합 사항을 관리할 수 있습니다';
|
|
||||||
} else {
|
|
||||||
titleElement.innerHTML = `
|
|
||||||
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
|
||||||
내 부적합 조회
|
|
||||||
`;
|
|
||||||
descriptionElement.textContent = '내가 등록한 부적합 사항을 확인할 수 있습니다';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로젝트 로드 (API 기반)
|
|
||||||
async function loadProjects() {
|
|
||||||
try {
|
|
||||||
// 모든 프로젝트 로드 (활성/비활성 모두 - 기존 데이터 조회를 위해)
|
|
||||||
projects = await ProjectsAPI.getAll(false);
|
|
||||||
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.job_no} / ${project.project_name}${!project.is_active ? ' (비활성)' : ''}`;
|
|
||||||
projectFilter.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('프로젝트 로드 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이슈 필터링
|
|
||||||
// 검토 상태 확인 함수
|
|
||||||
function isReviewCompleted(issue) {
|
|
||||||
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 필터링 함수
|
|
||||||
function filterByDate(issues, dateFilter) {
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
|
|
||||||
switch (dateFilter) {
|
|
||||||
case 'today':
|
|
||||||
return issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.report_date);
|
|
||||||
return issueDate >= today;
|
|
||||||
});
|
|
||||||
case 'week':
|
|
||||||
const weekStart = new Date(today);
|
|
||||||
weekStart.setDate(today.getDate() - today.getDay());
|
|
||||||
return issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.report_date);
|
|
||||||
return issueDate >= weekStart;
|
|
||||||
});
|
|
||||||
case 'month':
|
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
||||||
return issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.report_date);
|
|
||||||
return issueDate >= monthStart;
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 범위별 필터링 함수
|
|
||||||
function filterByDateRange(issues, range) {
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
|
|
||||||
switch (range) {
|
|
||||||
case 'today':
|
|
||||||
return issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.created_at);
|
|
||||||
const issueDay = new Date(issueDate.getFullYear(), issueDate.getMonth(), issueDate.getDate());
|
|
||||||
return issueDay.getTime() === today.getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
case 'week':
|
|
||||||
const weekStart = new Date(today);
|
|
||||||
weekStart.setDate(today.getDate() - today.getDay());
|
|
||||||
const weekEnd = new Date(weekStart);
|
|
||||||
weekEnd.setDate(weekStart.getDate() + 6);
|
|
||||||
weekEnd.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
return issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.created_at);
|
|
||||||
return issueDate >= weekStart && issueDate <= weekEnd;
|
|
||||||
});
|
|
||||||
|
|
||||||
case 'month':
|
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
||||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
|
||||||
monthEnd.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
return issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.created_at);
|
|
||||||
return issueDate >= monthStart && issueDate <= monthEnd;
|
|
||||||
});
|
|
||||||
|
|
||||||
default:
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterIssues() {
|
|
||||||
// 필터 값 가져오기
|
|
||||||
const selectedProjectId = document.getElementById('projectFilter').value;
|
|
||||||
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
|
|
||||||
|
|
||||||
let filteredIssues = [...issues];
|
|
||||||
|
|
||||||
// 프로젝트 필터 적용
|
|
||||||
if (selectedProjectId) {
|
|
||||||
filteredIssues = filteredIssues.filter(issue => {
|
|
||||||
const issueProjectId = issue.project_id || issue.projectId;
|
|
||||||
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 워크플로우 상태 필터 적용
|
|
||||||
if (reviewStatusFilter) {
|
|
||||||
filteredIssues = filteredIssues.filter(issue => {
|
|
||||||
// 새로운 워크플로우 시스템 사용
|
|
||||||
if (issue.review_status) {
|
|
||||||
return issue.review_status === reviewStatusFilter;
|
|
||||||
}
|
|
||||||
// 기존 데이터 호환성을 위한 폴백
|
|
||||||
else {
|
|
||||||
const isCompleted = isReviewCompleted(issue);
|
|
||||||
if (reviewStatusFilter === 'pending_review') return !isCompleted;
|
|
||||||
if (reviewStatusFilter === 'completed') return isCompleted;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 범위 필터 적용 (입력 필드에서 선택된 범위)
|
|
||||||
const startDateInput = document.getElementById('startDateInput').value;
|
|
||||||
const endDateInput = document.getElementById('endDateInput').value;
|
|
||||||
|
|
||||||
if (startDateInput && endDateInput) {
|
|
||||||
filteredIssues = filteredIssues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.report_date);
|
|
||||||
const startOfDay = new Date(startDateInput);
|
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
const endOfDay = new Date(endDateInput);
|
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
return issueDate >= startOfDay && issueDate <= endOfDay;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 변수에 필터링된 결과 저장
|
|
||||||
window.filteredIssues = filteredIssues;
|
|
||||||
|
|
||||||
displayResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로젝트 정보 표시용 함수
|
|
||||||
function getProjectInfo(projectId) {
|
|
||||||
if (!projectId) {
|
|
||||||
return '<span class="text-gray-500">프로젝트 미지정</span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 projects 배열에서 찾기
|
|
||||||
const project = projects.find(p => p.id == projectId);
|
|
||||||
if (project) {
|
|
||||||
return `${project.job_no} / ${project.project_name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 범위 설정 및 자동 조회
|
|
||||||
function setDateRange(range) {
|
|
||||||
currentRange = range;
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
let startDate, endDate;
|
|
||||||
|
|
||||||
switch (range) {
|
|
||||||
case 'today':
|
|
||||||
startDate = new Date(today);
|
|
||||||
endDate = new Date(today);
|
|
||||||
break;
|
|
||||||
case 'week':
|
|
||||||
startDate = new Date(today);
|
|
||||||
startDate.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
|
||||||
endDate = new Date(today);
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
|
||||||
endDate = new Date(today);
|
|
||||||
break;
|
|
||||||
case 'all':
|
|
||||||
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
|
|
||||||
endDate = new Date(today);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 날짜 입력 필드 업데이트
|
|
||||||
document.getElementById('startDateInput').value = formatDateForInput(startDate);
|
|
||||||
document.getElementById('endDateInput').value = formatDateForInput(endDate);
|
|
||||||
|
|
||||||
// 필터 적용
|
|
||||||
filterIssues();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 부적합 사항 로드 (자신이 올린 내용만)
|
|
||||||
async function loadIssues() {
|
|
||||||
const container = document.getElementById('issueResults');
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="text-gray-500 text-center py-8">
|
|
||||||
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
|
||||||
<p>데이터를 불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 모든 이슈 가져오기
|
|
||||||
const allIssues = await IssuesAPI.getAll();
|
|
||||||
|
|
||||||
// 자신이 올린 이슈만 필터링
|
|
||||||
issues = allIssues
|
|
||||||
.filter(issue => issue.reporter_id === currentUser.id)
|
|
||||||
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
|
|
||||||
|
|
||||||
// 결과 표시
|
|
||||||
filterIssues();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('부적합 사항 로드 실패:', error);
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="text-red-500 text-center py-8">
|
|
||||||
<i class="fas fa-exclamation-triangle text-3xl mb-3"></i>
|
|
||||||
<p>데이터를 불러오는데 실패했습니다.</p>
|
|
||||||
<button onclick="loadIssues()" class="mt-3 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
|
||||||
다시 시도
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결과 표시 (시간순 나열)
|
|
||||||
function displayResults() {
|
|
||||||
const container = document.getElementById('issueResults');
|
|
||||||
|
|
||||||
// 필터링된 결과 사용 (filterIssues에서 설정됨)
|
|
||||||
const filteredIssues = window.filteredIssues || issues;
|
|
||||||
|
|
||||||
if (filteredIssues.length === 0) {
|
|
||||||
const emptyMessage = currentUser.role === 'admin'
|
|
||||||
? '조건에 맞는 부적합 사항이 없습니다.'
|
|
||||||
: '아직 등록한 부적합 사항이 없습니다.<br><small class="text-sm">부적합 등록 페이지에서 새로운 부적합을 등록해보세요.</small>';
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="text-gray-500 text-center py-12">
|
|
||||||
<i class="fas fa-inbox text-4xl mb-4 text-gray-400"></i>
|
|
||||||
<p class="text-lg mb-2">${emptyMessage}</p>
|
|
||||||
${currentUser.role !== 'admin' ? `
|
|
||||||
<div class="mt-4">
|
|
||||||
<a href="/index.html" class="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
|
||||||
<i class="fas fa-plus mr-2"></i>
|
|
||||||
부적합 등록하기
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 워크플로우 상태별로 분류 및 정렬
|
|
||||||
const groupedIssues = {
|
|
||||||
pending_review: filteredIssues.filter(issue =>
|
|
||||||
issue.review_status === 'pending_review' || (!issue.review_status && !isReviewCompleted(issue))
|
|
||||||
),
|
|
||||||
in_progress: filteredIssues.filter(issue => issue.review_status === 'in_progress'),
|
|
||||||
completed: filteredIssues.filter(issue =>
|
|
||||||
issue.review_status === 'completed' || (!issue.review_status && isReviewCompleted(issue))
|
|
||||||
),
|
|
||||||
disposed: filteredIssues.filter(issue => issue.review_status === 'disposed')
|
|
||||||
};
|
|
||||||
|
|
||||||
container.innerHTML = '';
|
|
||||||
|
|
||||||
// 각 상태별로 표시
|
|
||||||
const statusConfig = [
|
|
||||||
{ key: 'pending_review', title: '수신함 (검토 대기)', icon: 'fas fa-inbox', color: 'text-orange-700' },
|
|
||||||
{ key: 'in_progress', title: '관리함 (진행 중)', icon: 'fas fa-cog', color: 'text-blue-700' },
|
|
||||||
{ key: 'completed', title: '관리함 (완료됨)', icon: 'fas fa-check-circle', color: 'text-green-700' },
|
|
||||||
{ key: 'disposed', title: '폐기함 (폐기됨)', icon: 'fas fa-trash', color: 'text-gray-700' }
|
|
||||||
];
|
|
||||||
|
|
||||||
statusConfig.forEach((config, index) => {
|
|
||||||
const issues = groupedIssues[config.key];
|
|
||||||
if (issues.length > 0) {
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = index > 0 ? 'mb-4 mt-8' : 'mb-4';
|
|
||||||
header.innerHTML = `
|
|
||||||
<h3 class="text-md font-semibold ${config.color} flex items-center">
|
|
||||||
<i class="${config.icon} mr-2"></i>${config.title} (${issues.length}건)
|
|
||||||
</h3>
|
|
||||||
`;
|
|
||||||
container.appendChild(header);
|
|
||||||
|
|
||||||
issues.forEach(issue => {
|
|
||||||
container.appendChild(createIssueCard(issue, config.key === 'completed'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 워크플로우 상태 표시 함수
|
|
||||||
function getWorkflowStatusBadge(issue) {
|
|
||||||
const status = issue.review_status || (isReviewCompleted(issue) ? 'completed' : 'pending_review');
|
|
||||||
|
|
||||||
const statusConfig = {
|
|
||||||
'pending_review': { text: '검토 대기', class: 'bg-orange-100 text-orange-700', icon: 'fas fa-inbox' },
|
|
||||||
'in_progress': { text: '진행 중', class: 'bg-blue-100 text-blue-700', icon: 'fas fa-cog' },
|
|
||||||
'completed': { text: '완료됨', class: 'bg-green-100 text-green-700', icon: 'fas fa-check-circle' },
|
|
||||||
'disposed': { text: '폐기됨', class: 'bg-gray-100 text-gray-700', icon: 'fas fa-trash' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = statusConfig[status] || statusConfig['pending_review'];
|
|
||||||
return `<span class="px-2 py-1 rounded-full text-xs font-medium ${config.class}">
|
|
||||||
<i class="${config.icon} mr-1"></i>${config.text}
|
|
||||||
</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 부적합 사항 카드 생성 함수 (조회용)
|
|
||||||
function createIssueCard(issue, isCompleted) {
|
|
||||||
const categoryNames = {
|
|
||||||
material_missing: '자재누락',
|
|
||||||
design_error: '설계미스',
|
|
||||||
incoming_defect: '입고자재 불량',
|
|
||||||
inspection_miss: '검사미스'
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
|
||||||
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
|
|
||||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
|
|
||||||
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
|
|
||||||
};
|
|
||||||
|
|
||||||
const div = document.createElement('div');
|
|
||||||
// 검토 완료 상태에 따른 스타일링
|
|
||||||
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
|
|
||||||
const statusClasses = isCompleted
|
|
||||||
? 'bg-gray-100 opacity-75'
|
|
||||||
: 'bg-gray-50 hover:bg-gray-100';
|
|
||||||
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
|
|
||||||
div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
|
|
||||||
|
|
||||||
const dateStr = DateUtils.formatKST(issue.report_date, true);
|
|
||||||
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
|
|
||||||
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
|
|
||||||
|
|
||||||
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
|
|
||||||
const canEdit = issue.reporter_id === currentUser.id;
|
|
||||||
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
|
|
||||||
|
|
||||||
div.innerHTML = `
|
|
||||||
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
|
|
||||||
<div class="flex justify-between items-start p-2 pb-0">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
${getWorkflowStatusBadge(issue)}
|
|
||||||
</div>
|
|
||||||
<div class="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
|
||||||
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 기존 내용 -->
|
|
||||||
<div class="flex gap-3 p-3 pt-1">
|
|
||||||
<!-- 사진들 -->
|
|
||||||
<div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
|
|
||||||
${(() => {
|
|
||||||
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-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
|
||||||
<i class="fas fa-image text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return photos.map(path => `
|
|
||||||
<img src="${path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${path}')">
|
|
||||||
`).join('');
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 내용 -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
|
|
||||||
${categoryNames[issue.category] || issue.category}
|
|
||||||
</span>
|
|
||||||
${issue.work_hours ?
|
|
||||||
`<span class="text-sm text-green-600 font-medium">
|
|
||||||
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
|
|
||||||
</span>` :
|
|
||||||
'<span class="text-sm text-gray-400">시간 미입력</span>'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
|
||||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
|
|
||||||
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
|
|
||||||
<span class="text-xs text-gray-400">${relativeTime}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 수정/삭제 버튼 -->
|
|
||||||
${(canEdit || canDelete) ? `
|
|
||||||
<div class="flex gap-2">
|
|
||||||
${canEdit ? `
|
|
||||||
<button onclick='showEditModal(${JSON.stringify(issue).replace(/'/g, "'")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
|
||||||
<i class="fas fa-edit mr-1"></i>수정
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
${canDelete ? `
|
|
||||||
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
|
|
||||||
<i class="fas fa-trash mr-1"></i>삭제
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 관리 버튼 클릭 처리
|
|
||||||
function handleAdminClick() {
|
|
||||||
if (currentUser.role === 'admin') {
|
|
||||||
// 관리자: 사용자 관리 페이지로 이동
|
|
||||||
window.location.href = 'admin.html';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 변경 모달 표시
|
|
||||||
function showPasswordChangeModal() {
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
|
||||||
modal.onclick = (e) => {
|
|
||||||
if (e.target === modal) modal.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
|
|
||||||
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="passwordChangeForm" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
|
|
||||||
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
|
|
||||||
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
|
|
||||||
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-4">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
|
||||||
변경
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
// 폼 제출 이벤트 처리
|
|
||||||
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 변경 처리
|
|
||||||
async function handlePasswordChange(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const currentPassword = document.getElementById('currentPassword').value;
|
|
||||||
const newPassword = document.getElementById('newPassword').value;
|
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
// 새 비밀번호 확인
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
alert('새 비밀번호가 일치하지 않습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 비밀번호 확인 (localStorage 기반)
|
|
||||||
let users = JSON.parse(localStorage.getItem('work-report-users') || '[]');
|
|
||||||
|
|
||||||
// 기본 사용자가 없으면 생성
|
|
||||||
if (users.length === 0) {
|
|
||||||
users = [
|
|
||||||
{
|
|
||||||
username: 'hyungi',
|
|
||||||
full_name: '관리자',
|
|
||||||
password: 'djg3-jj34-X3Q3',
|
|
||||||
role: 'admin'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
localStorage.setItem('work-report-users', JSON.stringify(users));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user = users.find(u => u.username === currentUser.username);
|
|
||||||
|
|
||||||
// 사용자가 없으면 기본값으로 생성
|
|
||||||
if (!user) {
|
|
||||||
const username = currentUser.username;
|
|
||||||
user = {
|
|
||||||
username: username,
|
|
||||||
full_name: username === 'hyungi' ? '관리자' : username,
|
|
||||||
password: 'djg3-jj34-X3Q3',
|
|
||||||
role: username === 'hyungi' ? 'admin' : 'user'
|
|
||||||
};
|
|
||||||
users.push(user);
|
|
||||||
localStorage.setItem('work-report-users', JSON.stringify(users));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.password !== currentPassword) {
|
|
||||||
alert('현재 비밀번호가 올바르지 않습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 비밀번호 변경
|
|
||||||
user.password = newPassword;
|
|
||||||
localStorage.setItem('work-report-users', JSON.stringify(users));
|
|
||||||
|
|
||||||
// 현재 사용자 정보도 업데이트
|
|
||||||
currentUser.password = newPassword;
|
|
||||||
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
|
||||||
|
|
||||||
alert('비밀번호가 성공적으로 변경되었습니다.');
|
|
||||||
document.querySelector('.fixed').remove(); // 모달 닫기
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
alert('비밀번호 변경에 실패했습니다: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그아웃 함수
|
|
||||||
function logout() {
|
|
||||||
TokenManager.removeToken();
|
|
||||||
TokenManager.removeUser();
|
|
||||||
window.location.href = 'index.html';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수정 모달 표시
|
|
||||||
function showEditModal(issue) {
|
|
||||||
const categoryNames = {
|
|
||||||
material_missing: '자재누락',
|
|
||||||
design_error: '설계미스',
|
|
||||||
incoming_defect: '입고자재 불량',
|
|
||||||
inspection_miss: '검사미스'
|
|
||||||
};
|
|
||||||
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
|
||||||
modal.onclick = (e) => {
|
|
||||||
if (e.target === modal) modal.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="text-lg font-semibold">부적합 수정</h3>
|
|
||||||
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="editIssueForm" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
|
||||||
<select id="editCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
|
||||||
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
|
||||||
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
|
|
||||||
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
|
||||||
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
|
|
||||||
<select id="editProject" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
|
||||||
${projects.map(p => `
|
|
||||||
<option value="${p.id}" ${p.id === issue.project_id ? 'selected' : ''}>
|
|
||||||
${p.job_no} / ${p.project_name}
|
|
||||||
</option>
|
|
||||||
`).join('')}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
|
|
||||||
<textarea id="editDescription" class="w-full px-3 py-2 border border-gray-300 rounded-lg" rows="4" required>${issue.description || ''}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 pt-4">
|
|
||||||
<button type="button" onclick="this.closest('.fixed').remove()"
|
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
|
||||||
수정
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
// 폼 제출 이벤트 처리
|
|
||||||
document.getElementById('editIssueForm').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
category: document.getElementById('editCategory').value,
|
|
||||||
description: document.getElementById('editDescription').value,
|
|
||||||
project_id: parseInt(document.getElementById('editProject').value)
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await IssuesAPI.update(issue.id, updateData);
|
|
||||||
alert('수정되었습니다.');
|
|
||||||
modal.remove();
|
|
||||||
// 목록 새로고침
|
|
||||||
await loadIssues();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('수정 실패:', error);
|
|
||||||
alert('수정에 실패했습니다: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 삭제 확인 다이얼로그
|
|
||||||
function confirmDelete(issueId) {
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
|
||||||
modal.onclick = (e) => {
|
|
||||||
if (e.target === modal) modal.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.innerHTML = `
|
|
||||||
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
|
||||||
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold mb-2">부적합 삭제</h3>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
이 부적합 사항을 삭제하시겠습니까?<br>
|
|
||||||
삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="this.closest('.fixed').remove()"
|
|
||||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button onclick="handleDelete(${issueId})"
|
|
||||||
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 삭제 처리
|
|
||||||
async function handleDelete(issueId) {
|
|
||||||
try {
|
|
||||||
await IssuesAPI.delete(issueId);
|
|
||||||
alert('삭제되었습니다.');
|
|
||||||
|
|
||||||
// 모달 닫기
|
|
||||||
const modal = document.querySelector('.fixed');
|
|
||||||
if (modal) modal.remove();
|
|
||||||
|
|
||||||
// 목록 새로고침
|
|
||||||
await loadIssues();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('삭제 실패:', error);
|
|
||||||
alert('삭제에 실패했습니다: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 네비게이션은 공통 헤더에서 처리됨
|
|
||||||
|
|
||||||
// API 스크립트 동적 로딩
|
|
||||||
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = `/static/js/api.js?cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
|
|
||||||
script.setAttribute('cache-control', 'no-cache');
|
|
||||||
script.setAttribute('pragma', 'no-cache');
|
|
||||||
script.onload = function() {
|
|
||||||
console.log('✅ API 스크립트 로드 완료 (issue-view.html)');
|
|
||||||
// API 로드 후 초기화 시작
|
|
||||||
initializeIssueView();
|
|
||||||
};
|
|
||||||
script.onerror = function() {
|
|
||||||
console.error('❌ API 스크립트 로드 실패');
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>폐기함 - 작업보고서</title>
|
<title>폐기함 - 작업보고서</title>
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS -->
|
||||||
|
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
@@ -15,25 +16,10 @@
|
|||||||
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
|
<link rel="stylesheet" href="/static/css/mobile-calendar.css">
|
||||||
|
|
||||||
<!-- 공통 스타일 -->
|
<!-- 공통 스타일 -->
|
||||||
<link rel="stylesheet" href="/static/css/tkqc-common.css">
|
<link rel="stylesheet" href="/static/css/tkqc-common.css?v=20260213">
|
||||||
|
|
||||||
<!-- Custom Styles -->
|
<!-- 페이지 전용 스타일 -->
|
||||||
<style>
|
<link rel="stylesheet" href="/static/css/issues-archive.css?v=20260213">
|
||||||
.archived-card {
|
|
||||||
border-left: 4px solid #6b7280;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.completed-card {
|
|
||||||
border-left: 4px solid #10b981;
|
|
||||||
background: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||||
@@ -210,371 +196,14 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="/static/js/date-utils.js?v=20250917"></script>
|
<script src="/static/js/date-utils.js?v=20260213"></script>
|
||||||
<script src="/static/js/core/permissions.js?v=20251025"></script>
|
<script src="/static/js/core/permissions.js?v=20260213"></script>
|
||||||
<script src="/static/js/components/common-header.js?v=20251025"></script>
|
<script src="/static/js/components/common-header.js?v=20260213"></script>
|
||||||
<script src="/static/js/core/page-manager.js?v=20251025"></script>
|
<script src="/static/js/core/page-manager.js?v=20260213"></script>
|
||||||
<script>
|
<script src="/static/js/utils/issue-helpers.js?v=20260213"></script>
|
||||||
let currentUser = null;
|
<script src="/static/js/utils/photo-modal.js?v=20260213"></script>
|
||||||
let issues = [];
|
<script src="/static/js/utils/toast.js?v=20260213"></script>
|
||||||
let projects = [];
|
<script src="/static/js/components/mobile-bottom-nav.js?v=20260213"></script>
|
||||||
let filteredIssues = [];
|
<script src="/static/js/pages/issues-archive.js?v=20260213"></script>
|
||||||
|
|
||||||
// API 로드 후 초기화 함수
|
|
||||||
async function initializeArchive() {
|
|
||||||
const token = TokenManager.getToken();
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await AuthAPI.getCurrentUser();
|
|
||||||
currentUser = user;
|
|
||||||
localStorage.setItem('currentUser', JSON.stringify(user));
|
|
||||||
|
|
||||||
// 공통 헤더 초기화
|
|
||||||
await window.commonHeader.init(user, 'issues_archive');
|
|
||||||
|
|
||||||
// 페이지 접근 권한 체크
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!canAccessPage('issues_archive')) {
|
|
||||||
alert('폐기함 페이지에 접근할 권한이 없습니다.');
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// 데이터 로드
|
|
||||||
await loadProjects();
|
|
||||||
await loadArchivedIssues();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('인증 실패:', error);
|
|
||||||
TokenManager.removeToken();
|
|
||||||
TokenManager.removeUser();
|
|
||||||
window.location.href = '/index.html';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 프로젝트 로드
|
|
||||||
async function loadProjects() {
|
|
||||||
try {
|
|
||||||
const apiUrl = window.API_BASE_URL || '/api';
|
|
||||||
const response = await fetch(`${apiUrl}/projects/`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
projects = await response.json();
|
|
||||||
updateProjectFilter();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('프로젝트 로드 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 보관된 부적합 로드
|
|
||||||
async function loadArchivedIssues() {
|
|
||||||
try {
|
|
||||||
let endpoint = '/api/issues/';
|
|
||||||
|
|
||||||
// 관리자인 경우 전체 부적합 조회 API 사용
|
|
||||||
if (currentUser.role === 'admin') {
|
|
||||||
endpoint = '/api/issues/admin/all';
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const allIssues = await response.json();
|
|
||||||
// 폐기된 부적합만 필터링 (폐기함 전용)
|
|
||||||
issues = allIssues.filter(issue =>
|
|
||||||
issue.review_status === 'disposed'
|
|
||||||
);
|
|
||||||
|
|
||||||
filterIssues();
|
|
||||||
updateStatistics();
|
|
||||||
renderCharts();
|
|
||||||
} else {
|
|
||||||
throw new Error('부적합 목록을 불러올 수 없습니다.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('부적합 로드 실패:', error);
|
|
||||||
alert('부적합 목록을 불러오는데 실패했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 필터링 및 표시
|
|
||||||
function filterIssues() {
|
|
||||||
const projectFilter = document.getElementById('projectFilter').value;
|
|
||||||
const statusFilter = document.getElementById('statusFilter').value;
|
|
||||||
const periodFilter = document.getElementById('periodFilter').value;
|
|
||||||
const categoryFilter = document.getElementById('categoryFilter').value;
|
|
||||||
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
|
||||||
|
|
||||||
filteredIssues = issues.filter(issue => {
|
|
||||||
if (projectFilter && issue.project_id != projectFilter) return false;
|
|
||||||
if (statusFilter && issue.status !== statusFilter) return false;
|
|
||||||
if (categoryFilter && issue.category !== categoryFilter) return false;
|
|
||||||
|
|
||||||
// 기간 필터
|
|
||||||
if (periodFilter) {
|
|
||||||
const issueDate = new Date(issue.updated_at || issue.created_at);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
switch (periodFilter) {
|
|
||||||
case 'week':
|
|
||||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
if (issueDate < weekAgo) return false;
|
|
||||||
break;
|
|
||||||
case 'month':
|
|
||||||
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
|
||||||
if (issueDate < monthAgo) return false;
|
|
||||||
break;
|
|
||||||
case 'quarter':
|
|
||||||
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
|
||||||
if (issueDate < quarterAgo) return false;
|
|
||||||
break;
|
|
||||||
case 'year':
|
|
||||||
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
|
||||||
if (issueDate < yearAgo) return false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchInput) {
|
|
||||||
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
|
|
||||||
if (!searchText.includes(searchInput)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
sortIssues();
|
|
||||||
displayIssues();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortIssues() {
|
|
||||||
const sortOrder = document.getElementById('sortOrder').value;
|
|
||||||
|
|
||||||
filteredIssues.sort((a, b) => {
|
|
||||||
switch (sortOrder) {
|
|
||||||
case 'newest':
|
|
||||||
return new Date(b.report_date) - new Date(a.report_date);
|
|
||||||
case 'oldest':
|
|
||||||
return new Date(a.report_date) - new Date(b.report_date);
|
|
||||||
case 'completed':
|
|
||||||
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
|
|
||||||
case 'category':
|
|
||||||
return (a.category || '').localeCompare(b.category || '');
|
|
||||||
default:
|
|
||||||
return new Date(b.report_date) - new Date(a.report_date);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayIssues() {
|
|
||||||
const container = document.getElementById('issuesList');
|
|
||||||
const emptyState = document.getElementById('emptyState');
|
|
||||||
|
|
||||||
if (filteredIssues.length === 0) {
|
|
||||||
container.innerHTML = '';
|
|
||||||
emptyState.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emptyState.classList.add('hidden');
|
|
||||||
|
|
||||||
container.innerHTML = filteredIssues.map(issue => {
|
|
||||||
const project = projects.find(p => p.id === issue.project_id);
|
|
||||||
|
|
||||||
// 폐기함은 폐기된 것만 표시
|
|
||||||
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
|
|
||||||
const statusText = '폐기';
|
|
||||||
const cardClass = 'archived-card';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="issue-card p-6 ${cardClass} cursor-pointer"
|
|
||||||
onclick="viewArchivedIssue(${issue.id})">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center space-x-3 mb-2">
|
|
||||||
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
|
|
||||||
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
|
|
||||||
<span class="text-sm text-gray-400">${completedDate}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
|
|
||||||
|
|
||||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
|
||||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
|
|
||||||
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
|
|
||||||
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
|
||||||
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 통계 업데이트
|
|
||||||
function updateStatistics() {
|
|
||||||
const completed = issues.filter(issue => issue.status === 'completed').length;
|
|
||||||
const archived = issues.filter(issue => issue.status === 'archived').length;
|
|
||||||
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
|
|
||||||
|
|
||||||
const thisMonth = issues.filter(issue => {
|
|
||||||
const issueDate = new Date(issue.updated_at || issue.created_at);
|
|
||||||
const now = new Date();
|
|
||||||
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
document.getElementById('completedCount').textContent = completed;
|
|
||||||
document.getElementById('archivedCount').textContent = archived;
|
|
||||||
document.getElementById('cancelledCount').textContent = cancelled;
|
|
||||||
document.getElementById('thisMonthCount').textContent = thisMonth;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 차트 렌더링 (간단한 텍스트 기반)
|
|
||||||
function renderCharts() {
|
|
||||||
renderMonthlyChart();
|
|
||||||
renderCategoryChart();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMonthlyChart() {
|
|
||||||
const canvas = document.getElementById('monthlyChart');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// 간단한 차트 대신 텍스트로 표시
|
|
||||||
canvas.width = canvas.offsetWidth;
|
|
||||||
canvas.height = canvas.offsetHeight;
|
|
||||||
|
|
||||||
ctx.fillStyle = '#374151';
|
|
||||||
ctx.font = '16px Inter';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
|
|
||||||
ctx.font = '12px Inter';
|
|
||||||
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCategoryChart() {
|
|
||||||
const canvas = document.getElementById('categoryChart');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
canvas.width = canvas.offsetWidth;
|
|
||||||
canvas.height = canvas.offsetHeight;
|
|
||||||
|
|
||||||
ctx.fillStyle = '#374151';
|
|
||||||
ctx.font = '16px Inter';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
|
|
||||||
ctx.font = '12px Inter';
|
|
||||||
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기타 함수들
|
|
||||||
function generateReport() {
|
|
||||||
alert('통계 보고서를 생성합니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupArchive() {
|
|
||||||
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
|
|
||||||
alert('데이터 정리가 완료되었습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewArchivedIssue(issueId) {
|
|
||||||
window.location.href = `/issue-view.html#detail-${issueId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 유틸리티 함수들
|
|
||||||
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 getStatusBadgeClass(status) {
|
|
||||||
const statusMap = {
|
|
||||||
'completed': 'completed',
|
|
||||||
'archived': 'archived',
|
|
||||||
'cancelled': 'cancelled'
|
|
||||||
};
|
|
||||||
return statusMap[status] || 'archived';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusText(status) {
|
|
||||||
const statusMap = {
|
|
||||||
'completed': '완료',
|
|
||||||
'archived': '보관',
|
|
||||||
'cancelled': '취소'
|
|
||||||
};
|
|
||||||
return statusMap[status] || status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusIcon(status) {
|
|
||||||
const iconMap = {
|
|
||||||
'completed': 'check-circle',
|
|
||||||
'archived': 'archive',
|
|
||||||
'cancelled': 'times-circle'
|
|
||||||
};
|
|
||||||
return iconMap[status] || 'archive';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusColor(status) {
|
|
||||||
const colorMap = {
|
|
||||||
'completed': 'text-green-500',
|
|
||||||
'archived': 'text-gray-500',
|
|
||||||
'cancelled': 'text-red-500'
|
|
||||||
};
|
|
||||||
return colorMap[status] || 'text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryText(category) {
|
|
||||||
const categoryMap = {
|
|
||||||
'material_missing': '자재 누락',
|
|
||||||
'design_error': '설계 오류',
|
|
||||||
'incoming_defect': '반입 불량',
|
|
||||||
'inspection_miss': '검사 누락',
|
|
||||||
'etc': '기타'
|
|
||||||
};
|
|
||||||
return categoryMap[category] || category;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 스크립트 동적 로딩
|
|
||||||
const cacheBuster = Date.now() + Math.random() + Math.floor(Math.random() * 1000000);
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = `/static/js/api.js?v=20251025-2&cb=${cacheBuster}&t=${Date.now()}&r=${Math.random()}`;
|
|
||||||
script.setAttribute('cache-control', 'no-cache');
|
|
||||||
script.setAttribute('pragma', 'no-cache');
|
|
||||||
script.onload = function() {
|
|
||||||
console.log('✅ API 스크립트 로드 완료 (issues-archive.html)');
|
|
||||||
initializeArchive();
|
|
||||||
};
|
|
||||||
script.onerror = function() {
|
|
||||||
console.error('❌ API 스크립트 로드 실패');
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,16 +7,21 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index issues-dashboard.html;
|
index issues-dashboard.html;
|
||||||
|
|
||||||
|
# gzip 압축
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
# HTML 캐시 비활성화
|
# HTML 캐시 비활성화
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
expires -1;
|
expires -1;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
# JS/CSS 캐시 비활성화
|
# JS/CSS 캐시 활성화 (버전 쿼리 스트링으로 무효화)
|
||||||
location ~* \.(js|css)$ {
|
location ~* \.(js|css)$ {
|
||||||
expires -1;
|
expires 1h;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
add_header Cache-Control "public, no-transform";
|
||||||
}
|
}
|
||||||
|
|
||||||
# 정적 파일 (이미지 등)
|
# 정적 파일 (이미지 등)
|
||||||
|
|||||||
49
system3-nonconformance/web/static/css/issue-view.css
Normal file
49
system3-nonconformance/web/static/css/issue-view.css
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/* issue-view.css — 부적합 조회 페이지 전용 스타일 */
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-effect {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
16
system3-nonconformance/web/static/css/issues-archive.css
Normal file
16
system3-nonconformance/web/static/css/issues-archive.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/* issues-archive.css — 폐기함 페이지 전용 스타일 */
|
||||||
|
|
||||||
|
.archived-card {
|
||||||
|
border-left: 4px solid #6b7280;
|
||||||
|
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed-card {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
background: linear-gradient(135deg, #ecfdf5 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
position: relative;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
73
system3-nonconformance/web/static/css/issues-dashboard.css
Normal file
73
system3-nonconformance/web/static/css/issues-dashboard.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* issues-dashboard.css — 현황판 페이지 전용 스타일 */
|
||||||
|
|
||||||
|
/* 대시보드 페이지는 @keyframes 기반 애니메이션 사용 (공통 CSS와 다른 방식) */
|
||||||
|
.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.2s ease;
|
||||||
|
background: #ffffff;
|
||||||
|
border-left: 4px solid #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이슈 카드 스타일 (대시보드 전용 오버라이드) */
|
||||||
|
.issue-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-left-color: #475569;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 그리드 */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
6
system3-nonconformance/web/static/css/issues-inbox.css
Normal file
6
system3-nonconformance/web/static/css/issues-inbox.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* issues-inbox.css — 수신함 페이지 전용 스타일 */
|
||||||
|
|
||||||
|
.issue-card.unread {
|
||||||
|
border-left-color: #3b82f6;
|
||||||
|
background: linear-gradient(135deg, #eff6ff 0%, #ffffff 100%);
|
||||||
|
}
|
||||||
123
system3-nonconformance/web/static/css/issues-management.css
Normal file
123
system3-nonconformance/web/static/css/issues-management.css
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/* issues-management.css — 관리함 페이지 전용 스타일 */
|
||||||
|
|
||||||
|
/* 액션 버튼 */
|
||||||
|
.action-btn {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모달 블러 */
|
||||||
|
.modal {
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이슈 테이블 컬럼 헤더 */
|
||||||
|
.issue-table th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table tbody tr:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 컬럼별 너비 조정 */
|
||||||
|
.col-no { min-width: 60px; }
|
||||||
|
.col-project { min-width: 120px; }
|
||||||
|
.col-content { min-width: 250px; max-width: 300px; }
|
||||||
|
.col-cause { min-width: 100px; }
|
||||||
|
.col-solution { min-width: 200px; max-width: 250px; }
|
||||||
|
.col-department { min-width: 100px; }
|
||||||
|
.col-person { min-width: 120px; }
|
||||||
|
.col-date { min-width: 120px; }
|
||||||
|
.col-confirmer { min-width: 120px; }
|
||||||
|
.col-comment { min-width: 200px; max-width: 250px; }
|
||||||
|
.col-status { min-width: 100px; }
|
||||||
|
.col-photos { min-width: 150px; }
|
||||||
|
.col-completion { min-width: 80px; }
|
||||||
|
.col-actions { min-width: 120px; }
|
||||||
|
|
||||||
|
/* 이슈 사진 */
|
||||||
|
.issue-photo {
|
||||||
|
width: 60px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 편집 가능한 필드 스타일 */
|
||||||
|
.editable-field {
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-wrap {
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 관리함 전용 collapse-content (max-height 기반 트랜지션) */
|
||||||
|
.collapse-content {
|
||||||
|
max-height: 1000px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-content.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 관리함 전용 이슈 카드 오버라이드 */
|
||||||
|
.issue-card label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card input:focus,
|
||||||
|
.issue-card select:focus,
|
||||||
|
.issue-card textarea:focus {
|
||||||
|
transform: scale(1.01);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card .bg-gray-50 {
|
||||||
|
border-left: 4px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 카드 내 아이콘 스타일 */
|
||||||
|
.issue-card i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
@@ -1,39 +1,404 @@
|
|||||||
/* tkqc-common.css — tkuser 스타일 통일 */
|
/* tkqc-common.css — 부적합 관리 시스템 공통 스타일 */
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background: #f1f5f9;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
/* ===== 로딩 오버레이 ===== */
|
||||||
background: white;
|
.loading-overlay {
|
||||||
border: 1px solid #e2e8f0;
|
position: fixed;
|
||||||
transition: all 0.2s;
|
top: 0;
|
||||||
}
|
left: 0;
|
||||||
.input-field:focus {
|
right: 0;
|
||||||
outline: none;
|
bottom: 0;
|
||||||
border-color: #64748b;
|
background: rgba(255, 255, 255, 0.9);
|
||||||
box-shadow: 0 0 0 3px rgba(100,116,139,0.1);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.issue-card {
|
.loading-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 날짜 그룹 ===== */
|
||||||
|
.date-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-header {
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border-left: 4px solid transparent;
|
|
||||||
}
|
|
||||||
.issue-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge { display: inline-flex; align-items: center; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; }
|
.date-header:hover {
|
||||||
|
background-color: #f3f4f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-content {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 우선순위 표시 ===== */
|
||||||
|
.priority-high { border-left-color: #ef4444 !important; }
|
||||||
|
.priority-medium { border-left-color: #f59e0b !important; }
|
||||||
|
.priority-low { border-left-color: #10b981 !important; }
|
||||||
|
|
||||||
|
/* ===== 상태 배지 ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.badge-new { background: #dbeafe; color: #1e40af; }
|
.badge-new { background: #dbeafe; color: #1e40af; }
|
||||||
.badge-processing { background: #fef3c7; color: #92400e; }
|
.badge-processing { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||||
.badge-completed { background: #d1fae5; color: #065f46; }
|
.badge-completed { background: #d1fae5; color: #065f46; }
|
||||||
.badge-archived { background: #f3f4f6; color: #374151; }
|
.badge-archived { background: #f3f4f6; color: #374151; }
|
||||||
.badge-cancelled { background: #fee2e2; color: #991b1b; }
|
.badge-cancelled { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
.fade-in { opacity: 0; transform: translateY(10px); transition: opacity 0.4s, transform 0.4s; }
|
/* ===== 이슈 카드 ===== */
|
||||||
.fade-in.visible { opacity: 1; transform: translateY(0); }
|
.issue-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-message { transition: all 0.3s ease; }
|
.issue-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 사진 프리뷰 ===== */
|
||||||
|
.photo-preview {
|
||||||
|
max-width: 150px;
|
||||||
|
max-height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 사진 모달 ===== */
|
||||||
|
.photo-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-modal-content {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-modal-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -12px;
|
||||||
|
right: -12px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-modal-close:hover {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 페이드인 애니메이션 ===== */
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fade-in.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
|
||||||
|
transition-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fade-in.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 프로그레스바 ===== */
|
||||||
|
.progress-bar {
|
||||||
|
background: #475569;
|
||||||
|
transition: width 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 모달 공통 ===== */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 이슈 테이블 ===== */
|
||||||
|
.issue-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table {
|
||||||
|
min-width: 2000px;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table th,
|
||||||
|
.issue-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 상태 보더 ===== */
|
||||||
|
.status-new { border-left-color: #3b82f6; }
|
||||||
|
.status-processing { border-left-color: #f59e0b; }
|
||||||
|
.status-pending { border-left-color: #8b5cf6; }
|
||||||
|
.status-completed { border-left-color: #10b981; }
|
||||||
|
|
||||||
|
/* ===== 탭 스크롤 인디케이터 ===== */
|
||||||
|
.tab-scroll-container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-scroll-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 40px;
|
||||||
|
background: linear-gradient(to right, transparent, white);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-scroll-container.has-overflow::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-scroll-inner {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-scroll-inner::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 모바일 하단 네비게이션 ===== */
|
||||||
|
.tkqc-mobile-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 64px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tkqc-mobile-nav-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tkqc-mobile-nav-item i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tkqc-mobile-nav-item:active {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tkqc-mobile-nav-item.active {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tkqc-mobile-nav-item.active i {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 모바일 반응형 ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* 터치 타겟 최소 44px */
|
||||||
|
button, a, [onclick], select {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview {
|
||||||
|
max-width: 80px;
|
||||||
|
max-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-modal-content {
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 하단 네비게이션 표시 */
|
||||||
|
.tkqc-mobile-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-bottom: calc(64px + env(safe-area-inset-bottom)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 테이블 → 카드 변환 */
|
||||||
|
.issue-table {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table tr {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
border-left: 4px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table td {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-table td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-right: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 대시보드 그리드 모바일 */
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2x2 그리드를 1열로 */
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 이슈 카드 터치 최적화 */
|
||||||
|
.issue-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-card:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* mobile-bottom-nav.js — tkqc 모바일 하단 네비게이션
|
||||||
|
* 768px 이하에서 고정 하단바 표시
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// 이미 삽입되었으면 스킵
|
||||||
|
if (document.getElementById('tkqcMobileNav')) return;
|
||||||
|
|
||||||
|
const nav = document.createElement('nav');
|
||||||
|
nav.id = 'tkqcMobileNav';
|
||||||
|
nav.className = 'tkqc-mobile-nav';
|
||||||
|
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: '/issues-dashboard.html', icon: 'fas fa-chart-line', label: '현황판', page: 'dashboard' },
|
||||||
|
{ href: '/issues-inbox.html', icon: 'fas fa-inbox', label: '수신함', page: 'inbox' },
|
||||||
|
{ href: '/issues-management.html', icon: 'fas fa-tasks', label: '관리함', page: 'management' },
|
||||||
|
{ href: '/issues-archive.html', icon: 'fas fa-archive', label: '폐기함', page: 'archive' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nav.innerHTML = items.map(item => {
|
||||||
|
const isActive = currentPath.includes(item.page) || currentPath === item.href;
|
||||||
|
return `
|
||||||
|
<a href="${item.href}" class="tkqc-mobile-nav-item ${isActive ? 'active' : ''}">
|
||||||
|
<i class="${item.icon}"></i>
|
||||||
|
<span>${item.label}</span>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
document.body.appendChild(nav);
|
||||||
|
})();
|
||||||
886
system3-nonconformance/web/static/js/pages/issue-view.js
Normal file
886
system3-nonconformance/web/static/js/pages/issue-view.js
Normal file
@@ -0,0 +1,886 @@
|
|||||||
|
/**
|
||||||
|
* issue-view.js — 부적합 조회 페이지 스크립트
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let issues = [];
|
||||||
|
let projects = []; // 프로젝트 데이터 캐시
|
||||||
|
let currentRange = 'week'; // 기본값: 이번 주
|
||||||
|
|
||||||
|
// 애니메이션 함수들
|
||||||
|
function animateHeaderAppearance() {
|
||||||
|
console.log('헤더 애니메이션 시작');
|
||||||
|
|
||||||
|
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
|
||||||
|
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
|
||||||
|
|
||||||
|
if (headerElement) {
|
||||||
|
headerElement.classList.add('header-fade-in');
|
||||||
|
setTimeout(() => {
|
||||||
|
headerElement.classList.add('visible');
|
||||||
|
|
||||||
|
// 헤더 애니메이션 완료 후 본문 애니메이션
|
||||||
|
setTimeout(() => {
|
||||||
|
animateContentAppearance();
|
||||||
|
}, 200);
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
// 헤더를 찾지 못했으면 바로 본문 애니메이션
|
||||||
|
animateContentAppearance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본문 컨텐츠 애니메이션
|
||||||
|
function animateContentAppearance() {
|
||||||
|
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
|
||||||
|
const contentElements = document.querySelectorAll('.content-fade-in');
|
||||||
|
|
||||||
|
contentElements.forEach((element, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.add('visible');
|
||||||
|
}, index * 100); // 100ms씩 지연
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 로드 후 초기화 함수
|
||||||
|
async function initializeIssueView() {
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await AuthAPI.getCurrentUser();
|
||||||
|
currentUser = user;
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||||
|
|
||||||
|
// 공통 헤더 초기화
|
||||||
|
await window.commonHeader.init(user, 'issues_view');
|
||||||
|
|
||||||
|
// 헤더 초기화 후 부드러운 애니메이션 시작
|
||||||
|
setTimeout(() => {
|
||||||
|
animateHeaderAppearance();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 사용자 역할에 따른 페이지 제목 설정
|
||||||
|
updatePageTitle(user);
|
||||||
|
|
||||||
|
// 페이지 접근 권한 체크 (부적합 조회 페이지)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!canAccessPage('issues_view')) {
|
||||||
|
alert('부적합 조회 페이지에 접근할 권한이 없습니다.');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('인증 실패:', error);
|
||||||
|
TokenManager.removeToken();
|
||||||
|
TokenManager.removeUser();
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 로드
|
||||||
|
await loadProjects();
|
||||||
|
|
||||||
|
// 기본 날짜 설정 (이번 주)
|
||||||
|
setDefaultDateRange();
|
||||||
|
|
||||||
|
// 기본값: 이번 주 데이터 로드
|
||||||
|
await loadIssues();
|
||||||
|
setDateRange('week');
|
||||||
|
}
|
||||||
|
|
||||||
|
// showImageModal은 photo-modal.js에서 제공됨
|
||||||
|
|
||||||
|
// 기본 날짜 범위 설정
|
||||||
|
function setDefaultDateRange() {
|
||||||
|
const today = new Date();
|
||||||
|
const weekStart = new Date(today);
|
||||||
|
weekStart.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
||||||
|
|
||||||
|
// 날짜 입력 필드에 기본값 설정
|
||||||
|
document.getElementById('startDateInput').value = formatDateForInput(weekStart);
|
||||||
|
document.getElementById('endDateInput').value = formatDateForInput(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜를 input[type="date"] 형식으로 포맷
|
||||||
|
function formatDateForInput(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터 적용
|
||||||
|
function applyDateFilter() {
|
||||||
|
const startDate = document.getElementById('startDateInput').value;
|
||||||
|
const endDate = document.getElementById('endDateInput').value;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
alert('시작날짜와 끝날짜를 모두 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(startDate) > new Date(endDate)) {
|
||||||
|
alert('시작날짜는 끝날짜보다 이전이어야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 적용
|
||||||
|
filterIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 역할에 따른 페이지 제목 업데이트
|
||||||
|
function updatePageTitle(user) {
|
||||||
|
const titleElement = document.getElementById('pageTitle');
|
||||||
|
const descriptionElement = document.getElementById('pageDescription');
|
||||||
|
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
titleElement.innerHTML = `
|
||||||
|
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
||||||
|
전체 부적합 조회
|
||||||
|
`;
|
||||||
|
descriptionElement.textContent = '모든 사용자가 등록한 부적합 사항을 관리할 수 있습니다';
|
||||||
|
} else {
|
||||||
|
titleElement.innerHTML = `
|
||||||
|
<i class="fas fa-list-alt text-blue-500 mr-3"></i>
|
||||||
|
내 부적합 조회
|
||||||
|
`;
|
||||||
|
descriptionElement.textContent = '내가 등록한 부적합 사항을 확인할 수 있습니다';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 로드 (API 기반)
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
// 모든 프로젝트 로드 (활성/비활성 모두 - 기존 데이터 조회를 위해)
|
||||||
|
projects = await ProjectsAPI.getAll(false);
|
||||||
|
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.job_no} / ${project.project_name}${!project.is_active ? ' (비활성)' : ''}`;
|
||||||
|
projectFilter.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이슈 필터링
|
||||||
|
// 검토 상태 확인 함수
|
||||||
|
function isReviewCompleted(issue) {
|
||||||
|
return issue.status === 'complete' && issue.work_hours && issue.work_hours > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터링 함수
|
||||||
|
function filterByDate(issues, dateFilter) {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
switch (dateFilter) {
|
||||||
|
case 'today':
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.report_date);
|
||||||
|
return issueDate >= today;
|
||||||
|
});
|
||||||
|
case 'week':
|
||||||
|
const weekStart = new Date(today);
|
||||||
|
weekStart.setDate(today.getDate() - today.getDay());
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.report_date);
|
||||||
|
return issueDate >= weekStart;
|
||||||
|
});
|
||||||
|
case 'month':
|
||||||
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.report_date);
|
||||||
|
return issueDate >= monthStart;
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 범위별 필터링 함수
|
||||||
|
function filterByDateRange(issues, range) {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'today':
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.created_at);
|
||||||
|
const issueDay = new Date(issueDate.getFullYear(), issueDate.getMonth(), issueDate.getDate());
|
||||||
|
return issueDay.getTime() === today.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'week':
|
||||||
|
const weekStart = new Date(today);
|
||||||
|
weekStart.setDate(today.getDate() - today.getDay());
|
||||||
|
const weekEnd = new Date(weekStart);
|
||||||
|
weekEnd.setDate(weekStart.getDate() + 6);
|
||||||
|
weekEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.created_at);
|
||||||
|
return issueDate >= weekStart && issueDate <= weekEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
case 'month':
|
||||||
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||||
|
monthEnd.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.created_at);
|
||||||
|
return issueDate >= monthStart && issueDate <= monthEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterIssues() {
|
||||||
|
// 필터 값 가져오기
|
||||||
|
const selectedProjectId = document.getElementById('projectFilter').value;
|
||||||
|
const reviewStatusFilter = document.getElementById('reviewStatusFilter').value;
|
||||||
|
|
||||||
|
let filteredIssues = [...issues];
|
||||||
|
|
||||||
|
// 프로젝트 필터 적용
|
||||||
|
if (selectedProjectId) {
|
||||||
|
filteredIssues = filteredIssues.filter(issue => {
|
||||||
|
const issueProjectId = issue.project_id || issue.projectId;
|
||||||
|
return issueProjectId && (issueProjectId == selectedProjectId || issueProjectId.toString() === selectedProjectId.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 워크플로우 상태 필터 적용
|
||||||
|
if (reviewStatusFilter) {
|
||||||
|
filteredIssues = filteredIssues.filter(issue => {
|
||||||
|
// 새로운 워크플로우 시스템 사용
|
||||||
|
if (issue.review_status) {
|
||||||
|
return issue.review_status === reviewStatusFilter;
|
||||||
|
}
|
||||||
|
// 기존 데이터 호환성을 위한 폴백
|
||||||
|
else {
|
||||||
|
const isCompleted = isReviewCompleted(issue);
|
||||||
|
if (reviewStatusFilter === 'pending_review') return !isCompleted;
|
||||||
|
if (reviewStatusFilter === 'completed') return isCompleted;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 범위 필터 적용 (입력 필드에서 선택된 범위)
|
||||||
|
const startDateInput = document.getElementById('startDateInput').value;
|
||||||
|
const endDateInput = document.getElementById('endDateInput').value;
|
||||||
|
|
||||||
|
if (startDateInput && endDateInput) {
|
||||||
|
filteredIssues = filteredIssues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.report_date);
|
||||||
|
const startOfDay = new Date(startDateInput);
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const endOfDay = new Date(endDateInput);
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return issueDate >= startOfDay && issueDate <= endOfDay;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 변수에 필터링된 결과 저장
|
||||||
|
window.filteredIssues = filteredIssues;
|
||||||
|
|
||||||
|
displayResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 정보 표시용 함수
|
||||||
|
function getProjectInfo(projectId) {
|
||||||
|
if (!projectId) {
|
||||||
|
return '<span class="text-gray-500">프로젝트 미지정</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 projects 배열에서 찾기
|
||||||
|
const project = projects.find(p => p.id == projectId);
|
||||||
|
if (project) {
|
||||||
|
return `${project.job_no} / ${project.project_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="text-red-500">프로젝트 ID: ${projectId} (정보 없음)</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 범위 설정 및 자동 조회
|
||||||
|
function setDateRange(range) {
|
||||||
|
currentRange = range;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
let startDate, endDate;
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'today':
|
||||||
|
startDate = new Date(today);
|
||||||
|
endDate = new Date(today);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
startDate = new Date(today);
|
||||||
|
startDate.setDate(today.getDate() - today.getDay()); // 이번 주 일요일
|
||||||
|
endDate = new Date(today);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
startDate = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
||||||
|
endDate = new Date(today);
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
|
||||||
|
endDate = new Date(today);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 입력 필드 업데이트
|
||||||
|
document.getElementById('startDateInput').value = formatDateForInput(startDate);
|
||||||
|
document.getElementById('endDateInput').value = formatDateForInput(endDate);
|
||||||
|
|
||||||
|
// 필터 적용
|
||||||
|
filterIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 사항 로드 (자신이 올린 내용만)
|
||||||
|
async function loadIssues() {
|
||||||
|
const container = document.getElementById('issueResults');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-gray-500 text-center py-8">
|
||||||
|
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||||
|
<p>데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모든 이슈 가져오기
|
||||||
|
const allIssues = await IssuesAPI.getAll();
|
||||||
|
|
||||||
|
// 자신이 올린 이슈만 필터링
|
||||||
|
issues = allIssues
|
||||||
|
.filter(issue => issue.reporter_id === currentUser.id)
|
||||||
|
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
|
||||||
|
|
||||||
|
// 결과 표시
|
||||||
|
filterIssues();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부적합 사항 로드 실패:', error);
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-red-500 text-center py-8">
|
||||||
|
<i class="fas fa-exclamation-triangle text-3xl mb-3"></i>
|
||||||
|
<p>데이터를 불러오는데 실패했습니다.</p>
|
||||||
|
<button onclick="loadIssues()" class="mt-3 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 표시 (시간순 나열)
|
||||||
|
function displayResults() {
|
||||||
|
const container = document.getElementById('issueResults');
|
||||||
|
|
||||||
|
// 필터링된 결과 사용 (filterIssues에서 설정됨)
|
||||||
|
const filteredIssues = window.filteredIssues || issues;
|
||||||
|
|
||||||
|
if (filteredIssues.length === 0) {
|
||||||
|
const emptyMessage = currentUser.role === 'admin'
|
||||||
|
? '조건에 맞는 부적합 사항이 없습니다.'
|
||||||
|
: '아직 등록한 부적합 사항이 없습니다.<br><small class="text-sm">부적합 등록 페이지에서 새로운 부적합을 등록해보세요.</small>';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-gray-500 text-center py-12">
|
||||||
|
<i class="fas fa-inbox text-4xl mb-4 text-gray-400"></i>
|
||||||
|
<p class="text-lg mb-2">${emptyMessage}</p>
|
||||||
|
${currentUser.role !== 'admin' ? `
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/index.html" class="inline-flex items-center px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
부적합 등록하기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 워크플로우 상태별로 분류 및 정렬
|
||||||
|
const groupedIssues = {
|
||||||
|
pending_review: filteredIssues.filter(issue =>
|
||||||
|
issue.review_status === 'pending_review' || (!issue.review_status && !isReviewCompleted(issue))
|
||||||
|
),
|
||||||
|
in_progress: filteredIssues.filter(issue => issue.review_status === 'in_progress'),
|
||||||
|
completed: filteredIssues.filter(issue =>
|
||||||
|
issue.review_status === 'completed' || (!issue.review_status && isReviewCompleted(issue))
|
||||||
|
),
|
||||||
|
disposed: filteredIssues.filter(issue => issue.review_status === 'disposed')
|
||||||
|
};
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// 각 상태별로 표시
|
||||||
|
const statusConfig = [
|
||||||
|
{ key: 'pending_review', title: '수신함 (검토 대기)', icon: 'fas fa-inbox', color: 'text-orange-700' },
|
||||||
|
{ key: 'in_progress', title: '관리함 (진행 중)', icon: 'fas fa-cog', color: 'text-blue-700' },
|
||||||
|
{ key: 'completed', title: '관리함 (완료됨)', icon: 'fas fa-check-circle', color: 'text-green-700' },
|
||||||
|
{ key: 'disposed', title: '폐기함 (폐기됨)', icon: 'fas fa-trash', color: 'text-gray-700' }
|
||||||
|
];
|
||||||
|
|
||||||
|
statusConfig.forEach((config, index) => {
|
||||||
|
const issues = groupedIssues[config.key];
|
||||||
|
if (issues.length > 0) {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = index > 0 ? 'mb-4 mt-8' : 'mb-4';
|
||||||
|
header.innerHTML = `
|
||||||
|
<h3 class="text-md font-semibold ${config.color} flex items-center">
|
||||||
|
<i class="${config.icon} mr-2"></i>${config.title} (${issues.length}건)
|
||||||
|
</h3>
|
||||||
|
`;
|
||||||
|
container.appendChild(header);
|
||||||
|
|
||||||
|
issues.forEach(issue => {
|
||||||
|
container.appendChild(createIssueCard(issue, config.key === 'completed'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 워크플로우 상태 표시 함수
|
||||||
|
function getWorkflowStatusBadge(issue) {
|
||||||
|
const status = issue.review_status || (isReviewCompleted(issue) ? 'completed' : 'pending_review');
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
'pending_review': { text: '검토 대기', class: 'bg-orange-100 text-orange-700', icon: 'fas fa-inbox' },
|
||||||
|
'in_progress': { text: '진행 중', class: 'bg-blue-100 text-blue-700', icon: 'fas fa-cog' },
|
||||||
|
'completed': { text: '완료됨', class: 'bg-green-100 text-green-700', icon: 'fas fa-check-circle' },
|
||||||
|
'disposed': { text: '폐기됨', class: 'bg-gray-100 text-gray-700', icon: 'fas fa-trash' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = statusConfig[status] || statusConfig['pending_review'];
|
||||||
|
return `<span class="px-2 py-1 rounded-full text-xs font-medium ${config.class}">
|
||||||
|
<i class="${config.icon} mr-1"></i>${config.text}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 사항 카드 생성 함수 (조회용)
|
||||||
|
function createIssueCard(issue, isCompleted) {
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
design_error: '설계미스',
|
||||||
|
incoming_defect: '입고자재 불량',
|
||||||
|
inspection_miss: '검사미스'
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryColors = {
|
||||||
|
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||||
|
design_error: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||||
|
incoming_defect: 'bg-red-100 text-red-700 border-red-300',
|
||||||
|
inspection_miss: 'bg-purple-100 text-purple-700 border-purple-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
// 검토 완료 상태에 따른 스타일링
|
||||||
|
const baseClasses = 'rounded-lg transition-colors border-l-4 mb-4';
|
||||||
|
const statusClasses = isCompleted
|
||||||
|
? 'bg-gray-100 opacity-75'
|
||||||
|
: 'bg-gray-50 hover:bg-gray-100';
|
||||||
|
const borderColor = categoryColors[issue.category]?.split(' ')[2] || 'border-gray-300';
|
||||||
|
div.className = `${baseClasses} ${statusClasses} ${borderColor}`;
|
||||||
|
|
||||||
|
const dateStr = DateUtils.formatKST(issue.report_date, true);
|
||||||
|
const relativeTime = DateUtils.getRelativeTime(issue.report_date);
|
||||||
|
const projectInfo = getProjectInfo(issue.project_id || issue.projectId);
|
||||||
|
|
||||||
|
// 수정/삭제 권한 확인 (본인이 등록한 부적합만)
|
||||||
|
const canEdit = issue.reporter_id === currentUser.id;
|
||||||
|
const canDelete = issue.reporter_id === currentUser.id || currentUser.role === 'admin';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<!-- 프로젝트 정보 및 상태 (오른쪽 상단) -->
|
||||||
|
<div class="flex justify-between items-start p-2 pb-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${getWorkflowStatusBadge(issue)}
|
||||||
|
</div>
|
||||||
|
<div class="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
|
||||||
|
<i class="fas fa-folder-open mr-1"></i>${projectInfo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 기존 내용 -->
|
||||||
|
<div class="flex gap-3 p-3 pt-1">
|
||||||
|
<!-- 사진들 -->
|
||||||
|
<div class="flex gap-1 flex-shrink-0 flex-wrap max-w-md">
|
||||||
|
${(() => {
|
||||||
|
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-20 h-20 bg-gray-200 rounded flex items-center justify-center">
|
||||||
|
<i class="fas fa-image text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return photos.map(path => `
|
||||||
|
<img src="${path}" class="w-20 h-20 object-cover rounded shadow-sm cursor-pointer" onclick="showImageModal('${path}')">
|
||||||
|
`).join('');
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 내용 -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<span class="px-2 py-1 rounded-full text-xs font-medium ${categoryColors[issue.category] || 'bg-gray-100 text-gray-700'}">
|
||||||
|
${categoryNames[issue.category] || issue.category}
|
||||||
|
</span>
|
||||||
|
${issue.work_hours ?
|
||||||
|
`<span class="text-sm text-green-600 font-medium">
|
||||||
|
<i class="fas fa-clock mr-1"></i>${issue.work_hours}시간
|
||||||
|
</span>` :
|
||||||
|
'<span class="text-sm text-gray-400">시간 미입력</span>'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-800 mb-2 line-clamp-2">${issue.description}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'}</span>
|
||||||
|
<span><i class="fas fa-calendar mr-1"></i>${dateStr}</span>
|
||||||
|
<span class="text-xs text-gray-400">${relativeTime}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 수정/삭제 버튼 -->
|
||||||
|
${(canEdit || canDelete) ? `
|
||||||
|
<div class="flex gap-2">
|
||||||
|
${canEdit ? `
|
||||||
|
<button onclick='showEditModal(${JSON.stringify(issue).replace(/'/g, "'")})' class="px-3 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fas fa-edit mr-1"></i>수정
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${canDelete ? `
|
||||||
|
<button onclick="confirmDelete(${issue.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600 transition-colors">
|
||||||
|
<i class="fas fa-trash mr-1"></i>삭제
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리 버튼 클릭 처리
|
||||||
|
function handleAdminClick() {
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
// 관리자: 사용자 관리 페이지로 이동
|
||||||
|
window.location.href = 'admin.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 변경 모달 표시
|
||||||
|
function showPasswordChangeModal() {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">비밀번호 변경</h3>
|
||||||
|
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="passwordChangeForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
|
||||||
|
<input type="password" id="currentPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
|
||||||
|
<input type="password" id="newPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required minlength="6">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
|
||||||
|
<input type="password" id="confirmPassword" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-4">
|
||||||
|
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||||
|
변경
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// 폼 제출 이벤트 처리
|
||||||
|
document.getElementById('passwordChangeForm').addEventListener('submit', handlePasswordChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 변경 처리
|
||||||
|
async function handlePasswordChange(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const currentPassword = document.getElementById('currentPassword').value;
|
||||||
|
const newPassword = document.getElementById('newPassword').value;
|
||||||
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
|
// 새 비밀번호 확인
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert('새 비밀번호가 일치하지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 비밀번호 확인 (localStorage 기반)
|
||||||
|
let users = JSON.parse(localStorage.getItem('work-report-users') || '[]');
|
||||||
|
|
||||||
|
// 기본 사용자가 없으면 생성
|
||||||
|
if (users.length === 0) {
|
||||||
|
users = [
|
||||||
|
{
|
||||||
|
username: 'hyungi',
|
||||||
|
full_name: '관리자',
|
||||||
|
password: 'djg3-jj34-X3Q3',
|
||||||
|
role: 'admin'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
localStorage.setItem('work-report-users', JSON.stringify(users));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = users.find(u => u.username === currentUser.username);
|
||||||
|
|
||||||
|
// 사용자가 없으면 기본값으로 생성
|
||||||
|
if (!user) {
|
||||||
|
const username = currentUser.username;
|
||||||
|
user = {
|
||||||
|
username: username,
|
||||||
|
full_name: username === 'hyungi' ? '관리자' : username,
|
||||||
|
password: 'djg3-jj34-X3Q3',
|
||||||
|
role: username === 'hyungi' ? 'admin' : 'user'
|
||||||
|
};
|
||||||
|
users.push(user);
|
||||||
|
localStorage.setItem('work-report-users', JSON.stringify(users));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.password !== currentPassword) {
|
||||||
|
alert('현재 비밀번호가 올바르지 않습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 비밀번호 변경
|
||||||
|
user.password = newPassword;
|
||||||
|
localStorage.setItem('work-report-users', JSON.stringify(users));
|
||||||
|
|
||||||
|
// 현재 사용자 정보도 업데이트
|
||||||
|
currentUser.password = newPassword;
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(currentUser));
|
||||||
|
|
||||||
|
alert('비밀번호가 성공적으로 변경되었습니다.');
|
||||||
|
document.querySelector('.fixed').remove(); // 모달 닫기
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert('비밀번호 변경에 실패했습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그아웃 함수
|
||||||
|
function logout() {
|
||||||
|
TokenManager.removeToken();
|
||||||
|
TokenManager.removeUser();
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수정 모달 표시
|
||||||
|
function showEditModal(issue) {
|
||||||
|
const categoryNames = {
|
||||||
|
material_missing: '자재누락',
|
||||||
|
design_error: '설계미스',
|
||||||
|
incoming_defect: '입고자재 불량',
|
||||||
|
inspection_miss: '검사미스'
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4';
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">부적합 수정</h3>
|
||||||
|
<button onclick="this.closest('.fixed').remove()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="editIssueForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
||||||
|
<select id="editCategory" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||||
|
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||||
|
<option value="design_error" ${issue.category === 'design_error' ? 'selected' : ''}>설계미스</option>
|
||||||
|
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||||
|
<option value="inspection_miss" ${issue.category === 'inspection_miss' ? 'selected' : ''}>검사미스</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
|
||||||
|
<select id="editProject" class="w-full px-3 py-2 border border-gray-300 rounded-lg" required>
|
||||||
|
${projects.map(p => `
|
||||||
|
<option value="${p.id}" ${p.id === issue.project_id ? 'selected' : ''}>
|
||||||
|
${p.job_no} / ${p.project_name}
|
||||||
|
</option>
|
||||||
|
`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">내용</label>
|
||||||
|
<textarea id="editDescription" class="w-full px-3 py-2 border border-gray-300 rounded-lg" rows="4" required>${issue.description || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-4">
|
||||||
|
<button type="button" onclick="this.closest('.fixed').remove()"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// 폼 제출 이벤트 처리
|
||||||
|
document.getElementById('editIssueForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
category: document.getElementById('editCategory').value,
|
||||||
|
description: document.getElementById('editDescription').value,
|
||||||
|
project_id: parseInt(document.getElementById('editProject').value)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await IssuesAPI.update(issue.id, updateData);
|
||||||
|
alert('수정되었습니다.');
|
||||||
|
modal.remove();
|
||||||
|
// 목록 새로고침
|
||||||
|
await loadIssues();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수정 실패:', error);
|
||||||
|
alert('수정에 실패했습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제 확인 다이얼로그
|
||||||
|
function confirmDelete(issueId) {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||||
|
modal.onclick = (e) => {
|
||||||
|
if (e.target === modal) modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg p-6 w-96 max-w-md mx-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<i class="fas fa-exclamation-triangle text-red-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">부적합 삭제</h3>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
이 부적합 사항을 삭제하시겠습니까?<br>
|
||||||
|
삭제된 데이터는 로그로 보관되지만 복구할 수 없습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="this.closest('.fixed').remove()"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button onclick="handleDelete(${issueId})"
|
||||||
|
class="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제 처리
|
||||||
|
async function handleDelete(issueId) {
|
||||||
|
try {
|
||||||
|
await IssuesAPI.delete(issueId);
|
||||||
|
alert('삭제되었습니다.');
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
const modal = document.querySelector('.fixed');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
|
||||||
|
// 목록 새로고침
|
||||||
|
await loadIssues();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 실패:', error);
|
||||||
|
alert('삭제에 실패했습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 스크립트 동적 로딩
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '/static/js/api.js?v=20260213';
|
||||||
|
script.onload = function() {
|
||||||
|
console.log('API 스크립트 로드 완료 (issue-view.html)');
|
||||||
|
// API 로드 후 초기화 시작
|
||||||
|
initializeIssueView();
|
||||||
|
};
|
||||||
|
script.onerror = function() {
|
||||||
|
console.error('API 스크립트 로드 실패');
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
332
system3-nonconformance/web/static/js/pages/issues-archive.js
Normal file
332
system3-nonconformance/web/static/js/pages/issues-archive.js
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* issues-archive.js — 폐기함 페이지 스크립트
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let issues = [];
|
||||||
|
let projects = [];
|
||||||
|
let filteredIssues = [];
|
||||||
|
|
||||||
|
// API 로드 후 초기화 함수
|
||||||
|
async function initializeArchive() {
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await AuthAPI.getCurrentUser();
|
||||||
|
currentUser = user;
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||||
|
|
||||||
|
// 공통 헤더 초기화
|
||||||
|
await window.commonHeader.init(user, 'issues_archive');
|
||||||
|
|
||||||
|
// 페이지 접근 권한 체크
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!canAccessPage('issues_archive')) {
|
||||||
|
alert('폐기함 페이지에 접근할 권한이 없습니다.');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
await loadProjects();
|
||||||
|
await loadArchivedIssues();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('인증 실패:', error);
|
||||||
|
TokenManager.removeToken();
|
||||||
|
TokenManager.removeUser();
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 로드
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const apiUrl = window.API_BASE_URL || '/api';
|
||||||
|
const response = await fetch(`${apiUrl}/projects/`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
projects = await response.json();
|
||||||
|
updateProjectFilter();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('프로젝트 로드 실패:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보관된 부적합 로드
|
||||||
|
async function loadArchivedIssues() {
|
||||||
|
try {
|
||||||
|
let endpoint = '/api/issues/';
|
||||||
|
|
||||||
|
// 관리자인 경우 전체 부적합 조회 API 사용
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
endpoint = '/api/issues/admin/all';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const allIssues = await response.json();
|
||||||
|
// 폐기된 부적합만 필터링 (폐기함 전용)
|
||||||
|
issues = allIssues.filter(issue =>
|
||||||
|
issue.review_status === 'disposed'
|
||||||
|
);
|
||||||
|
|
||||||
|
filterIssues();
|
||||||
|
updateStatistics();
|
||||||
|
renderCharts();
|
||||||
|
} else {
|
||||||
|
throw new Error('부적합 목록을 불러올 수 없습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('부적합 로드 실패:', error);
|
||||||
|
alert('부적합 목록을 불러오는데 실패했습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터링 및 표시
|
||||||
|
function filterIssues() {
|
||||||
|
const projectFilter = document.getElementById('projectFilter').value;
|
||||||
|
const statusFilter = document.getElementById('statusFilter').value;
|
||||||
|
const periodFilter = document.getElementById('periodFilter').value;
|
||||||
|
const categoryFilter = document.getElementById('categoryFilter').value;
|
||||||
|
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
|
||||||
|
filteredIssues = issues.filter(issue => {
|
||||||
|
if (projectFilter && issue.project_id != projectFilter) return false;
|
||||||
|
if (statusFilter && issue.status !== statusFilter) return false;
|
||||||
|
if (categoryFilter && issue.category !== categoryFilter) return false;
|
||||||
|
|
||||||
|
// 기간 필터
|
||||||
|
if (periodFilter) {
|
||||||
|
const issueDate = new Date(issue.updated_at || issue.created_at);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (periodFilter) {
|
||||||
|
case 'week':
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
if (issueDate < weekAgo) return false;
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
const monthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||||
|
if (issueDate < monthAgo) return false;
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
const quarterAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||||
|
if (issueDate < quarterAgo) return false;
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||||
|
if (issueDate < yearAgo) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchInput) {
|
||||||
|
const searchText = `${issue.description} ${issue.reporter?.username || ''}`.toLowerCase();
|
||||||
|
if (!searchText.includes(searchInput)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortIssues();
|
||||||
|
displayIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortIssues() {
|
||||||
|
const sortOrder = document.getElementById('sortOrder').value;
|
||||||
|
|
||||||
|
filteredIssues.sort((a, b) => {
|
||||||
|
switch (sortOrder) {
|
||||||
|
case 'newest':
|
||||||
|
return new Date(b.report_date) - new Date(a.report_date);
|
||||||
|
case 'oldest':
|
||||||
|
return new Date(a.report_date) - new Date(b.report_date);
|
||||||
|
case 'completed':
|
||||||
|
return new Date(b.disposed_at || b.report_date) - new Date(a.disposed_at || a.report_date);
|
||||||
|
case 'category':
|
||||||
|
return (a.category || '').localeCompare(b.category || '');
|
||||||
|
default:
|
||||||
|
return new Date(b.report_date) - new Date(a.report_date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayIssues() {
|
||||||
|
const container = document.getElementById('issuesList');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
if (filteredIssues.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
emptyState.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
|
||||||
|
container.innerHTML = filteredIssues.map(issue => {
|
||||||
|
const project = projects.find(p => p.id === issue.project_id);
|
||||||
|
|
||||||
|
// 폐기함은 폐기된 것만 표시
|
||||||
|
const completedDate = issue.disposed_at ? new Date(issue.disposed_at).toLocaleDateString('ko-KR') : 'Invalid Date';
|
||||||
|
const statusText = '폐기';
|
||||||
|
const cardClass = 'archived-card';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="issue-card p-6 ${cardClass} cursor-pointer"
|
||||||
|
onclick="viewArchivedIssue(${issue.id})">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<span class="badge badge-${getStatusBadgeClass(issue.status)}">${getStatusText(issue.status)}</span>
|
||||||
|
${project ? `<span class="text-sm text-gray-500">${project.project_name}</span>` : ''}
|
||||||
|
<span class="text-sm text-gray-400">${completedDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">${issue.description}</h3>
|
||||||
|
|
||||||
|
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
||||||
|
<span><i class="fas fa-user mr-1"></i>${issue.reporter?.username || '알 수 없음'}</span>
|
||||||
|
${issue.category ? `<span><i class="fas fa-tag mr-1"></i>${getCategoryText(issue.category)}</span>` : ''}
|
||||||
|
<span><i class="fas fa-clock mr-1"></i>${statusText}: ${completedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
|
<i class="fas fa-${getStatusIcon(issue.status)} text-2xl ${getStatusColor(issue.status)}"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 업데이트
|
||||||
|
function updateStatistics() {
|
||||||
|
const completed = issues.filter(issue => issue.status === 'completed').length;
|
||||||
|
const archived = issues.filter(issue => issue.status === 'archived').length;
|
||||||
|
const cancelled = issues.filter(issue => issue.status === 'cancelled').length;
|
||||||
|
|
||||||
|
const thisMonth = issues.filter(issue => {
|
||||||
|
const issueDate = new Date(issue.updated_at || issue.created_at);
|
||||||
|
const now = new Date();
|
||||||
|
return issueDate.getMonth() === now.getMonth() && issueDate.getFullYear() === now.getFullYear();
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
document.getElementById('completedCount').textContent = completed;
|
||||||
|
document.getElementById('archivedCount').textContent = archived;
|
||||||
|
document.getElementById('cancelledCount').textContent = cancelled;
|
||||||
|
document.getElementById('thisMonthCount').textContent = thisMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 차트 렌더링 (간단한 텍스트 기반)
|
||||||
|
function renderCharts() {
|
||||||
|
renderMonthlyChart();
|
||||||
|
renderCategoryChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMonthlyChart() {
|
||||||
|
const canvas = document.getElementById('monthlyChart');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '16px Inter';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('월별 완료 현황 차트', canvas.width / 2, canvas.height / 2);
|
||||||
|
ctx.font = '12px Inter';
|
||||||
|
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoryChart() {
|
||||||
|
const canvas = document.getElementById('categoryChart');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
canvas.width = canvas.offsetWidth;
|
||||||
|
canvas.height = canvas.offsetHeight;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '16px Inter';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('카테고리별 분포 차트', canvas.width / 2, canvas.height / 2);
|
||||||
|
ctx.font = '12px Inter';
|
||||||
|
ctx.fillText('(차트 라이브러리 구현 예정)', canvas.width / 2, canvas.height / 2 + 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 함수들
|
||||||
|
function generateReport() {
|
||||||
|
alert('통계 보고서를 생성합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupArchive() {
|
||||||
|
if (confirm('오래된 보관 데이터를 정리하시겠습니까?')) {
|
||||||
|
alert('데이터 정리가 완료되었습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewArchivedIssue(issueId) {
|
||||||
|
window.location.href = `/issue-view.html#detail-${issueId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유틸리티 함수들
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 전용 유틸리티 (shared에 없는 것들)
|
||||||
|
function getStatusIcon(status) {
|
||||||
|
const iconMap = {
|
||||||
|
'completed': 'check-circle',
|
||||||
|
'archived': 'archive',
|
||||||
|
'cancelled': 'times-circle'
|
||||||
|
};
|
||||||
|
return iconMap[status] || 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
const colorMap = {
|
||||||
|
'completed': 'text-green-500',
|
||||||
|
'archived': 'text-gray-500',
|
||||||
|
'cancelled': 'text-red-500'
|
||||||
|
};
|
||||||
|
return colorMap[status] || 'text-gray-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 스크립트 동적 로딩
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '/static/js/api.js?v=20260213';
|
||||||
|
script.onload = function() {
|
||||||
|
console.log('API 스크립트 로드 완료 (issues-archive.html)');
|
||||||
|
initializeArchive();
|
||||||
|
};
|
||||||
|
script.onerror = function() {
|
||||||
|
console.error('API 스크립트 로드 실패');
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
1793
system3-nonconformance/web/static/js/pages/issues-dashboard.js
Normal file
1793
system3-nonconformance/web/static/js/pages/issues-dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
888
system3-nonconformance/web/static/js/pages/issues-inbox.js
Normal file
888
system3-nonconformance/web/static/js/pages/issues-inbox.js
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
/**
|
||||||
|
* issues-inbox.js — 수신함 페이지 스크립트
|
||||||
|
*/
|
||||||
|
|
||||||
|
let currentUser = null;
|
||||||
|
let issues = [];
|
||||||
|
let projects = [];
|
||||||
|
let filteredIssues = [];
|
||||||
|
|
||||||
|
// 한국 시간(KST) 유틸리티 함수
|
||||||
|
function getKSTDate(date) {
|
||||||
|
const utcDate = new Date(date);
|
||||||
|
// UTC + 9시간 = KST
|
||||||
|
return new Date(utcDate.getTime() + (9 * 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKSTDate(date) {
|
||||||
|
const kstDate = getKSTDate(date);
|
||||||
|
return kstDate.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKSTTime(date) {
|
||||||
|
const kstDate = getKSTDate(date);
|
||||||
|
return kstDate.toLocaleTimeString('ko-KR', {
|
||||||
|
timeZone: 'Asia/Seoul',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKSTToday() {
|
||||||
|
const now = new Date();
|
||||||
|
const kstNow = getKSTDate(now);
|
||||||
|
return new Date(kstNow.getFullYear(), kstNow.getMonth(), kstNow.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 애니메이션 함수들
|
||||||
|
function animateHeaderAppearance() {
|
||||||
|
// 헤더 요소 찾기 (공통 헤더가 생성한 요소)
|
||||||
|
const headerElement = document.querySelector('header') || document.querySelector('[class*="header"]') || document.querySelector('nav');
|
||||||
|
|
||||||
|
if (headerElement) {
|
||||||
|
headerElement.classList.add('header-fade-in');
|
||||||
|
setTimeout(() => {
|
||||||
|
headerElement.classList.add('visible');
|
||||||
|
|
||||||
|
// 헤더 애니메이션 완료 후 본문 애니메이션
|
||||||
|
setTimeout(() => {
|
||||||
|
animateContentAppearance();
|
||||||
|
}, 200);
|
||||||
|
}, 50);
|
||||||
|
} else {
|
||||||
|
// 헤더를 찾지 못했으면 바로 본문 애니메이션
|
||||||
|
animateContentAppearance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 본문 컨텐츠 애니메이션
|
||||||
|
function animateContentAppearance() {
|
||||||
|
// 모든 content-fade-in 요소들을 순차적으로 애니메이션
|
||||||
|
const contentElements = document.querySelectorAll('.content-fade-in');
|
||||||
|
|
||||||
|
contentElements.forEach((element, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.add('visible');
|
||||||
|
}, index * 100); // 100ms씩 지연
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 로드 후 초기화 함수
|
||||||
|
async function initializeInbox() {
|
||||||
|
console.log('수신함 초기화 시작');
|
||||||
|
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await AuthAPI.getCurrentUser();
|
||||||
|
currentUser = user;
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(user));
|
||||||
|
|
||||||
|
// 공통 헤더 초기화
|
||||||
|
await window.commonHeader.init(user, 'issues_inbox');
|
||||||
|
|
||||||
|
// 헤더 초기화 후 부드러운 애니메이션 시작
|
||||||
|
setTimeout(() => {
|
||||||
|
animateHeaderAppearance();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 페이지 접근 권한 체크
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof canAccessPage === 'function') {
|
||||||
|
const hasAccess = canAccessPage('issues_inbox');
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
alert('수신함 페이지에 접근할 권한이 없습니다.');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
await loadProjects();
|
||||||
|
await loadIssues();
|
||||||
|
// loadIssues()에서 이미 loadStatistics() 호출함
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수신함 초기화 실패:', error);
|
||||||
|
|
||||||
|
// 401 Unauthorized 에러인 경우만 로그아웃 처리
|
||||||
|
if (error.message && (error.message.includes('401') || error.message.includes('Unauthorized') || error.message.includes('Not authenticated'))) {
|
||||||
|
TokenManager.removeToken();
|
||||||
|
TokenManager.removeUser();
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
} else {
|
||||||
|
// 다른 에러는 사용자에게 알리고 계속 진행
|
||||||
|
alert('일부 데이터를 불러오는데 실패했습니다. 새로고침 후 다시 시도해주세요.');
|
||||||
|
|
||||||
|
// 공통 헤더만이라도 초기화
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
|
||||||
|
if (user.id) {
|
||||||
|
await window.commonHeader.init(user, 'issues_inbox');
|
||||||
|
// 에러 상황에서도 애니메이션 적용
|
||||||
|
setTimeout(() => {
|
||||||
|
animateHeaderAppearance();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} catch (headerError) {
|
||||||
|
console.error('공통 헤더 초기화 실패:', headerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로젝트 로드
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const apiUrl = window.API_BASE_URL || '/api';
|
||||||
|
const response = await fetch(`${apiUrl}/projects/`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
projects = await response.json();
|
||||||
|
updateProjectFilter();
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수신함 부적합 목록 로드 (실제 API 연동)
|
||||||
|
async function loadIssues() {
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const projectId = document.getElementById('projectFilter').value;
|
||||||
|
let url = '/api/inbox/';
|
||||||
|
|
||||||
|
// 프로젝트 필터 적용
|
||||||
|
if (projectId) {
|
||||||
|
url += `?project_id=${projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
issues = await response.json();
|
||||||
|
|
||||||
|
|
||||||
|
filterIssues();
|
||||||
|
await loadStatistics();
|
||||||
|
} else {
|
||||||
|
throw new Error('수신함 목록을 불러올 수 없습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('수신함 로드 실패:', error);
|
||||||
|
showError('수신함 목록을 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신고 필터링
|
||||||
|
function filterIssues() {
|
||||||
|
const projectFilter = document.getElementById('projectFilter').value;
|
||||||
|
|
||||||
|
filteredIssues = issues.filter(issue => {
|
||||||
|
// 프로젝트 필터
|
||||||
|
if (projectFilter && issue.project_id != projectFilter) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortIssues();
|
||||||
|
displayIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신고 정렬
|
||||||
|
function sortIssues() {
|
||||||
|
const sortOrder = document.getElementById('sortOrder').value;
|
||||||
|
|
||||||
|
filteredIssues.sort((a, b) => {
|
||||||
|
switch (sortOrder) {
|
||||||
|
case 'newest':
|
||||||
|
return new Date(b.report_date) - new Date(a.report_date);
|
||||||
|
case 'oldest':
|
||||||
|
return new Date(a.report_date) - new Date(b.report_date);
|
||||||
|
case 'priority':
|
||||||
|
const priorityOrder = { 'high': 3, 'medium': 2, 'low': 1 };
|
||||||
|
return (priorityOrder[b.priority] || 1) - (priorityOrder[a.priority] || 1);
|
||||||
|
default:
|
||||||
|
return new Date(b.report_date) - new Date(a.report_date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합 목록 표시
|
||||||
|
function displayIssues() {
|
||||||
|
const container = document.getElementById('issuesList');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
if (filteredIssues.length === 0) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
emptyState.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
|
||||||
|
container.innerHTML = filteredIssues.map(issue => {
|
||||||
|
const project = projects.find(p => p.id === issue.project_id);
|
||||||
|
const reportDate = new Date(issue.report_date);
|
||||||
|
const createdDate = formatKSTDate(reportDate);
|
||||||
|
const createdTime = formatKSTTime(reportDate);
|
||||||
|
const timeAgo = getTimeAgo(reportDate);
|
||||||
|
|
||||||
|
// 사진 정보 처리
|
||||||
|
const photoCount = [issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5].filter(Boolean).length;
|
||||||
|
const photoInfo = photoCount > 0 ? `사진 ${photoCount}장` : '사진 없음';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="issue-card p-6 hover:bg-gray-50 border-l-4 border-blue-500"
|
||||||
|
data-issue-id="${issue.id}">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<!-- 상단 정보 -->
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="badge badge-new">검토 대기</span>
|
||||||
|
${project ? `<span class="text-sm font-medium text-blue-600">${project.project_name}</span>` : '<span class="text-sm text-gray-400">프로젝트 미지정</span>'}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">ID: ${issue.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 제목 -->
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-3 cursor-pointer hover:text-blue-600 transition-colors" onclick="viewIssueDetail(${issue.id})">${issue.final_description || issue.description}</h3>
|
||||||
|
|
||||||
|
<!-- 상세 정보 그리드 -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4 text-sm">
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<i class="fas fa-user mr-2 text-blue-500"></i>
|
||||||
|
<span class="font-medium">${issue.reporter?.username || '알 수 없음'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<i class="fas fa-tag mr-2 text-green-500"></i>
|
||||||
|
<span>${getCategoryText(issue.category || issue.final_category)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<i class="fas fa-camera mr-2 text-purple-500"></i>
|
||||||
|
<span class="${photoCount > 0 ? 'text-purple-600 font-medium' : ''}">${photoInfo}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<i class="fas fa-clock mr-2 text-orange-500"></i>
|
||||||
|
<span class="font-medium">${timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 업로드 시간 정보 -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<div class="flex items-center text-gray-600">
|
||||||
|
<i class="fas fa-calendar-alt mr-2"></i>
|
||||||
|
<span>업로드: <strong>${createdDate} ${createdTime}</strong></span>
|
||||||
|
</div>
|
||||||
|
${issue.work_hours > 0 ? `<div class="flex items-center text-gray-600">
|
||||||
|
<i class="fas fa-hourglass-half mr-2"></i>
|
||||||
|
<span>공수: <strong>${issue.work_hours}시간</strong></span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${issue.detail_notes ? `<div class="mt-2 text-sm text-gray-600">
|
||||||
|
<i class="fas fa-sticky-note mr-2"></i>
|
||||||
|
<span class="italic">"${issue.detail_notes}"</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사진 미리보기 -->
|
||||||
|
${photoCount > 0 ? `
|
||||||
|
<div class="photo-gallery">
|
||||||
|
${[issue.photo_path, issue.photo_path2, issue.photo_path3, issue.photo_path4, issue.photo_path5]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((path, idx) => `<img src="${path}" class="photo-preview" onclick="openPhotoModal('${path}')" alt="첨부 사진 ${idx + 1}">`)
|
||||||
|
.join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- 워크플로우 액션 버튼들 -->
|
||||||
|
<div class="flex items-center space-x-2 mt-3">
|
||||||
|
<button onclick="openDisposeModal(${issue.id})"
|
||||||
|
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors">
|
||||||
|
<i class="fas fa-trash mr-1"></i>폐기
|
||||||
|
</button>
|
||||||
|
<button onclick="openReviewModal(${issue.id})"
|
||||||
|
class="px-3 py-1 bg-blue-500 text-white text-sm rounded hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fas fa-edit mr-1"></i>검토
|
||||||
|
</button>
|
||||||
|
<button onclick="openStatusModal(${issue.id})"
|
||||||
|
class="px-3 py-1 bg-green-500 text-white text-sm rounded hover:bg-green-600 transition-colors">
|
||||||
|
<i class="fas fa-check mr-1"></i>확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 로드 (새로운 기준)
|
||||||
|
async function loadStatistics() {
|
||||||
|
try {
|
||||||
|
// 현재 수신함 이슈들을 기반으로 통계 계산 (KST 기준)
|
||||||
|
const todayStart = getKSTToday();
|
||||||
|
|
||||||
|
// 금일 신규: 오늘 올라온 목록 숫자 (확인된 것 포함) - KST 기준
|
||||||
|
const todayNewCount = issues.filter(issue => {
|
||||||
|
const reportDate = getKSTDate(new Date(issue.report_date));
|
||||||
|
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
|
||||||
|
return reportDateOnly >= todayStart;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
// 금일 처리: 오늘 처리된 건수 (API에서 가져와야 함)
|
||||||
|
let todayProcessedCount = 0;
|
||||||
|
try {
|
||||||
|
const processedResponse = await fetch('/api/inbox/statistics', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (processedResponse.ok) {
|
||||||
|
const stats = await processedResponse.json();
|
||||||
|
todayProcessedCount = stats.today_processed || 0;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('처리된 건수 조회 실패:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미해결: 오늘꺼 제외한 남아있는 것들 - KST 기준
|
||||||
|
const unresolvedCount = issues.filter(issue => {
|
||||||
|
const reportDate = getKSTDate(new Date(issue.report_date));
|
||||||
|
const reportDateOnly = new Date(reportDate.getFullYear(), reportDate.getMonth(), reportDate.getDate());
|
||||||
|
return reportDateOnly < todayStart;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
// 통계 업데이트
|
||||||
|
document.getElementById('todayNewCount').textContent = todayNewCount;
|
||||||
|
document.getElementById('todayProcessedCount').textContent = todayProcessedCount;
|
||||||
|
document.getElementById('unresolvedCount').textContent = unresolvedCount;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('통계 로드 오류:', error);
|
||||||
|
// 오류 시 기본값 설정
|
||||||
|
document.getElementById('todayNewCount').textContent = '0';
|
||||||
|
document.getElementById('todayProcessedCount').textContent = '0';
|
||||||
|
document.getElementById('unresolvedCount').textContent = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 새로고침
|
||||||
|
function refreshInbox() {
|
||||||
|
loadIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 신고 상세 보기
|
||||||
|
function viewIssueDetail(issueId) {
|
||||||
|
window.location.href = `/issue-view.html#detail-${issueId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPhotoModal, closePhotoModal, handleEscKey는 photo-modal.js에서 제공됨
|
||||||
|
|
||||||
|
// ===== 워크플로우 모달 관련 함수들 =====
|
||||||
|
let currentIssueId = null;
|
||||||
|
|
||||||
|
// 폐기 모달 열기
|
||||||
|
function openDisposeModal(issueId) {
|
||||||
|
currentIssueId = issueId;
|
||||||
|
document.getElementById('disposalReason').value = 'duplicate';
|
||||||
|
document.getElementById('customReason').value = '';
|
||||||
|
document.getElementById('customReasonDiv').classList.add('hidden');
|
||||||
|
document.getElementById('selectedDuplicateId').value = '';
|
||||||
|
document.getElementById('disposeModal').classList.remove('hidden');
|
||||||
|
|
||||||
|
// 중복 선택 영역 표시 (기본값이 duplicate이므로)
|
||||||
|
toggleDuplicateSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폐기 모달 닫기
|
||||||
|
function closeDisposeModal() {
|
||||||
|
currentIssueId = null;
|
||||||
|
document.getElementById('disposeModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정의 사유 토글
|
||||||
|
function toggleCustomReason() {
|
||||||
|
const reason = document.getElementById('disposalReason').value;
|
||||||
|
const customDiv = document.getElementById('customReasonDiv');
|
||||||
|
|
||||||
|
if (reason === 'custom') {
|
||||||
|
customDiv.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
customDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 대상 선택 토글
|
||||||
|
function toggleDuplicateSelection() {
|
||||||
|
const reason = document.getElementById('disposalReason').value;
|
||||||
|
const duplicateDiv = document.getElementById('duplicateSelectionDiv');
|
||||||
|
|
||||||
|
if (reason === 'duplicate') {
|
||||||
|
duplicateDiv.classList.remove('hidden');
|
||||||
|
loadManagementIssues();
|
||||||
|
} else {
|
||||||
|
duplicateDiv.classList.add('hidden');
|
||||||
|
document.getElementById('selectedDuplicateId').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리함 이슈 목록 로드
|
||||||
|
async function loadManagementIssues() {
|
||||||
|
const currentIssue = issues.find(issue => issue.id === currentIssueId);
|
||||||
|
const projectId = currentIssue ? currentIssue.project_id : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/inbox/management-issues${projectId ? `?project_id=${projectId}` : ''}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('관리함 이슈 목록을 불러올 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const managementIssues = await response.json();
|
||||||
|
displayManagementIssues(managementIssues);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('관리함 이슈 로드 오류:', error);
|
||||||
|
document.getElementById('managementIssuesList').innerHTML = `
|
||||||
|
<div class="p-4 text-center text-red-500">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 목록을 불러올 수 없습니다.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관리함 이슈 목록 표시
|
||||||
|
function displayManagementIssues(managementIssues) {
|
||||||
|
const container = document.getElementById('managementIssuesList');
|
||||||
|
|
||||||
|
if (managementIssues.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="p-4 text-center text-gray-500">
|
||||||
|
<i class="fas fa-inbox mr-2"></i>동일 프로젝트의 관리함 이슈가 없습니다.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = managementIssues.map(issue => `
|
||||||
|
<div class="p-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer"
|
||||||
|
onclick="selectDuplicateTarget(${issue.id}, this)">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 mb-1">
|
||||||
|
${issue.description || issue.final_description}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span class="px-2 py-1 bg-gray-100 rounded">${getCategoryText(issue.category || issue.final_category)}</span>
|
||||||
|
<span>신고자: ${issue.reporter_name}</span>
|
||||||
|
${issue.duplicate_count > 0 ? `<span class="text-orange-600">중복 ${issue.duplicate_count}건</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400">
|
||||||
|
ID: ${issue.id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 대상 선택
|
||||||
|
function selectDuplicateTarget(issueId, element) {
|
||||||
|
// 이전 선택 해제
|
||||||
|
document.querySelectorAll('#managementIssuesList > div').forEach(div => {
|
||||||
|
div.classList.remove('bg-blue-50', 'border-blue-200');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 선택 표시
|
||||||
|
element.classList.add('bg-blue-50', 'border-blue-200');
|
||||||
|
document.getElementById('selectedDuplicateId').value = issueId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폐기 확인
|
||||||
|
async function confirmDispose() {
|
||||||
|
if (!currentIssueId) return;
|
||||||
|
|
||||||
|
const disposalReason = document.getElementById('disposalReason').value;
|
||||||
|
const customReason = document.getElementById('customReason').value;
|
||||||
|
const duplicateId = document.getElementById('selectedDuplicateId').value;
|
||||||
|
|
||||||
|
// 사용자 정의 사유 검증
|
||||||
|
if (disposalReason === 'custom' && !customReason.trim()) {
|
||||||
|
alert('사용자 정의 폐기 사유를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 대상 선택 검증
|
||||||
|
if (disposalReason === 'duplicate' && !duplicateId) {
|
||||||
|
alert('중복 대상을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {
|
||||||
|
disposal_reason: disposalReason,
|
||||||
|
custom_disposal_reason: disposalReason === 'custom' ? customReason : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// 중복 처리인 경우 대상 ID 추가
|
||||||
|
if (disposalReason === 'duplicate' && duplicateId) {
|
||||||
|
requestBody.duplicate_of_issue_id = parseInt(duplicateId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/inbox/${currentIssueId}/dispose`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
const message = disposalReason === 'duplicate'
|
||||||
|
? '중복 신고가 처리되었습니다.\n신고자 정보가 원본 이슈에 추가되었습니다.'
|
||||||
|
: `부적합이 성공적으로 폐기되었습니다.\n사유: ${getDisposalReasonText(disposalReason)}`;
|
||||||
|
|
||||||
|
alert(message);
|
||||||
|
closeDisposeModal();
|
||||||
|
await loadIssues(); // 목록 새로고침
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || '폐기 처리에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('폐기 처리 오류:', error);
|
||||||
|
alert('폐기 처리 중 오류가 발생했습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검토 모달 열기
|
||||||
|
async function openReviewModal(issueId) {
|
||||||
|
currentIssueId = issueId;
|
||||||
|
|
||||||
|
// 현재 부적합 정보 찾기
|
||||||
|
const issue = issues.find(i => i.id === issueId);
|
||||||
|
if (!issue) return;
|
||||||
|
|
||||||
|
// 원본 정보 표시
|
||||||
|
const originalInfo = document.getElementById('originalInfo');
|
||||||
|
const project = projects.find(p => p.id === issue.project_id);
|
||||||
|
originalInfo.innerHTML = `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div><strong>프로젝트:</strong> ${project ? project.project_name : '미지정'}</div>
|
||||||
|
<div><strong>카테고리:</strong> ${getCategoryText(issue.category || issue.final_category)}</div>
|
||||||
|
<div><strong>설명:</strong> ${issue.description || issue.final_description}</div>
|
||||||
|
<div><strong>등록자:</strong> ${issue.reporter?.username || '알 수 없음'}</div>
|
||||||
|
<div><strong>등록일:</strong> ${new Date(issue.report_date).toLocaleDateString('ko-KR')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 프로젝트 옵션 업데이트
|
||||||
|
const reviewProjectSelect = document.getElementById('reviewProjectId');
|
||||||
|
reviewProjectSelect.innerHTML = '<option value="">프로젝트 선택</option>';
|
||||||
|
projects.forEach(project => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = project.id;
|
||||||
|
option.textContent = project.project_name;
|
||||||
|
if (project.id === issue.project_id) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
reviewProjectSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 값들로 폼 초기화 (최신 내용 우선 사용)
|
||||||
|
document.getElementById('reviewCategory').value = issue.category || issue.final_category;
|
||||||
|
// 최신 description을 title과 description으로 분리 (첫 번째 줄을 title로 사용)
|
||||||
|
const currentDescription = issue.description || issue.final_description;
|
||||||
|
const lines = currentDescription.split('\n');
|
||||||
|
document.getElementById('reviewTitle').value = lines[0] || '';
|
||||||
|
document.getElementById('reviewDescription').value = lines.slice(1).join('\n') || currentDescription;
|
||||||
|
|
||||||
|
document.getElementById('reviewModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검토 모달 닫기
|
||||||
|
function closeReviewModal() {
|
||||||
|
currentIssueId = null;
|
||||||
|
document.getElementById('reviewModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검토 저장
|
||||||
|
async function saveReview() {
|
||||||
|
if (!currentIssueId) return;
|
||||||
|
|
||||||
|
const projectId = document.getElementById('reviewProjectId').value;
|
||||||
|
const category = document.getElementById('reviewCategory').value;
|
||||||
|
const title = document.getElementById('reviewTitle').value.trim();
|
||||||
|
const description = document.getElementById('reviewDescription').value.trim();
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
alert('부적합명을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부적합명과 상세 내용을 합쳐서 저장 (첫 번째 줄에 제목, 나머지는 상세 내용)
|
||||||
|
const combinedDescription = title + (description ? '\n' + description : '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/inbox/${currentIssueId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
project_id: projectId ? parseInt(projectId) : null,
|
||||||
|
category: category,
|
||||||
|
description: combinedDescription
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`검토가 완료되었습니다.\n수정된 항목: ${result.modifications_count}개`);
|
||||||
|
closeReviewModal();
|
||||||
|
await loadIssues(); // 목록 새로고침
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || '검토 처리에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('검토 처리 오류:', error);
|
||||||
|
alert('검토 처리 중 오류가 발생했습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 모달 열기
|
||||||
|
function openStatusModal(issueId) {
|
||||||
|
currentIssueId = issueId;
|
||||||
|
|
||||||
|
// 라디오 버튼 초기화
|
||||||
|
document.querySelectorAll('input[name="finalStatus"]').forEach(radio => {
|
||||||
|
radio.checked = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('statusModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 모달 닫기
|
||||||
|
function closeStatusModal() {
|
||||||
|
currentIssueId = null;
|
||||||
|
document.getElementById('statusModal').classList.add('hidden');
|
||||||
|
// 완료 관련 필드 초기화
|
||||||
|
document.getElementById('completionSection').classList.add('hidden');
|
||||||
|
document.getElementById('completionPhotoInput').value = '';
|
||||||
|
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||||
|
document.getElementById('solutionInput').value = '';
|
||||||
|
document.getElementById('responsibleDepartmentInput').value = '';
|
||||||
|
document.getElementById('responsiblePersonInput').value = '';
|
||||||
|
completionPhotoBase64 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 완료 섹션 토글
|
||||||
|
function toggleCompletionPhotoSection() {
|
||||||
|
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
|
||||||
|
const completionSection = document.getElementById('completionSection');
|
||||||
|
|
||||||
|
if (selectedStatus && selectedStatus.value === 'completed') {
|
||||||
|
completionSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
completionSection.classList.add('hidden');
|
||||||
|
// 완료 관련 필드 초기화
|
||||||
|
document.getElementById('completionPhotoInput').value = '';
|
||||||
|
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||||
|
document.getElementById('solutionInput').value = '';
|
||||||
|
document.getElementById('responsibleDepartmentInput').value = '';
|
||||||
|
document.getElementById('responsiblePersonInput').value = '';
|
||||||
|
completionPhotoBase64 = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 완료 사진 선택 처리
|
||||||
|
let completionPhotoBase64 = null;
|
||||||
|
function handleCompletionPhotoSelect(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
completionPhotoBase64 = null;
|
||||||
|
document.getElementById('completionPhotoPreview').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 크기 체크 (5MB 제한)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('파일 크기는 5MB 이하여야 합니다.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 파일인지 확인
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('이미지 파일만 업로드 가능합니다.');
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
completionPhotoBase64 = e.target.result.split(',')[1]; // Base64 부분만 추출
|
||||||
|
|
||||||
|
// 미리보기 표시
|
||||||
|
document.getElementById('completionPhotoImg').src = e.target.result;
|
||||||
|
document.getElementById('completionPhotoPreview').classList.remove('hidden');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 변경 확인
|
||||||
|
async function confirmStatus() {
|
||||||
|
if (!currentIssueId) return;
|
||||||
|
|
||||||
|
const selectedStatus = document.querySelector('input[name="finalStatus"]:checked');
|
||||||
|
if (!selectedStatus) {
|
||||||
|
alert('상태를 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewStatus = selectedStatus.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {
|
||||||
|
review_status: reviewStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
// 완료 상태일 때 추가 정보 수집
|
||||||
|
if (reviewStatus === 'completed') {
|
||||||
|
// 완료 사진
|
||||||
|
if (completionPhotoBase64) {
|
||||||
|
requestBody.completion_photo = completionPhotoBase64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해결방안
|
||||||
|
const solution = document.getElementById('solutionInput').value.trim();
|
||||||
|
if (solution) {
|
||||||
|
requestBody.solution = solution;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 담당부서
|
||||||
|
const responsibleDepartment = document.getElementById('responsibleDepartmentInput').value;
|
||||||
|
if (responsibleDepartment) {
|
||||||
|
requestBody.responsible_department = responsibleDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 담당자
|
||||||
|
const responsiblePerson = document.getElementById('responsiblePersonInput').value.trim();
|
||||||
|
if (responsiblePerson) {
|
||||||
|
requestBody.responsible_person = responsiblePerson;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/inbox/${currentIssueId}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TokenManager.getToken()}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`상태가 성공적으로 변경되었습니다.\n${result.destination}으로 이동됩니다.`);
|
||||||
|
closeStatusModal();
|
||||||
|
await loadIssues(); // 목록 새로고침
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || '상태 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('상태 변경 오류:', error);
|
||||||
|
alert('상태 변경 중 오류가 발생했습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStatusBadgeClass, getStatusText, getCategoryText, getDisposalReasonText는
|
||||||
|
// issue-helpers.js에서 제공됨
|
||||||
|
|
||||||
|
function getTimeAgo(date) {
|
||||||
|
const now = getKSTDate(new Date());
|
||||||
|
const kstDate = getKSTDate(date);
|
||||||
|
const diffMs = now - kstDate;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return '방금 전';
|
||||||
|
if (diffMins < 60) return `${diffMins}분 전`;
|
||||||
|
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||||
|
if (diffDays < 7) return `${diffDays}일 전`;
|
||||||
|
return formatKSTDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show) {
|
||||||
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
|
if (show) {
|
||||||
|
overlay.classList.add('active');
|
||||||
|
} else {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 스크립트 동적 로딩
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '/static/js/api.js?v=20260213';
|
||||||
|
script.onload = function() {
|
||||||
|
console.log('API 스크립트 로드 완료 (issues-inbox.html)');
|
||||||
|
initializeInbox();
|
||||||
|
};
|
||||||
|
script.onerror = function() {
|
||||||
|
console.error('API 스크립트 로드 실패');
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
2165
system3-nonconformance/web/static/js/pages/issues-management.js
Normal file
2165
system3-nonconformance/web/static/js/pages/issues-management.js
Normal file
File diff suppressed because it is too large
Load Diff
88
system3-nonconformance/web/static/js/utils/issue-helpers.js
Normal file
88
system3-nonconformance/web/static/js/utils/issue-helpers.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* issue-helpers.js — 부적합 관리 공통 유틸리티 함수
|
||||||
|
* dashboard, management, inbox, archive 등에서 공유
|
||||||
|
*/
|
||||||
|
|
||||||
|
function getDepartmentText(department) {
|
||||||
|
const departments = {
|
||||||
|
'production': '생산',
|
||||||
|
'quality': '품질',
|
||||||
|
'purchasing': '구매',
|
||||||
|
'design': '설계',
|
||||||
|
'sales': '영업'
|
||||||
|
};
|
||||||
|
return department ? departments[department] || department : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryText(category) {
|
||||||
|
const categoryMap = {
|
||||||
|
'material_missing': '자재 누락',
|
||||||
|
'design_error': '설계 오류',
|
||||||
|
'incoming_defect': '반입 불량',
|
||||||
|
'inspection_miss': '검사 누락',
|
||||||
|
'quality': '품질',
|
||||||
|
'safety': '안전',
|
||||||
|
'environment': '환경',
|
||||||
|
'process': '공정',
|
||||||
|
'equipment': '장비',
|
||||||
|
'material': '자재',
|
||||||
|
'etc': '기타'
|
||||||
|
};
|
||||||
|
return categoryMap[category] || category || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'new': 'new',
|
||||||
|
'processing': 'processing',
|
||||||
|
'pending': 'pending',
|
||||||
|
'completed': 'completed',
|
||||||
|
'archived': 'archived',
|
||||||
|
'cancelled': 'cancelled'
|
||||||
|
};
|
||||||
|
return statusMap[status] || 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'new': '새 부적합',
|
||||||
|
'processing': '처리 중',
|
||||||
|
'pending': '대기 중',
|
||||||
|
'completed': '완료',
|
||||||
|
'archived': '보관',
|
||||||
|
'cancelled': '취소'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getDisposalReasonText(reason) {
|
||||||
|
const reasonMap = {
|
||||||
|
'duplicate': '중복',
|
||||||
|
'invalid_report': '잘못된 신고',
|
||||||
|
'not_applicable': '해당 없음',
|
||||||
|
'spam': '스팸/오류',
|
||||||
|
'custom': '직접 입력'
|
||||||
|
};
|
||||||
|
return reasonMap[reason] || reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReporterNames(issue) {
|
||||||
|
let names = [issue.reporter?.full_name || issue.reporter?.username || '알 수 없음'];
|
||||||
|
if (issue.duplicate_reporters && issue.duplicate_reporters.length > 0) {
|
||||||
|
const duplicateNames = issue.duplicate_reporters.map(r => r.full_name || r.username);
|
||||||
|
names = names.concat(duplicateNames);
|
||||||
|
}
|
||||||
|
return names.join(', ');
|
||||||
|
}
|
||||||
42
system3-nonconformance/web/static/js/utils/photo-modal.js
Normal file
42
system3-nonconformance/web/static/js/utils/photo-modal.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* photo-modal.js — 사진 확대 모달 공통 모듈
|
||||||
|
* dashboard, management, inbox, issue-view 등에서 공유
|
||||||
|
*/
|
||||||
|
|
||||||
|
function openPhotoModal(photoPath) {
|
||||||
|
if (!photoPath) return;
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'photo-modal-overlay';
|
||||||
|
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="photo-modal-content">
|
||||||
|
<img src="${photoPath}" alt="확대된 사진">
|
||||||
|
<button class="photo-modal-close" onclick="this.closest('.photo-modal-overlay').remove()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// ESC 키로 닫기
|
||||||
|
const handleEsc = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
modal.remove();
|
||||||
|
document.removeEventListener('keydown', handleEsc);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEsc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 코드 호환용 별칭
|
||||||
|
function showImageModal(imagePath) {
|
||||||
|
openPhotoModal(imagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePhotoModal() {
|
||||||
|
const modal = document.querySelector('.photo-modal-overlay');
|
||||||
|
if (modal) modal.remove();
|
||||||
|
}
|
||||||
45
system3-nonconformance/web/static/js/utils/toast.js
Normal file
45
system3-nonconformance/web/static/js/utils/toast.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* toast.js — 토스트 알림 공통 모듈
|
||||||
|
*/
|
||||||
|
|
||||||
|
function showToast(message, type = 'success', duration = 3000) {
|
||||||
|
const existing = document.querySelector('.toast-notification');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: 'fas fa-check-circle',
|
||||||
|
error: 'fas fa-exclamation-circle',
|
||||||
|
warning: 'fas fa-exclamation-triangle',
|
||||||
|
info: 'fas fa-info-circle'
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorMap = {
|
||||||
|
success: 'bg-green-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
info: 'bg-blue-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast-notification fixed top-4 right-4 z-[9999] ${colorMap[type] || colorMap.info} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-2 transform translate-x-full transition-transform duration-300`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i class="${iconMap[type] || iconMap.info}"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.style.transform = 'translateX(0)';
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transform = 'translateX(120%)';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 코드 호환용 별칭
|
||||||
|
function showToastMessage(message, type = 'success') {
|
||||||
|
showToast(message, type);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ FROM nginx:alpine
|
|||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY index.html /usr/share/nginx/html/index.html
|
COPY index.html /usr/share/nginx/html/index.html
|
||||||
|
COPY static/ /usr/share/nginx/html/static/
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,29 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# gzip 압축
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# HTML 캐시 비활성화
|
||||||
|
location ~* \.html$ {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# JS/CSS 캐시 활성화 (버전 쿼리 스트링으로 무효화)
|
||||||
|
location ~* \.(js|css)$ {
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 파일 (이미지 등)
|
||||||
|
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
}
|
||||||
|
|
||||||
# 정적 파일
|
# 정적 파일
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
18
user-management/web/static/css/tkuser.css
Normal file
18
user-management/web/static/css/tkuser.css
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f1f5f9; min-height: 100vh; }
|
||||||
|
.input-field { background: white; border: 1px solid #e2e8f0; transition: all 0.2s; }
|
||||||
|
.input-field:focus { outline: none; border-color: #64748b; box-shadow: 0 0 0 3px rgba(100,116,139,0.1); }
|
||||||
|
.tab-btn { transition: all 0.2s; }
|
||||||
|
.tab-btn.active { background: #334155; color: white; }
|
||||||
|
.tab-btn:not(.active) { color: #64748b; }
|
||||||
|
.tab-btn:not(.active):hover { background: #e2e8f0; }
|
||||||
|
.system-section { border-left: 4px solid; }
|
||||||
|
.system-section.system1 { border-color: #3b82f6; }
|
||||||
|
.system-section.system3 { border-color: #8b5cf6; }
|
||||||
|
.group-header { cursor: pointer; user-select: none; }
|
||||||
|
.group-header:hover { background: #f8fafc; }
|
||||||
|
.perm-item { transition: all 0.15s; }
|
||||||
|
.perm-item.checked { background: #f0f9ff; border-color: #93c5fd; }
|
||||||
|
.toast-message { transition: all 0.3s ease; }
|
||||||
|
.fade-in { opacity: 0; transform: translateY(10px); transition: opacity 0.4s, transform 0.4s; }
|
||||||
|
.fade-in.visible { opacity: 1; transform: translateY(0); }
|
||||||
72
user-management/web/static/js/tkuser-core.js
Normal file
72
user-management/web/static/js/tkuser-core.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* ===== Config ===== */
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
/* ===== Token ===== */
|
||||||
|
function _cookieGet(n) { const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; }
|
||||||
|
function _cookieRemove(n) { let c = n + '=; path=/; max-age=0'; if (location.hostname.includes('technicalkorea.net')) c += '; domain=.technicalkorea.net'; document.cookie = c; }
|
||||||
|
function getToken() { return _cookieGet('sso_token') || localStorage.getItem('sso_token') || localStorage.getItem('access_token'); }
|
||||||
|
function getLoginUrl() {
|
||||||
|
const h = location.hostname;
|
||||||
|
if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href);
|
||||||
|
return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href);
|
||||||
|
}
|
||||||
|
function decodeToken(t) { try { return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); } catch { return null; } }
|
||||||
|
|
||||||
|
/* ===== API ===== */
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(API_BASE + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '', ...(opts.headers||{}) } });
|
||||||
|
if (res.status === 401) { location.href = getLoginUrl(); throw new Error('인증 만료'); }
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || data.detail || '요청 실패');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Toast ===== */
|
||||||
|
function showToast(msg, type = 'success') {
|
||||||
|
document.querySelector('.toast-message')?.remove();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg ${type==='success'?'bg-emerald-500':'bg-red-500'}`;
|
||||||
|
el.innerHTML = `<i class="fas ${type==='success'?'fa-check-circle':'fa-exclamation-circle'} mr-2"></i>${msg}`;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
setTimeout(() => { el.classList.add('opacity-0'); setTimeout(() => el.remove(), 300); }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Helpers ===== */
|
||||||
|
const DEPT = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' };
|
||||||
|
function deptLabel(d) { return DEPT[d] || d || ''; }
|
||||||
|
function formatDate(d) { if (!d) return ''; return d.substring(0, 10); }
|
||||||
|
function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
/* ===== Logout ===== */
|
||||||
|
function doLogout() {
|
||||||
|
if (!confirm('로그아웃?')) return;
|
||||||
|
_cookieRemove('sso_token'); localStorage.removeItem('sso_token'); localStorage.removeItem('access_token'); localStorage.removeItem('currentUser');
|
||||||
|
location.href = getLoginUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== State ===== */
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
/* ===== Init ===== */
|
||||||
|
async function init() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) { location.href = getLoginUrl(); return; }
|
||||||
|
const decoded = decodeToken(token);
|
||||||
|
if (!decoded) { location.href = getLoginUrl(); return; }
|
||||||
|
|
||||||
|
currentUser = { id: decoded.user_id||decoded.id, username: decoded.username||decoded.sub, name: decoded.name||decoded.full_name, role: decoded.role||decoded.access_level };
|
||||||
|
const dn = currentUser.name || currentUser.username;
|
||||||
|
document.getElementById('headerUserName').textContent = dn;
|
||||||
|
document.getElementById('headerUserRole').textContent = currentUser.role === 'admin' ? '관리자' : '사용자';
|
||||||
|
document.getElementById('headerUserAvatar').textContent = dn.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
document.getElementById('tabNav').classList.remove('hidden');
|
||||||
|
document.getElementById('adminSection').classList.remove('hidden');
|
||||||
|
await loadUsers();
|
||||||
|
} else {
|
||||||
|
document.getElementById('passwordChangeSection').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
setTimeout(() => document.querySelector('.fade-in').classList.add('visible'), 50);
|
||||||
|
}
|
||||||
91
user-management/web/static/js/tkuser-departments.js
Normal file
91
user-management/web/static/js/tkuser-departments.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/* ===== Departments CRUD ===== */
|
||||||
|
let departments = [], departmentsLoaded = false;
|
||||||
|
|
||||||
|
async function loadDepartments() {
|
||||||
|
try {
|
||||||
|
const r = await api('/departments'); departments = r.data || r;
|
||||||
|
departmentsLoaded = true;
|
||||||
|
populateParentDeptSelects();
|
||||||
|
displayDepartments();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('departmentList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateParentDeptSelects() {
|
||||||
|
['newDeptParent','editDeptParent'].forEach(id => {
|
||||||
|
const sel = document.getElementById(id); if (!sel) return;
|
||||||
|
const val = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">없음 (최상위)</option>';
|
||||||
|
departments.filter(d => d.is_active !== 0 && d.is_active !== false).forEach(d => {
|
||||||
|
const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o);
|
||||||
|
});
|
||||||
|
sel.value = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDepartments() {
|
||||||
|
const c = document.getElementById('departmentList');
|
||||||
|
if (!departments.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 부서가 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = departments.map(d => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-sitemap mr-1.5 text-gray-400 text-xs"></i>${d.department_name}</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
${d.parent_name ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-500">상위: ${d.parent_name}</span>` : '<span class="px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-500">최상위</span>'}
|
||||||
|
<span class="text-gray-400">순서: ${d.display_order || 0}</span>
|
||||||
|
${d.is_active === 0 || d.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600">활성</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="editDepartment(${d.department_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
${d.is_active !== 0 && d.is_active !== false ? `<button onclick="deactivateDepartment(${d.department_id},'${(d.department_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addDepartmentForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('/departments', { method: 'POST', body: JSON.stringify({
|
||||||
|
department_name: document.getElementById('newDeptName').value.trim(),
|
||||||
|
parent_id: document.getElementById('newDeptParent').value ? parseInt(document.getElementById('newDeptParent').value) : null,
|
||||||
|
description: document.getElementById('newDeptDescription').value.trim() || null,
|
||||||
|
display_order: parseInt(document.getElementById('newDeptOrder').value) || 0
|
||||||
|
})});
|
||||||
|
showToast('부서가 추가되었습니다.'); document.getElementById('addDepartmentForm').reset(); await loadDepartments();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function editDepartment(id) {
|
||||||
|
const d = departments.find(x => x.department_id === id); if (!d) return;
|
||||||
|
document.getElementById('editDeptId').value = d.department_id;
|
||||||
|
document.getElementById('editDeptName').value = d.department_name;
|
||||||
|
document.getElementById('editDeptDescription').value = d.description || '';
|
||||||
|
document.getElementById('editDeptOrder').value = d.display_order || 0;
|
||||||
|
document.getElementById('editDeptActive').value = (d.is_active === 0 || d.is_active === false) ? '0' : '1';
|
||||||
|
populateParentDeptSelects();
|
||||||
|
document.getElementById('editDeptParent').value = d.parent_id || '';
|
||||||
|
document.getElementById('editDepartmentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeDepartmentModal() { document.getElementById('editDepartmentModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
document.getElementById('editDepartmentForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api(`/departments/${document.getElementById('editDeptId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||||
|
department_name: document.getElementById('editDeptName').value.trim(),
|
||||||
|
parent_id: document.getElementById('editDeptParent').value ? parseInt(document.getElementById('editDeptParent').value) : null,
|
||||||
|
description: document.getElementById('editDeptDescription').value.trim() || null,
|
||||||
|
display_order: parseInt(document.getElementById('editDeptOrder').value) || 0,
|
||||||
|
is_active: document.getElementById('editDeptActive').value === '1'
|
||||||
|
})});
|
||||||
|
showToast('수정되었습니다.'); closeDepartmentModal(); await loadDepartments();
|
||||||
|
await loadDepartmentsForSelect();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deactivateDepartment(id, name) {
|
||||||
|
if (!confirm(`"${name}" 부서를 비활성화?`)) return;
|
||||||
|
try { await api(`/departments/${id}`, { method: 'DELETE' }); showToast('부서 비활성화 완료'); await loadDepartments(); } catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
5
user-management/web/static/js/tkuser-issue-types.js
Normal file
5
user-management/web/static/js/tkuser-issue-types.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/* ===== Issue Types ===== */
|
||||||
|
/* Placeholder module for issue type CRUD operations.
|
||||||
|
This file is reserved for future issue category management functionality.
|
||||||
|
Currently, issue types are managed through System 3 permissions in tkuser-users.js.
|
||||||
|
*/
|
||||||
315
user-management/web/static/js/tkuser-layout-map.js
Normal file
315
user-management/web/static/js/tkuser-layout-map.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
/* ===== Layout Map (구역지도) ===== */
|
||||||
|
let layoutMapImage = null;
|
||||||
|
let mapRegions = [];
|
||||||
|
let mapCanvas = null;
|
||||||
|
let mapCtx = null;
|
||||||
|
let isDrawing = false;
|
||||||
|
let drawStartX = 0;
|
||||||
|
let drawStartY = 0;
|
||||||
|
let currentRect = null;
|
||||||
|
let selectedMapCategoryId = null;
|
||||||
|
|
||||||
|
// 구역지도 프리뷰 캔버스 클릭 -> 해당 영역의 작업장으로 드릴다운
|
||||||
|
document.getElementById('previewCanvas')?.addEventListener('click', function(e) {
|
||||||
|
if (!previewMapRegions.length) return;
|
||||||
|
const rect = this.getBoundingClientRect();
|
||||||
|
const xPct = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const yPct = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
for (const region of previewMapRegions) {
|
||||||
|
if (xPct >= region.x_start && xPct <= region.x_end && yPct >= region.y_start && yPct <= region.y_end) {
|
||||||
|
const wp = workplaces.find(w => w.workplace_id === region.workplace_id);
|
||||||
|
if (wp) {
|
||||||
|
selectWorkplaceForEquipments(wp.workplace_id, wp.workplace_name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLayoutPreview(categoryId) {
|
||||||
|
const cat = workplaceCategories.find(c => c.category_id == categoryId);
|
||||||
|
if (!cat || !cat.layout_image) {
|
||||||
|
document.getElementById('layoutPreviewArea').classList.remove('hidden');
|
||||||
|
document.getElementById('layoutPreviewCanvas').classList.add('hidden');
|
||||||
|
document.getElementById('layoutPreviewArea').innerHTML = '<i class="fas fa-image text-3xl mb-2"></i><p>레이아웃 이미지가 없습니다. "지도 설정"에서 업로드하세요.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('layoutPreviewArea').classList.add('hidden');
|
||||||
|
document.getElementById('layoutPreviewCanvas').classList.remove('hidden');
|
||||||
|
|
||||||
|
const pCanvas = document.getElementById('previewCanvas');
|
||||||
|
const pCtx = pCanvas.getContext('2d');
|
||||||
|
|
||||||
|
const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, '');
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async function() {
|
||||||
|
const maxW = 800;
|
||||||
|
const scale = img.width > maxW ? maxW / img.width : 1;
|
||||||
|
pCanvas.width = img.width * scale;
|
||||||
|
pCanvas.height = img.height * scale;
|
||||||
|
pCtx.drawImage(img, 0, 0, pCanvas.width, pCanvas.height);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await api(`/workplaces/categories/${categoryId}/map-regions`);
|
||||||
|
const regions = r.data || [];
|
||||||
|
previewMapRegions = regions;
|
||||||
|
regions.forEach(region => {
|
||||||
|
const x1 = (region.x_start / 100) * pCanvas.width;
|
||||||
|
const y1 = (region.y_start / 100) * pCanvas.height;
|
||||||
|
const x2 = (region.x_end / 100) * pCanvas.width;
|
||||||
|
const y2 = (region.y_end / 100) * pCanvas.height;
|
||||||
|
pCtx.strokeStyle = '#10b981';
|
||||||
|
pCtx.lineWidth = 2;
|
||||||
|
pCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||||
|
pCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||||
|
pCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||||
|
pCtx.fillStyle = '#10b981';
|
||||||
|
pCtx.font = '14px sans-serif';
|
||||||
|
pCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||||
|
});
|
||||||
|
} catch(e) { console.warn('영역 로드 실패:', e); }
|
||||||
|
};
|
||||||
|
img.src = imgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구역지도 모달
|
||||||
|
function openLayoutMapModal() {
|
||||||
|
if (!selectedMapCategoryId) {
|
||||||
|
showToast('공장을 먼저 선택해주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const modal = document.getElementById('layoutMapModal');
|
||||||
|
mapCanvas = document.getElementById('regionCanvas');
|
||||||
|
mapCtx = mapCanvas.getContext('2d');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
loadLayoutMapData();
|
||||||
|
updateRegionWorkplaceSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLayoutMapModal() {
|
||||||
|
const modal = document.getElementById('layoutMapModal');
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
if (mapCanvas) {
|
||||||
|
mapCanvas.removeEventListener('mousedown', onCanvasMouseDown);
|
||||||
|
mapCanvas.removeEventListener('mousemove', onCanvasMouseMove);
|
||||||
|
mapCanvas.removeEventListener('mouseup', onCanvasMouseUp);
|
||||||
|
mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp);
|
||||||
|
}
|
||||||
|
currentRect = null;
|
||||||
|
if (selectedMapCategoryId) loadLayoutPreview(selectedMapCategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLayoutMapData() {
|
||||||
|
try {
|
||||||
|
const cat = workplaceCategories.find(c => c.category_id == selectedMapCategoryId);
|
||||||
|
if (!cat) return;
|
||||||
|
|
||||||
|
const imgDiv = document.getElementById('currentLayoutImage');
|
||||||
|
if (cat.layout_image) {
|
||||||
|
const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, '');
|
||||||
|
imgDiv.innerHTML = `<img src="${imgUrl}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="레이아웃">`;
|
||||||
|
loadImageToCanvas(imgUrl);
|
||||||
|
} else {
|
||||||
|
imgDiv.innerHTML = '<span class="text-sm text-gray-400">업로드된 이미지가 없습니다</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await api(`/workplaces/categories/${selectedMapCategoryId}/map-regions`);
|
||||||
|
mapRegions = r.data || [];
|
||||||
|
renderRegionList();
|
||||||
|
} catch(e) {
|
||||||
|
console.error('레이아웃 데이터 로딩 오류:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageToCanvas(imgUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
const maxW = 800;
|
||||||
|
const scale = img.width > maxW ? maxW / img.width : 1;
|
||||||
|
mapCanvas.width = img.width * scale;
|
||||||
|
mapCanvas.height = img.height * scale;
|
||||||
|
mapCtx.drawImage(img, 0, 0, mapCanvas.width, mapCanvas.height);
|
||||||
|
layoutMapImage = img;
|
||||||
|
drawExistingRegions();
|
||||||
|
setupCanvasEvents();
|
||||||
|
};
|
||||||
|
img.src = imgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRegionWorkplaceSelect() {
|
||||||
|
const sel = document.getElementById('regionWorkplaceSelect');
|
||||||
|
if (!sel) return;
|
||||||
|
const catWps = workplaces.filter(w => w.category_id == selectedMapCategoryId);
|
||||||
|
let html = '<option value="">작업장을 선택하세요</option>';
|
||||||
|
catWps.forEach(wp => {
|
||||||
|
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
|
||||||
|
html += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
|
||||||
|
});
|
||||||
|
sel.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewLayoutImage(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
document.getElementById('currentLayoutImage').innerHTML = `
|
||||||
|
<img src="${e.target.result}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="미리보기">
|
||||||
|
<p class="text-xs text-gray-400 mt-1">미리보기 (저장하려면 "이미지 업로드" 버튼 클릭)</p>`;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLayoutImage() {
|
||||||
|
const file = document.getElementById('layoutImageFile').files[0];
|
||||||
|
if (!file) { showToast('이미지를 선택해주세요.', 'error'); return; }
|
||||||
|
if (!selectedMapCategoryId) { showToast('공장을 먼저 선택해주세요.', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', file);
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/workplaces/categories/${selectedMapCategoryId}/layout-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': token ? `Bearer ${token}` : '' },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) throw new Error(result.error || '업로드 실패');
|
||||||
|
|
||||||
|
showToast('이미지가 업로드되었습니다.');
|
||||||
|
const imgUrl = '/uploads/' + result.data.image_path.replace(/^\/uploads\//, '');
|
||||||
|
document.getElementById('currentLayoutImage').innerHTML = `<img src="${imgUrl}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="레이아웃">`;
|
||||||
|
loadImageToCanvas(imgUrl);
|
||||||
|
|
||||||
|
// 카테고리 데이터 갱신
|
||||||
|
await loadWorkplaceCategories();
|
||||||
|
} catch(e) {
|
||||||
|
showToast(e.message || '업로드 실패', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캔버스 드로잉
|
||||||
|
function setupCanvasEvents() {
|
||||||
|
mapCanvas.removeEventListener('mousedown', onCanvasMouseDown);
|
||||||
|
mapCanvas.removeEventListener('mousemove', onCanvasMouseMove);
|
||||||
|
mapCanvas.removeEventListener('mouseup', onCanvasMouseUp);
|
||||||
|
mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp);
|
||||||
|
mapCanvas.addEventListener('mousedown', onCanvasMouseDown);
|
||||||
|
mapCanvas.addEventListener('mousemove', onCanvasMouseMove);
|
||||||
|
mapCanvas.addEventListener('mouseup', onCanvasMouseUp);
|
||||||
|
mapCanvas.addEventListener('mouseleave', onCanvasMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseDown(e) {
|
||||||
|
const r = mapCanvas.getBoundingClientRect();
|
||||||
|
const scaleX = mapCanvas.width / r.width;
|
||||||
|
const scaleY = mapCanvas.height / r.height;
|
||||||
|
drawStartX = (e.clientX - r.left) * scaleX;
|
||||||
|
drawStartY = (e.clientY - r.top) * scaleY;
|
||||||
|
isDrawing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseMove(e) {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
const r = mapCanvas.getBoundingClientRect();
|
||||||
|
const scaleX = mapCanvas.width / r.width;
|
||||||
|
const scaleY = mapCanvas.height / r.height;
|
||||||
|
const curX = (e.clientX - r.left) * scaleX;
|
||||||
|
const curY = (e.clientY - r.top) * scaleY;
|
||||||
|
|
||||||
|
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
|
||||||
|
if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
|
||||||
|
drawExistingRegions();
|
||||||
|
|
||||||
|
const w = curX - drawStartX;
|
||||||
|
const h = curY - drawStartY;
|
||||||
|
mapCtx.strokeStyle = '#3b82f6';
|
||||||
|
mapCtx.lineWidth = 3;
|
||||||
|
mapCtx.strokeRect(drawStartX, drawStartY, w, h);
|
||||||
|
mapCtx.fillStyle = 'rgba(59, 130, 246, 0.2)';
|
||||||
|
mapCtx.fillRect(drawStartX, drawStartY, w, h);
|
||||||
|
|
||||||
|
currentRect = { startX: drawStartX, startY: drawStartY, endX: curX, endY: curY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasMouseUp() { isDrawing = false; }
|
||||||
|
|
||||||
|
function drawExistingRegions() {
|
||||||
|
mapRegions.forEach(region => {
|
||||||
|
const x1 = (region.x_start / 100) * mapCanvas.width;
|
||||||
|
const y1 = (region.y_start / 100) * mapCanvas.height;
|
||||||
|
const x2 = (region.x_end / 100) * mapCanvas.width;
|
||||||
|
const y2 = (region.y_end / 100) * mapCanvas.height;
|
||||||
|
mapCtx.strokeStyle = '#10b981';
|
||||||
|
mapCtx.lineWidth = 2;
|
||||||
|
mapCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||||
|
mapCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
|
||||||
|
mapCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||||
|
mapCtx.fillStyle = '#10b981';
|
||||||
|
mapCtx.font = '14px sans-serif';
|
||||||
|
mapCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCurrentRegion() {
|
||||||
|
currentRect = null;
|
||||||
|
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
|
||||||
|
if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
|
||||||
|
drawExistingRegions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRegion() {
|
||||||
|
const wpId = document.getElementById('regionWorkplaceSelect').value;
|
||||||
|
if (!wpId) { showToast('작업장을 선택해주세요.', 'error'); return; }
|
||||||
|
if (!currentRect) { showToast('영역을 그려주세요.', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xStart = (Math.min(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2);
|
||||||
|
const yStart = (Math.min(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2);
|
||||||
|
const xEnd = (Math.max(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2);
|
||||||
|
const yEnd = (Math.max(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2);
|
||||||
|
|
||||||
|
const existing = mapRegions.find(r => r.workplace_id == wpId);
|
||||||
|
const body = { workplace_id: parseInt(wpId), category_id: selectedMapCategoryId, x_start: xStart, y_start: yStart, x_end: xEnd, y_end: yEnd, shape: 'rect' };
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await api(`/workplaces/map-regions/${existing.region_id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
} else {
|
||||||
|
await api('/workplaces/map-regions', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('영역이 저장되었습니다.');
|
||||||
|
await loadLayoutMapData();
|
||||||
|
updateRegionWorkplaceSelect();
|
||||||
|
clearCurrentRegion();
|
||||||
|
document.getElementById('regionWorkplaceSelect').value = '';
|
||||||
|
} catch(e) { showToast(e.message || '저장 실패', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRegionList() {
|
||||||
|
const div = document.getElementById('regionList');
|
||||||
|
if (!mapRegions.length) { div.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">정의된 영역이 없습니다</p>'; return; }
|
||||||
|
div.innerHTML = '<div class="space-y-2">' + mapRegions.map(r => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-semibold text-gray-800">${r.workplace_name || ''}</span>
|
||||||
|
<span class="text-xs text-gray-400 ml-2">(${Number(r.x_start).toFixed(1)}%, ${Number(r.y_start).toFixed(1)}%) ~ (${Number(r.x_end).toFixed(1)}%, ${Number(r.y_end).toFixed(1)}%)</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="deleteRegion(${r.region_id})" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded text-xs"><i class="fas fa-trash-alt"></i> 삭제</button>
|
||||||
|
</div>`).join('') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRegion(regionId) {
|
||||||
|
if (!confirm('이 영역을 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await api(`/workplaces/map-regions/${regionId}`, { method: 'DELETE' });
|
||||||
|
showToast('영역이 삭제되었습니다.');
|
||||||
|
await loadLayoutMapData();
|
||||||
|
updateRegionWorkplaceSelect();
|
||||||
|
} catch(e) { showToast(e.message || '삭제 실패', 'error'); }
|
||||||
|
}
|
||||||
92
user-management/web/static/js/tkuser-projects.js
Normal file
92
user-management/web/static/js/tkuser-projects.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/* ===== Projects CRUD ===== */
|
||||||
|
let projects = [], projectsLoaded = false;
|
||||||
|
|
||||||
|
function statusBadge(status, isActive) {
|
||||||
|
if (!isActive || isActive === 0 || isActive === false) return '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>';
|
||||||
|
if (status === 'completed') return '<span class="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-600">완료</span>';
|
||||||
|
return '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">진행중</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
try {
|
||||||
|
const r = await api('/projects'); projects = r.data || r;
|
||||||
|
projectsLoaded = true;
|
||||||
|
displayProjects();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('projectList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayProjects() {
|
||||||
|
const c = document.getElementById('projectList');
|
||||||
|
if (!projects.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 프로젝트가 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = projects.map(p => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-folder mr-1.5 text-gray-400 text-xs"></i>${p.project_name}</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
<span class="font-mono">${p.job_no}</span>
|
||||||
|
${p.site?`<span class="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600">${p.site}</span>`:''}
|
||||||
|
${p.pm?`<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-500">${p.pm}</span>`:''}
|
||||||
|
${statusBadge(p.project_status, p.is_active)}
|
||||||
|
${p.due_date?`<span class="text-gray-400">${formatDate(p.due_date)}</span>`:''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="editProject(${p.project_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
${p.is_active?`<button onclick="deactivateProject(${p.project_id},'${p.project_name.replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>`:''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addProjectForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('/projects', { method: 'POST', body: JSON.stringify({
|
||||||
|
job_no: document.getElementById('newJobNo').value.trim(),
|
||||||
|
project_name: document.getElementById('newProjectName').value.trim(),
|
||||||
|
contract_date: document.getElementById('newContractDate').value || null,
|
||||||
|
due_date: document.getElementById('newDueDate').value || null,
|
||||||
|
site: document.getElementById('newSite').value.trim() || null,
|
||||||
|
pm: document.getElementById('newPm').value.trim() || null
|
||||||
|
})});
|
||||||
|
showToast('프로젝트가 추가되었습니다.'); document.getElementById('addProjectForm').reset(); await loadProjects();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function editProject(id) {
|
||||||
|
const p = projects.find(x => x.project_id === id); if (!p) return;
|
||||||
|
document.getElementById('editProjectId').value = p.project_id;
|
||||||
|
document.getElementById('editJobNo').value = p.job_no;
|
||||||
|
document.getElementById('editProjectName').value = p.project_name;
|
||||||
|
document.getElementById('editContractDate').value = formatDate(p.contract_date);
|
||||||
|
document.getElementById('editDueDate').value = formatDate(p.due_date);
|
||||||
|
document.getElementById('editSite').value = p.site || '';
|
||||||
|
document.getElementById('editPm').value = p.pm || '';
|
||||||
|
document.getElementById('editProjectStatus').value = p.project_status || 'active';
|
||||||
|
document.getElementById('editIsActive').value = p.is_active ? '1' : '0';
|
||||||
|
document.getElementById('editProjectModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeProjectModal() { document.getElementById('editProjectModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
document.getElementById('editProjectForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api(`/projects/${document.getElementById('editProjectId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||||
|
job_no: document.getElementById('editJobNo').value.trim(),
|
||||||
|
project_name: document.getElementById('editProjectName').value.trim(),
|
||||||
|
contract_date: document.getElementById('editContractDate').value || null,
|
||||||
|
due_date: document.getElementById('editDueDate').value || null,
|
||||||
|
site: document.getElementById('editSite').value.trim() || null,
|
||||||
|
pm: document.getElementById('editPm').value.trim() || null,
|
||||||
|
project_status: document.getElementById('editProjectStatus').value,
|
||||||
|
is_active: document.getElementById('editIsActive').value === '1'
|
||||||
|
})});
|
||||||
|
showToast('수정되었습니다.'); closeProjectModal(); await loadProjects();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deactivateProject(id, name) {
|
||||||
|
if (!confirm(`"${name}" 프로젝트를 비활성화?`)) return;
|
||||||
|
try { await api(`/projects/${id}`, { method: 'DELETE' }); showToast('프로젝트 비활성화 완료'); await loadProjects(); } catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
24
user-management/web/static/js/tkuser-tabs.js
Normal file
24
user-management/web/static/js/tkuser-tabs.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/* ===== Tab ===== */
|
||||||
|
function switchTab(name) {
|
||||||
|
document.querySelectorAll('[id^="tab-"]').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.getElementById('tab-' + name)?.classList.remove('hidden');
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
event.currentTarget.classList.add('active');
|
||||||
|
// 사이드바 레이아웃 탭에서 main/nav/header 너비 확장
|
||||||
|
const mainEl = document.querySelector('main');
|
||||||
|
const navInner = document.getElementById('tabNavInner');
|
||||||
|
const headerInner = document.getElementById('headerInner');
|
||||||
|
const wideClass = 'max-w-[1600px]';
|
||||||
|
const defaultClass = 'max-w-7xl';
|
||||||
|
if (name === 'workplaces' || name === 'tasks' || name === 'vacations') {
|
||||||
|
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(defaultClass); el.classList.add(wideClass); });
|
||||||
|
} else {
|
||||||
|
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(wideClass); el.classList.add(defaultClass); });
|
||||||
|
}
|
||||||
|
if (name === 'projects' && !projectsLoaded) loadProjects();
|
||||||
|
if (name === 'workers' && !workersLoaded) loadWorkers();
|
||||||
|
if (name === 'departments' && !departmentsLoaded) loadDepartments();
|
||||||
|
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
|
||||||
|
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
|
||||||
|
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
|
||||||
|
}
|
||||||
218
user-management/web/static/js/tkuser-tasks.js
Normal file
218
user-management/web/static/js/tkuser-tasks.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/* ===== Tasks CRUD ===== */
|
||||||
|
let taskWorkTypes = [], allTasks = [], tasksLoaded = false;
|
||||||
|
let selectedTaskWorkTypeFilter = null;
|
||||||
|
|
||||||
|
async function loadTasksTab() {
|
||||||
|
await loadWorkTypes();
|
||||||
|
await loadTasks();
|
||||||
|
tasksLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkTypes() {
|
||||||
|
try {
|
||||||
|
const r = await api('/tasks/work-types');
|
||||||
|
taskWorkTypes = r.data || [];
|
||||||
|
renderWorkTypeSidebar();
|
||||||
|
populateTaskWorkTypeSelect();
|
||||||
|
} catch(e) { console.warn('공정 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkTypeSidebar() {
|
||||||
|
const c = document.getElementById('workTypeSidebar');
|
||||||
|
if (!c) return;
|
||||||
|
let html = `<div onclick="filterTasksByWorkType(null)" class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${selectedTaskWorkTypeFilter === null ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}">
|
||||||
|
<span class="text-sm font-medium ${selectedTaskWorkTypeFilter === null ? 'text-blue-700' : 'text-gray-700'}">전체</span>
|
||||||
|
<span class="text-xs text-gray-400">${allTasks.length}</span>
|
||||||
|
</div>`;
|
||||||
|
// 카테고리별 그룹핑
|
||||||
|
const grouped = {};
|
||||||
|
taskWorkTypes.forEach(wt => {
|
||||||
|
const cat = wt.category || '미분류';
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push(wt);
|
||||||
|
});
|
||||||
|
Object.keys(grouped).sort().forEach(cat => {
|
||||||
|
html += `<div class="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mt-3 mb-1 px-2">${cat}</div>`;
|
||||||
|
grouped[cat].forEach(wt => {
|
||||||
|
const count = allTasks.filter(t => t.work_type_id === wt.id).length;
|
||||||
|
const isActive = selectedTaskWorkTypeFilter === wt.id;
|
||||||
|
html += `<div onclick="filterTasksByWorkType(${wt.id})" class="group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${isActive ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}">
|
||||||
|
<span class="text-sm ${isActive ? 'font-medium text-blue-700' : 'text-gray-700'} truncate">${wt.name}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span class="text-xs text-gray-400">${count}</span>
|
||||||
|
<button onclick="event.stopPropagation(); editWorkType(${wt.id})" class="p-0.5 text-gray-300 hover:text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity" title="수정"><i class="fas fa-pen text-[10px]"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// 미지정 작업 수
|
||||||
|
const noType = allTasks.filter(t => !t.work_type_id).length;
|
||||||
|
if (noType > 0) {
|
||||||
|
html += `<div onclick="filterTasksByWorkType(0)" class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors mt-2 ${selectedTaskWorkTypeFilter === 0 ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}">
|
||||||
|
<span class="text-sm text-gray-400 italic">미지정</span>
|
||||||
|
<span class="text-xs text-gray-400">${noType}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
c.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateTaskWorkTypeSelect() {
|
||||||
|
const sel = document.getElementById('taskWorkType');
|
||||||
|
if (!sel) return;
|
||||||
|
const val = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">미지정</option>';
|
||||||
|
taskWorkTypes.forEach(wt => {
|
||||||
|
sel.innerHTML += `<option value="${wt.id}">${wt.category ? wt.category + ' > ' : ''}${wt.name}</option>`;
|
||||||
|
});
|
||||||
|
sel.value = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTasksByWorkType(wtId) {
|
||||||
|
selectedTaskWorkTypeFilter = wtId;
|
||||||
|
renderWorkTypeSidebar();
|
||||||
|
displayTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
const r = await api('/tasks');
|
||||||
|
allTasks = r.data || [];
|
||||||
|
renderWorkTypeSidebar();
|
||||||
|
displayTasks();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('taskList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${e.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayTasks() {
|
||||||
|
const c = document.getElementById('taskList');
|
||||||
|
let filtered = allTasks;
|
||||||
|
let label = '전체';
|
||||||
|
if (selectedTaskWorkTypeFilter === 0) {
|
||||||
|
filtered = allTasks.filter(t => !t.work_type_id);
|
||||||
|
label = '미지정';
|
||||||
|
} else if (selectedTaskWorkTypeFilter) {
|
||||||
|
filtered = allTasks.filter(t => t.work_type_id === selectedTaskWorkTypeFilter);
|
||||||
|
const wt = taskWorkTypes.find(w => w.id === selectedTaskWorkTypeFilter);
|
||||||
|
label = wt ? wt.name : '';
|
||||||
|
}
|
||||||
|
document.getElementById('taskFilterLabel').textContent = `- ${label}`;
|
||||||
|
const active = filtered.filter(t => t.is_active).length;
|
||||||
|
const inactive = filtered.length - active;
|
||||||
|
document.getElementById('taskStats').textContent = `활성 ${active} / 비활성 ${inactive}`;
|
||||||
|
|
||||||
|
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-8 text-sm">등록된 작업이 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = filtered.map(t => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate">${escHtml(t.task_name)}</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
${t.work_type_name ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-600">${escHtml(t.work_type_name)}</span>` : '<span class="text-gray-300 italic">미지정</span>'}
|
||||||
|
${t.description ? `<span class="text-gray-400 truncate max-w-[200px]" title="${escHtml(t.description)}">${escHtml(t.description)}</span>` : ''}
|
||||||
|
${t.is_active ? '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600">활성</span>' : '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="editTask(${t.task_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
<button onclick="deleteTask(${t.task_id},'${escHtml(t.task_name).replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 모달
|
||||||
|
function openWorkTypeModal(editId) {
|
||||||
|
document.getElementById('wtEditId').value = '';
|
||||||
|
document.getElementById('workTypeForm').reset();
|
||||||
|
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
|
||||||
|
if (editId) {
|
||||||
|
const wt = taskWorkTypes.find(w => w.id === editId);
|
||||||
|
if (!wt) return;
|
||||||
|
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
|
||||||
|
document.getElementById('wtEditId').value = wt.id;
|
||||||
|
document.getElementById('wtName').value = wt.name || '';
|
||||||
|
document.getElementById('wtCategory').value = wt.category || '';
|
||||||
|
document.getElementById('wtDesc').value = wt.description || '';
|
||||||
|
}
|
||||||
|
document.getElementById('workTypeModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeWorkTypeModal() { document.getElementById('workTypeModal').classList.add('hidden'); }
|
||||||
|
function editWorkType(id) { openWorkTypeModal(id); }
|
||||||
|
|
||||||
|
document.getElementById('workTypeForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const editId = document.getElementById('wtEditId').value;
|
||||||
|
const body = {
|
||||||
|
name: document.getElementById('wtName').value.trim(),
|
||||||
|
category: document.getElementById('wtCategory').value.trim() || null,
|
||||||
|
description: document.getElementById('wtDesc').value.trim() || null
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api(`/tasks/work-types/${editId}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
showToast('공정이 수정되었습니다.');
|
||||||
|
} else {
|
||||||
|
await api('/tasks/work-types', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
showToast('공정이 추가되었습니다.');
|
||||||
|
}
|
||||||
|
closeWorkTypeModal();
|
||||||
|
await loadWorkTypes();
|
||||||
|
await loadTasks();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 작업 모달
|
||||||
|
function openTaskModal(editId) {
|
||||||
|
document.getElementById('taskEditId').value = '';
|
||||||
|
document.getElementById('taskForm').reset();
|
||||||
|
document.getElementById('taskActive').checked = true;
|
||||||
|
document.getElementById('taskModalTitle').textContent = '작업 추가';
|
||||||
|
populateTaskWorkTypeSelect();
|
||||||
|
// 사이드바 필터 선택된 공정 자동 선택
|
||||||
|
if (!editId && selectedTaskWorkTypeFilter && selectedTaskWorkTypeFilter !== 0) {
|
||||||
|
document.getElementById('taskWorkType').value = selectedTaskWorkTypeFilter;
|
||||||
|
}
|
||||||
|
if (editId) {
|
||||||
|
const t = allTasks.find(x => x.task_id === editId);
|
||||||
|
if (!t) return;
|
||||||
|
document.getElementById('taskModalTitle').textContent = '작업 수정';
|
||||||
|
document.getElementById('taskEditId').value = t.task_id;
|
||||||
|
document.getElementById('taskName').value = t.task_name || '';
|
||||||
|
document.getElementById('taskWorkType').value = t.work_type_id || '';
|
||||||
|
document.getElementById('taskDesc').value = t.description || '';
|
||||||
|
document.getElementById('taskActive').checked = !!t.is_active;
|
||||||
|
}
|
||||||
|
document.getElementById('taskModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeTaskModal() { document.getElementById('taskModal').classList.add('hidden'); }
|
||||||
|
function editTask(id) { openTaskModal(id); }
|
||||||
|
|
||||||
|
document.getElementById('taskForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const editId = document.getElementById('taskEditId').value;
|
||||||
|
const body = {
|
||||||
|
task_name: document.getElementById('taskName').value.trim(),
|
||||||
|
work_type_id: document.getElementById('taskWorkType').value ? parseInt(document.getElementById('taskWorkType').value) : null,
|
||||||
|
description: document.getElementById('taskDesc').value.trim() || null,
|
||||||
|
is_active: document.getElementById('taskActive').checked ? 1 : 0
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api(`/tasks/${editId}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
showToast('작업이 수정되었습니다.');
|
||||||
|
} else {
|
||||||
|
await api('/tasks', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
showToast('작업이 추가되었습니다.');
|
||||||
|
}
|
||||||
|
closeTaskModal();
|
||||||
|
await loadTasks();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteTask(id, name) {
|
||||||
|
if (!confirm(`"${name}" 작업을 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/tasks/${id}`, { method: 'DELETE' });
|
||||||
|
showToast('작업이 삭제되었습니다.');
|
||||||
|
await loadTasks();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
385
user-management/web/static/js/tkuser-users.js
Normal file
385
user-management/web/static/js/tkuser-users.js
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
/* ===== Permission Page Definitions ===== */
|
||||||
|
const SYSTEM1_PAGES = {
|
||||||
|
'작업 관리': [
|
||||||
|
{ key: 's1.dashboard', title: '대시보드', icon: 'fa-chart-line', def: true },
|
||||||
|
{ key: 's1.work.tbm', title: 'TBM 관리', icon: 'fa-hard-hat', def: true },
|
||||||
|
{ key: 's1.work.report_create', title: '작업보고서 작성', icon: 'fa-file-pen', def: true },
|
||||||
|
{ key: 's1.work.analysis', title: '작업 분석', icon: 'fa-magnifying-glass-chart', def: false },
|
||||||
|
{ key: 's1.work.nonconformity', title: '부적합 현황', icon: 'fa-triangle-exclamation', def: true },
|
||||||
|
],
|
||||||
|
'공장 관리': [
|
||||||
|
{ key: 's1.factory.repair_management', title: '시설설비 관리', icon: 'fa-wrench', def: false },
|
||||||
|
{ key: 's1.inspection.daily_patrol', title: '일일순회점검', icon: 'fa-clipboard-check', def: false },
|
||||||
|
{ key: 's1.inspection.checkin', title: '출근 체크', icon: 'fa-fingerprint', def: true },
|
||||||
|
{ key: 's1.inspection.work_status', title: '근무 현황', icon: 'fa-user-clock', def: false },
|
||||||
|
],
|
||||||
|
'안전 관리': [
|
||||||
|
{ key: 's1.safety.visit_request', title: '출입 신청', icon: 'fa-id-badge', def: true },
|
||||||
|
{ key: 's1.safety.management', title: '안전 관리', icon: 'fa-fire-extinguisher', def: false },
|
||||||
|
{ key: 's1.safety.checklist_manage', title: '체크리스트 관리', icon: 'fa-list-check', def: false },
|
||||||
|
],
|
||||||
|
'근태 관리': [
|
||||||
|
{ key: 's1.attendance.my_vacation_info', title: '내 연차 정보', icon: 'fa-umbrella-beach', def: true },
|
||||||
|
{ key: 's1.attendance.monthly', title: '월간 근태', icon: 'fa-calendar-days', def: true },
|
||||||
|
{ key: 's1.attendance.vacation_request', title: '휴가 신청', icon: 'fa-paper-plane', def: true },
|
||||||
|
{ key: 's1.attendance.vacation_management', title: '휴가 관리', icon: 'fa-calendar-check', def: false },
|
||||||
|
{ key: 's1.attendance.vacation_allocation', title: '휴가 발생 입력', icon: 'fa-calendar-plus', def: false },
|
||||||
|
{ key: 's1.attendance.annual_overview', title: '연간 휴가 현황', icon: 'fa-chart-pie', def: false },
|
||||||
|
],
|
||||||
|
'시스템 관리': [
|
||||||
|
{ key: 's1.admin.workers', title: '작업자 관리', icon: 'fa-people-group', def: false },
|
||||||
|
{ key: 's1.admin.projects', title: '프로젝트 관리', icon: 'fa-folder-open', def: false },
|
||||||
|
{ key: 's1.admin.tasks', title: '작업 관리', icon: 'fa-list-check', def: false },
|
||||||
|
{ key: 's1.admin.workplaces', title: '작업장 관리', icon: 'fa-warehouse', def: false },
|
||||||
|
{ key: 's1.admin.equipments', title: '설비 관리', icon: 'fa-gears', def: false },
|
||||||
|
{ key: 's1.admin.issue_categories', title: '신고 카테고리', icon: 'fa-tags', def: false },
|
||||||
|
{ key: 's1.admin.attendance_report', title: '출퇴근-보고서 대조', icon: 'fa-scale-balanced', def: false },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM3_PAGES = {
|
||||||
|
'메인': [
|
||||||
|
{ key: 'issues_dashboard', title: '현황판', icon: 'fa-chart-line', def: true },
|
||||||
|
{ key: 'issues_inbox', title: '수신함', icon: 'fa-inbox', def: true },
|
||||||
|
{ key: 'issues_management', title: '관리함', icon: 'fa-cog', def: false },
|
||||||
|
{ key: 'issues_archive', title: '폐기함', icon: 'fa-archive', def: false },
|
||||||
|
],
|
||||||
|
'업무': [
|
||||||
|
{ key: 'daily_work', title: '일일 공수', icon: 'fa-calendar-check', def: false },
|
||||||
|
{ key: 'projects_manage', title: '프로젝트 관리', icon: 'fa-folder-open', def: false },
|
||||||
|
],
|
||||||
|
'보고서': [
|
||||||
|
{ key: 'reports', title: '보고서', icon: 'fa-chart-bar', def: false },
|
||||||
|
{ key: 'reports_daily', title: '일일보고서', icon: 'fa-file-excel', def: false },
|
||||||
|
{ key: 'reports_weekly', title: '주간보고서', icon: 'fa-calendar-week', def: false },
|
||||||
|
{ key: 'reports_monthly', title: '월간보고서', icon: 'fa-calendar-alt', def: false },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===== Users State ===== */
|
||||||
|
let users = [], selectedUserId = null, currentPermissions = {};
|
||||||
|
|
||||||
|
/* ===== Users CRUD ===== */
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const r = await api('/users'); users = r.data || r;
|
||||||
|
displayUsers(); updatePermissionUserSelect();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('userList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function displayUsers() {
|
||||||
|
const c = document.getElementById('userList');
|
||||||
|
if (!users.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 사용자가 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = users.map(u => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-user mr-1.5 text-gray-400 text-xs"></i>${u.name||u.username}</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
<span>${u.username}</span>
|
||||||
|
${u.department?`<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${deptLabel(u.department)}</span>`:''}
|
||||||
|
<span class="px-1.5 py-0.5 rounded ${u.role==='admin'?'bg-red-50 text-red-600':'bg-slate-50 text-slate-500'}">${u.role==='admin'?'관리자':'사용자'}</span>
|
||||||
|
${u.is_active===0||u.is_active===false?'<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>':''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="editUser(${u.user_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
<button onclick="resetPassword(${u.user_id},'${u.username}')" class="p-1.5 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded" title="비밀번호 초기화"><i class="fas fa-key text-xs"></i></button>
|
||||||
|
${u.username!=='hyungi'?`<button onclick="deleteUser(${u.user_id},'${u.username}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>`:''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addUserForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department: document.getElementById('newDepartment').value||null, role: document.getElementById('newRole').value }) });
|
||||||
|
showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers();
|
||||||
|
} catch(e) { showToast(e.message,'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function editUser(id) {
|
||||||
|
const u = users.find(x=>x.user_id===id); if(!u) return;
|
||||||
|
document.getElementById('editUserId').value=u.user_id; document.getElementById('editUsername').value=u.username;
|
||||||
|
document.getElementById('editFullName').value=u.name||''; document.getElementById('editDepartment').value=u.department||''; document.getElementById('editRole').value=u.role;
|
||||||
|
document.getElementById('editUserModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
document.getElementById('editUserForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department: document.getElementById('editDepartment').value||null, role: document.getElementById('editRole').value }) });
|
||||||
|
showToast('수정되었습니다.'); closeEditModal(); await loadUsers();
|
||||||
|
} catch(e) { showToast(e.message,'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resetPassword(id, name) {
|
||||||
|
if (!confirm(`${name}의 비밀번호를 "000000"으로 초기화?`)) return;
|
||||||
|
try { await api(`/users/${id}/reset-password`,{method:'POST',body:JSON.stringify({new_password:'000000'})}); showToast(`${name} 비밀번호 초기화 완료`); } catch(e) { showToast(e.message,'error'); }
|
||||||
|
}
|
||||||
|
async function deleteUser(id, name) {
|
||||||
|
if (!confirm(`${name}을(를) 비활성화?`)) return;
|
||||||
|
try { await api(`/users/${id}`,{method:'DELETE'}); showToast('비활성화 완료'); await loadUsers(); } catch(e) { showToast(e.message,'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('changePasswordForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const np = document.getElementById('newPasswordChange').value;
|
||||||
|
if (np !== document.getElementById('confirmPassword').value) { showToast('비밀번호 불일치','error'); return; }
|
||||||
|
try {
|
||||||
|
await api('/users/change-password',{method:'POST',body:JSON.stringify({current_password:document.getElementById('currentPassword').value,new_password:np})});
|
||||||
|
showToast('비밀번호 변경 완료'); document.getElementById('changePasswordForm').reset();
|
||||||
|
} catch(e) { showToast(e.message,'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ===== Permissions ===== */
|
||||||
|
function updatePermissionUserSelect() {
|
||||||
|
const sel = document.getElementById('permissionUserSelect');
|
||||||
|
sel.innerHTML = '<option value="">사용자 선택</option>';
|
||||||
|
users.filter(u=>u.role==='user').forEach(u => { const o=document.createElement('option'); o.value=u.user_id; o.textContent=`${u.name||u.username} (${u.username})`; sel.appendChild(o); });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('permissionUserSelect').addEventListener('change', async e => {
|
||||||
|
selectedUserId = e.target.value;
|
||||||
|
if (selectedUserId) {
|
||||||
|
await loadUserPermissions(selectedUserId);
|
||||||
|
renderPermissionGrid();
|
||||||
|
document.getElementById('permissionPanel').classList.remove('hidden');
|
||||||
|
document.getElementById('permissionEmpty').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('permissionPanel').classList.add('hidden');
|
||||||
|
document.getElementById('permissionEmpty').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadUserPermissions(userId) {
|
||||||
|
// 기본값 세팅
|
||||||
|
currentPermissions = {};
|
||||||
|
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES };
|
||||||
|
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; });
|
||||||
|
try {
|
||||||
|
const perms = await api(`/users/${userId}/page-permissions`);
|
||||||
|
(Array.isArray(perms)?perms:[]).forEach(p => { currentPermissions[p.page_name] = !!p.can_access; });
|
||||||
|
} catch(e) { console.warn('권한 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPermissionGrid() {
|
||||||
|
renderSystemPerms('s1-perms', SYSTEM1_PAGES, 'blue');
|
||||||
|
renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSystemPerms(containerId, pageDef, color) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
let html = '';
|
||||||
|
Object.entries(pageDef).forEach(([groupName, pages]) => {
|
||||||
|
const groupId = containerId + '-' + groupName.replace(/\s/g,'');
|
||||||
|
const allChecked = pages.every(p => currentPermissions[p.key]);
|
||||||
|
html += `
|
||||||
|
<div>
|
||||||
|
<div class="group-header flex items-center justify-between py-2 px-1 rounded" onclick="toggleGroup('${groupId}')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform" id="arrow-${groupId}"></i>
|
||||||
|
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">${groupName}</span>
|
||||||
|
<span class="text-xs text-gray-400">${pages.length}</span>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer" onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox" ${allChecked?'checked':''} onchange="toggleGroupAll('${groupId}', this.checked)"
|
||||||
|
class="h-3.5 w-3.5 text-${color}-500 rounded border-gray-300">
|
||||||
|
<span>전체</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="${groupId}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
|
||||||
|
${pages.map(p => {
|
||||||
|
const checked = currentPermissions[p.key] || false;
|
||||||
|
return `
|
||||||
|
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
|
||||||
|
<input type="checkbox" id="perm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
|
||||||
|
onchange="onPermChange(this)">
|
||||||
|
<i class="fas ${p.icon} text-sm ${checked?`text-${color}-500`:'text-gray-400'}" data-color="${color}"></i>
|
||||||
|
<span class="text-sm text-gray-700">${p.title}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPermChange(cb) {
|
||||||
|
const item = cb.closest('.perm-item');
|
||||||
|
const icon = item.querySelector('i[data-color]');
|
||||||
|
const color = icon.dataset.color;
|
||||||
|
item.classList.toggle('checked', cb.checked);
|
||||||
|
icon.classList.toggle(`text-${color}-500`, cb.checked);
|
||||||
|
icon.classList.toggle('text-gray-400', !cb.checked);
|
||||||
|
// 그룹 전체 체크박스 동기화
|
||||||
|
const group = item.dataset.group;
|
||||||
|
const groupCbs = document.querySelectorAll(`[data-group="${group}"] input[type="checkbox"]`);
|
||||||
|
const allChecked = [...groupCbs].every(c => c.checked);
|
||||||
|
const groupHeader = document.getElementById(group)?.previousElementSibling;
|
||||||
|
if (groupHeader) { const gc = groupHeader.querySelector('input[type="checkbox"]'); if(gc) gc.checked = allChecked; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(groupId) {
|
||||||
|
const el = document.getElementById(groupId);
|
||||||
|
const arrow = document.getElementById('arrow-' + groupId);
|
||||||
|
el.classList.toggle('hidden');
|
||||||
|
arrow?.classList.toggle('-rotate-90');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroupAll(groupId, checked) {
|
||||||
|
document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
onPermChange(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSystemAll(prefix, checked) {
|
||||||
|
const containerId = prefix === 's1' ? 's1-perms' : 's3-perms';
|
||||||
|
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
onPermChange(cb);
|
||||||
|
});
|
||||||
|
// 그룹 전체 체크박스도 동기화
|
||||||
|
document.querySelectorAll(`#${containerId} .group-header input[type="checkbox"]`).forEach(cb => cb.checked = checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
document.getElementById('savePermissionsBtn').addEventListener('click', async () => {
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
const btn = document.getElementById('savePermissionsBtn');
|
||||||
|
const st = document.getElementById('permissionSaveStatus');
|
||||||
|
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat()];
|
||||||
|
const permissions = allPages.map(p => {
|
||||||
|
const cb = document.getElementById('perm_' + p.key);
|
||||||
|
return { page_name: p.key, can_access: cb ? cb.checked : false };
|
||||||
|
});
|
||||||
|
await api('/permissions/bulk-grant', { method:'POST', body: JSON.stringify({ user_id: parseInt(selectedUserId), permissions }) });
|
||||||
|
st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600';
|
||||||
|
showToast('권한이 저장되었습니다.');
|
||||||
|
setTimeout(() => { st.textContent = ''; }, 3000);
|
||||||
|
} catch(e) {
|
||||||
|
st.textContent = e.message; st.className = 'text-sm text-red-500';
|
||||||
|
showToast('저장 실패: ' + e.message, 'error');
|
||||||
|
} finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-2"></i>권한 저장'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ===== Workers CRUD ===== */
|
||||||
|
let workers = [], workersLoaded = false, departmentsForSelect = [];
|
||||||
|
|
||||||
|
const JOB_TYPE = { leader: '반장', worker: '작업자' };
|
||||||
|
function jobTypeBadge(t) {
|
||||||
|
if (t === 'leader') return '<span class="px-1.5 py-0.5 rounded text-xs bg-amber-50 text-amber-600">반장</span>';
|
||||||
|
if (t === 'worker') return '<span class="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-600">작업자</span>';
|
||||||
|
return t ? `<span class="px-1.5 py-0.5 rounded text-xs bg-gray-50 text-gray-500">${t}</span>` : '';
|
||||||
|
}
|
||||||
|
function workerStatusBadge(s) {
|
||||||
|
if (s === 'inactive') return '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>';
|
||||||
|
return '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">재직</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDepartmentsForSelect() {
|
||||||
|
try {
|
||||||
|
const r = await api('/departments'); departmentsForSelect = (r.data || r).filter(d => d.is_active !== 0 && d.is_active !== false);
|
||||||
|
populateDeptSelects();
|
||||||
|
} catch(e) { console.warn('부서 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
function populateDeptSelects() {
|
||||||
|
['newWorkerDept','editWorkerDept'].forEach(id => {
|
||||||
|
const sel = document.getElementById(id); if (!sel) return;
|
||||||
|
const val = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">선택</option>';
|
||||||
|
departmentsForSelect.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); });
|
||||||
|
sel.value = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkers() {
|
||||||
|
await loadDepartmentsForSelect();
|
||||||
|
try {
|
||||||
|
const r = await api('/workers'); workers = r.data || r;
|
||||||
|
workersLoaded = true;
|
||||||
|
displayWorkers();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('workerList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayWorkers() {
|
||||||
|
const c = document.getElementById('workerList');
|
||||||
|
if (!workers.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 작업자가 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = workers.map(w => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-hard-hat mr-1.5 text-gray-400 text-xs"></i>${w.worker_name}</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
${jobTypeBadge(w.job_type)}
|
||||||
|
${w.department_name ? `<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${w.department_name}</span>` : ''}
|
||||||
|
${workerStatusBadge(w.status)}
|
||||||
|
${w.phone_number ? `<span class="text-gray-400">${w.phone_number}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="editWorker(${w.worker_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
${w.status !== 'inactive' ? `<button onclick="deactivateWorker(${w.worker_id},'${(w.worker_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addWorkerForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('/workers', { method: 'POST', body: JSON.stringify({
|
||||||
|
worker_name: document.getElementById('newWorkerName').value.trim(),
|
||||||
|
job_type: document.getElementById('newJobType').value || null,
|
||||||
|
department_id: document.getElementById('newWorkerDept').value ? parseInt(document.getElementById('newWorkerDept').value) : null,
|
||||||
|
phone_number: document.getElementById('newWorkerPhone').value.trim() || null,
|
||||||
|
hire_date: document.getElementById('newWorkerHireDate').value || null,
|
||||||
|
notes: document.getElementById('newWorkerNotes').value.trim() || null
|
||||||
|
})});
|
||||||
|
showToast('작업자가 추가되었습니다.'); document.getElementById('addWorkerForm').reset(); await loadWorkers();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function editWorker(id) {
|
||||||
|
const w = workers.find(x => x.worker_id === id); if (!w) return;
|
||||||
|
document.getElementById('editWorkerId').value = w.worker_id;
|
||||||
|
document.getElementById('editWorkerName').value = w.worker_name;
|
||||||
|
document.getElementById('editJobType').value = w.job_type || '';
|
||||||
|
document.getElementById('editWorkerDept').value = w.department_id || '';
|
||||||
|
document.getElementById('editWorkerPhone').value = w.phone_number || '';
|
||||||
|
document.getElementById('editWorkerHireDate').value = formatDate(w.hire_date);
|
||||||
|
document.getElementById('editWorkerNotes').value = w.notes || '';
|
||||||
|
document.getElementById('editWorkerStatus').value = w.status || 'active';
|
||||||
|
document.getElementById('editEmploymentStatus').value = w.employment_status || 'employed';
|
||||||
|
populateDeptSelects();
|
||||||
|
document.getElementById('editWorkerDept').value = w.department_id || '';
|
||||||
|
document.getElementById('editWorkerModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeWorkerModal() { document.getElementById('editWorkerModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
document.getElementById('editWorkerForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api(`/workers/${document.getElementById('editWorkerId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||||
|
worker_name: document.getElementById('editWorkerName').value.trim(),
|
||||||
|
job_type: document.getElementById('editJobType').value || null,
|
||||||
|
department_id: document.getElementById('editWorkerDept').value ? parseInt(document.getElementById('editWorkerDept').value) : null,
|
||||||
|
phone_number: document.getElementById('editWorkerPhone').value.trim() || null,
|
||||||
|
hire_date: document.getElementById('editWorkerHireDate').value || null,
|
||||||
|
notes: document.getElementById('editWorkerNotes').value.trim() || null,
|
||||||
|
status: document.getElementById('editWorkerStatus').value,
|
||||||
|
employment_status: document.getElementById('editEmploymentStatus').value
|
||||||
|
})});
|
||||||
|
showToast('수정되었습니다.'); closeWorkerModal(); await loadWorkers();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deactivateWorker(id, name) {
|
||||||
|
if (!confirm(`"${name}" 작업자를 비활성화?`)) return;
|
||||||
|
try { await api(`/workers/${id}`, { method: 'DELETE' }); showToast('작업자 비활성화 완료'); await loadWorkers(); } catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
310
user-management/web/static/js/tkuser-vacations.js
Normal file
310
user-management/web/static/js/tkuser-vacations.js
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/* ===== Vacation CRUD ===== */
|
||||||
|
let vacTypes = [], vacBalances = [], vacationsLoaded = false, vacWorkers = [];
|
||||||
|
|
||||||
|
async function loadVacationsTab() {
|
||||||
|
// 연도 셀렉트 초기화
|
||||||
|
const sel = document.getElementById('vacYear');
|
||||||
|
if (sel && !sel.children.length) {
|
||||||
|
const curYear = new Date().getFullYear();
|
||||||
|
for (let y = curYear + 1; y >= curYear - 2; y--) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = y; o.textContent = y + '년';
|
||||||
|
if (y === curYear) o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadVacTypes();
|
||||||
|
await loadVacWorkers();
|
||||||
|
await loadVacBalances();
|
||||||
|
vacationsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVacTypes() {
|
||||||
|
try {
|
||||||
|
const r = await api('/vacations/types?all=true');
|
||||||
|
vacTypes = r.data || [];
|
||||||
|
renderVacTypeSidebar();
|
||||||
|
} catch(e) { console.warn('휴가 유형 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVacWorkers() {
|
||||||
|
try {
|
||||||
|
const r = await api('/workers');
|
||||||
|
vacWorkers = (r.data || []).filter(w => w.status !== 'inactive');
|
||||||
|
} catch(e) { console.warn('작업자 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVacTypeSidebar() {
|
||||||
|
const c = document.getElementById('vacTypeSidebar');
|
||||||
|
if (!c) return;
|
||||||
|
if (!vacTypes.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 유형이 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = vacTypes.map(vt => `
|
||||||
|
<div class="group flex items-center justify-between p-2 rounded-lg ${vt.is_active ? 'bg-gray-50' : 'bg-gray-50 opacity-50'} hover:bg-blue-50 transition-colors">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate flex items-center gap-1.5">
|
||||||
|
${vt.type_name}
|
||||||
|
${vt.is_system ? '<span class="text-[10px] px-1 py-0.5 rounded bg-blue-50 text-blue-500">시스템</span>' : ''}
|
||||||
|
${vt.is_special ? '<span class="text-[10px] px-1 py-0.5 rounded bg-purple-50 text-purple-500">특별</span>' : ''}
|
||||||
|
${!vt.is_active ? '<span class="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-0.5">
|
||||||
|
${vt.type_code} | 차감 ${vt.deduct_days}일 | 우선순위 ${vt.priority}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-0.5 ml-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button onclick="editVacType(${vt.id})" class="p-1 text-slate-400 hover:text-slate-600 rounded" title="수정"><i class="fas fa-pen text-[10px]"></i></button>
|
||||||
|
${!vt.is_system ? `<button onclick="deleteVacType(${vt.id},'${(vt.type_name||'').replace(/'/g,"\\'")}')" class="p-1 text-red-300 hover:text-red-500 rounded" title="비활성화"><i class="fas fa-ban text-[10px]"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유형 모달
|
||||||
|
function openVacTypeModal(editId) {
|
||||||
|
document.getElementById('vtEditId').value = '';
|
||||||
|
document.getElementById('vacTypeForm').reset();
|
||||||
|
document.getElementById('vtDeductDays').value = '1.0';
|
||||||
|
document.getElementById('vtPriority').value = '99';
|
||||||
|
document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 추가';
|
||||||
|
document.getElementById('vtCode').readOnly = false;
|
||||||
|
if (editId) {
|
||||||
|
const vt = vacTypes.find(v => v.id === editId);
|
||||||
|
if (!vt) return;
|
||||||
|
document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 수정';
|
||||||
|
document.getElementById('vtEditId').value = vt.id;
|
||||||
|
document.getElementById('vtCode').value = vt.type_code;
|
||||||
|
document.getElementById('vtCode').readOnly = !!vt.is_system;
|
||||||
|
document.getElementById('vtName').value = vt.type_name;
|
||||||
|
document.getElementById('vtDeductDays').value = vt.deduct_days;
|
||||||
|
document.getElementById('vtPriority').value = vt.priority;
|
||||||
|
document.getElementById('vtDescription').value = vt.description || '';
|
||||||
|
document.getElementById('vtSpecial').checked = !!vt.is_special;
|
||||||
|
}
|
||||||
|
document.getElementById('vacTypeModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeVacTypeModal() { document.getElementById('vacTypeModal').classList.add('hidden'); }
|
||||||
|
function editVacType(id) { openVacTypeModal(id); }
|
||||||
|
|
||||||
|
document.getElementById('vacTypeForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const editId = document.getElementById('vtEditId').value;
|
||||||
|
const body = {
|
||||||
|
type_code: document.getElementById('vtCode').value.trim().toUpperCase(),
|
||||||
|
type_name: document.getElementById('vtName').value.trim(),
|
||||||
|
deduct_days: parseFloat(document.getElementById('vtDeductDays').value) || 1.0,
|
||||||
|
priority: parseInt(document.getElementById('vtPriority').value) || 99,
|
||||||
|
description: document.getElementById('vtDescription').value.trim() || null,
|
||||||
|
is_special: document.getElementById('vtSpecial').checked
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api(`/vacations/types/${editId}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
showToast('휴가 유형이 수정되었습니다.');
|
||||||
|
} else {
|
||||||
|
await api('/vacations/types', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
showToast('휴가 유형이 추가되었습니다.');
|
||||||
|
}
|
||||||
|
closeVacTypeModal(); await loadVacTypes();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteVacType(id, name) {
|
||||||
|
if (!confirm(`"${name}" 유형을 비활성화하시겠습니까?`)) return;
|
||||||
|
try { await api(`/vacations/types/${id}`, { method: 'DELETE' }); showToast('비활성화되었습니다.'); await loadVacTypes(); }
|
||||||
|
catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연차 배정 테이블
|
||||||
|
async function loadVacBalances() {
|
||||||
|
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
||||||
|
try {
|
||||||
|
const r = await api(`/vacations/balances/year/${year}`);
|
||||||
|
vacBalances = r.data || [];
|
||||||
|
renderVacBalanceTable();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('vacBalanceTable').innerHTML = `<div class="text-red-500 text-center py-6"><p class="text-sm">${e.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vacDeptCollapsed = {};
|
||||||
|
|
||||||
|
function toggleVacDept(deptName) {
|
||||||
|
vacDeptCollapsed[deptName] = !vacDeptCollapsed[deptName];
|
||||||
|
const body = document.getElementById('vacDept_' + CSS.escape(deptName));
|
||||||
|
const icon = document.getElementById('vacDeptIcon_' + CSS.escape(deptName));
|
||||||
|
if (body) body.classList.toggle('hidden');
|
||||||
|
if (icon) icon.style.transform = vacDeptCollapsed[deptName] ? 'rotate(-90deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVacBalanceTable() {
|
||||||
|
const c = document.getElementById('vacBalanceTable');
|
||||||
|
if (!vacBalances.length) {
|
||||||
|
c.innerHTML = '<div class="text-gray-400 text-center py-8 text-sm"><i class="fas fa-calendar-xmark text-3xl mb-2"></i><p>배정된 연차가 없습니다. "자동 계산" 또는 "개별 배정"을 이용하세요.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 부서 -> 작업자 -> 휴가유형 그룹핑
|
||||||
|
const deptMap = {};
|
||||||
|
vacBalances.forEach(b => {
|
||||||
|
const deptName = b.department_name || '미배정';
|
||||||
|
if (!deptMap[deptName]) deptMap[deptName] = {};
|
||||||
|
if (!deptMap[deptName][b.worker_id]) deptMap[deptName][b.worker_id] = { name: b.worker_name, hire_date: b.hire_date, items: [] };
|
||||||
|
deptMap[deptName][b.worker_id].items.push(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deptNames = Object.keys(deptMap).sort((a, b) => a === '미배정' ? 1 : b === '미배정' ? -1 : a.localeCompare(b));
|
||||||
|
let html = '<div class="space-y-3">';
|
||||||
|
|
||||||
|
deptNames.forEach(deptName => {
|
||||||
|
const workers = deptMap[deptName];
|
||||||
|
const workerCount = Object.keys(workers).length;
|
||||||
|
// 부서 합계
|
||||||
|
let dTotal = 0, dUsed = 0;
|
||||||
|
Object.values(workers).forEach(g => g.items.forEach(b => { dTotal += parseFloat(b.total_days) || 0; dUsed += parseFloat(b.used_days) || 0; }));
|
||||||
|
const dRemain = dTotal - dUsed;
|
||||||
|
const usagePct = dTotal > 0 ? Math.round((dUsed / dTotal) * 100) : 0;
|
||||||
|
const barColor = usagePct >= 80 ? 'bg-red-400' : usagePct >= 50 ? 'bg-amber-400' : 'bg-emerald-400';
|
||||||
|
const collapsed = vacDeptCollapsed[deptName];
|
||||||
|
const eid = CSS.escape(deptName);
|
||||||
|
|
||||||
|
html += `<div class="border border-gray-200 rounded-lg overflow-hidden">`;
|
||||||
|
// 헤더 (클릭으로 접기/펼치기)
|
||||||
|
html += `<div onclick="toggleVacDept('${escHtml(deptName).replace(/'/g, "\\'")}')" class="flex items-center justify-between px-4 py-3 bg-slate-50 cursor-pointer hover:bg-slate-100 select-none transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i id="vacDeptIcon_${eid}" class="fas fa-chevron-down text-xs text-gray-400 transition-transform" style="${collapsed ? 'transform:rotate(-90deg)' : ''}"></i>
|
||||||
|
<span class="text-sm font-bold text-gray-700"><i class="fas fa-building mr-1.5 text-blue-400"></i>${escHtml(deptName)}</span>
|
||||||
|
<span class="text-xs text-gray-400">${workerCount}명</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="text-gray-400">배정 <b class="text-gray-600">${dTotal}</b></span>
|
||||||
|
<span class="text-gray-400">사용 <b class="text-gray-600">${dUsed}</b></span>
|
||||||
|
<span class="text-gray-400">잔여 <b class="${dRemain <= 0 ? 'text-red-500' : 'text-emerald-600'}">${dRemain}</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div class="${barColor} h-full rounded-full transition-all" style="width:${usagePct}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium ${usagePct >= 80 ? 'text-red-500' : usagePct >= 50 ? 'text-amber-500' : 'text-emerald-600'}">${usagePct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
// 테이블 본문
|
||||||
|
html += `<div id="vacDept_${eid}" class="${collapsed ? 'hidden' : ''}">`;
|
||||||
|
html += '<table class="w-full text-sm"><thead class="bg-gray-50"><tr>';
|
||||||
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">작업자</th>';
|
||||||
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">입사일</th>';
|
||||||
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">휴가유형</th>';
|
||||||
|
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">배정</th>';
|
||||||
|
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">사용</th>';
|
||||||
|
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">잔여</th>';
|
||||||
|
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">비고</th>';
|
||||||
|
html += '<th class="px-3 py-2 text-xs font-semibold text-gray-500 w-16"></th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
Object.values(workers).forEach(g => {
|
||||||
|
g.items.forEach((b, i) => {
|
||||||
|
const remaining = parseFloat(b.remaining_days || (b.total_days - b.used_days));
|
||||||
|
const remClass = remaining <= 0 ? 'text-red-500 font-semibold' : remaining <= 3 ? 'text-amber-500 font-medium' : 'text-emerald-600';
|
||||||
|
html += `<tr class="border-t border-gray-100 hover:bg-gray-50">`;
|
||||||
|
if (i === 0) {
|
||||||
|
html += `<td class="px-3 py-2 font-medium text-gray-800" rowspan="${g.items.length}">${escHtml(g.name)}</td>`;
|
||||||
|
html += `<td class="px-3 py-2 text-xs text-gray-400" rowspan="${g.items.length}">${g.hire_date ? new Date(g.hire_date).toISOString().substring(0,10) : '-'}</td>`;
|
||||||
|
}
|
||||||
|
html += `<td class="px-3 py-2"><span class="px-1.5 py-0.5 rounded text-xs bg-slate-50 text-slate-600">${escHtml(b.type_name)}</span></td>`;
|
||||||
|
html += `<td class="px-3 py-2 text-center">${b.total_days}</td>`;
|
||||||
|
html += `<td class="px-3 py-2 text-center">${b.used_days}</td>`;
|
||||||
|
html += `<td class="px-3 py-2 text-center ${remClass}">${remaining}</td>`;
|
||||||
|
html += `<td class="px-3 py-2 text-xs text-gray-400 truncate max-w-[150px]" title="${escHtml(b.notes||'')}">${escHtml(b.notes||'')}</td>`;
|
||||||
|
html += `<td class="px-3 py-2 text-center">
|
||||||
|
<button onclick="editVacBalance(${b.id})" class="p-1 text-slate-400 hover:text-slate-600" title="수정"><i class="fas fa-pen text-xs"></i></button>
|
||||||
|
<button onclick="deleteVacBalance(${b.id})" class="p-1 text-red-300 hover:text-red-500" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
|
||||||
|
</td>`;
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
c.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 계산
|
||||||
|
async function autoCalcVacation() {
|
||||||
|
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
||||||
|
if (!confirm(`${year}년 전체 작업자 연차를 입사일 기준으로 자동 계산합니다.\n기존 배정이 있으면 덮어씁니다. 진행하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
const r = await api('/vacations/balances/auto-calculate', { method: 'POST', body: JSON.stringify({ year: parseInt(year) }) });
|
||||||
|
showToast(`${r.data.count}명 자동 배정 완료`);
|
||||||
|
await loadVacBalances();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개별 배정 모달
|
||||||
|
function openVacBalanceModal(editId) {
|
||||||
|
document.getElementById('vbEditId').value = '';
|
||||||
|
document.getElementById('vacBalanceForm').reset();
|
||||||
|
document.getElementById('vbTotalDays').value = '0';
|
||||||
|
document.getElementById('vbUsedDays').value = '0';
|
||||||
|
document.getElementById('vacBalModalTitle').textContent = '연차 배정';
|
||||||
|
// 작업자 셀렉트
|
||||||
|
const wSel = document.getElementById('vbWorker');
|
||||||
|
wSel.innerHTML = '<option value="">선택</option>';
|
||||||
|
vacWorkers.forEach(w => { wSel.innerHTML += `<option value="${w.worker_id}">${w.worker_name}</option>`; });
|
||||||
|
// 유형 셀렉트
|
||||||
|
const tSel = document.getElementById('vbType');
|
||||||
|
tSel.innerHTML = '<option value="">선택</option>';
|
||||||
|
vacTypes.filter(t => t.is_active).forEach(t => { tSel.innerHTML += `<option value="${t.id}">${t.type_name} (${t.type_code})</option>`; });
|
||||||
|
if (editId) {
|
||||||
|
const b = vacBalances.find(x => x.id === editId);
|
||||||
|
if (!b) return;
|
||||||
|
document.getElementById('vacBalModalTitle').textContent = '배정 수정';
|
||||||
|
document.getElementById('vbEditId').value = b.id;
|
||||||
|
wSel.value = b.worker_id;
|
||||||
|
wSel.disabled = true;
|
||||||
|
tSel.value = b.vacation_type_id;
|
||||||
|
tSel.disabled = true;
|
||||||
|
document.getElementById('vbTotalDays').value = b.total_days;
|
||||||
|
document.getElementById('vbUsedDays').value = b.used_days;
|
||||||
|
document.getElementById('vbNotes').value = b.notes || '';
|
||||||
|
} else {
|
||||||
|
wSel.disabled = false;
|
||||||
|
tSel.disabled = false;
|
||||||
|
}
|
||||||
|
document.getElementById('vacBalanceModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeVacBalanceModal() {
|
||||||
|
document.getElementById('vacBalanceModal').classList.add('hidden');
|
||||||
|
document.getElementById('vbWorker').disabled = false;
|
||||||
|
document.getElementById('vbType').disabled = false;
|
||||||
|
}
|
||||||
|
function editVacBalance(id) { openVacBalanceModal(id); }
|
||||||
|
|
||||||
|
document.getElementById('vacBalanceForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const editId = document.getElementById('vbEditId').value;
|
||||||
|
try {
|
||||||
|
if (editId) {
|
||||||
|
await api(`/vacations/balances/${editId}`, { method: 'PUT', body: JSON.stringify({
|
||||||
|
total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0,
|
||||||
|
used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0,
|
||||||
|
notes: document.getElementById('vbNotes').value.trim() || null
|
||||||
|
})});
|
||||||
|
showToast('수정되었습니다.');
|
||||||
|
} else {
|
||||||
|
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
|
||||||
|
await api('/vacations/balances', { method: 'POST', body: JSON.stringify({
|
||||||
|
worker_id: parseInt(document.getElementById('vbWorker').value),
|
||||||
|
vacation_type_id: parseInt(document.getElementById('vbType').value),
|
||||||
|
year: parseInt(year),
|
||||||
|
total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0,
|
||||||
|
used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0,
|
||||||
|
notes: document.getElementById('vbNotes').value.trim() || null
|
||||||
|
})});
|
||||||
|
showToast('배정되었습니다.');
|
||||||
|
}
|
||||||
|
closeVacBalanceModal(); await loadVacBalances();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteVacBalance(id) {
|
||||||
|
if (!confirm('이 배정을 삭제하시겠습니까?')) return;
|
||||||
|
try { await api(`/vacations/balances/${id}`, { method: 'DELETE' }); showToast('삭제되었습니다.'); await loadVacBalances(); }
|
||||||
|
catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
514
user-management/web/static/js/tkuser-workplaces.js
Normal file
514
user-management/web/static/js/tkuser-workplaces.js
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
/* ===== Workplaces CRUD ===== */
|
||||||
|
let workplaces = [], workplacesLoaded = false, workplaceCategories = [];
|
||||||
|
let selectedWorkplaceId = null, selectedWorkplaceName = '';
|
||||||
|
let equipments = [], equipmentTypes = [];
|
||||||
|
let wpNavLevel = 'categories'; // 'categories' | 'workplaces'
|
||||||
|
let wpNavCategoryId = null;
|
||||||
|
let wpNavCategoryName = '';
|
||||||
|
let previewMapRegions = [];
|
||||||
|
|
||||||
|
function purposeBadge(p) {
|
||||||
|
const colors = { '작업구역': 'bg-blue-50 text-blue-600', '창고': 'bg-amber-50 text-amber-600', '설비': 'bg-purple-50 text-purple-600', '휴게시설': 'bg-green-50 text-green-600' };
|
||||||
|
return p ? `<span class="px-1.5 py-0.5 rounded text-xs ${colors[p] || 'bg-gray-50 text-gray-500'}">${p}</span>` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkplaceCategories() {
|
||||||
|
try {
|
||||||
|
const r = await api('/workplaces/categories'); workplaceCategories = r.data || r;
|
||||||
|
populateCategorySelects();
|
||||||
|
renderSidebar();
|
||||||
|
} catch(e) { console.warn('카테고리 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
function populateCategorySelects() {
|
||||||
|
['newWorkplaceCategory','editWorkplaceCategory'].forEach(id => {
|
||||||
|
const sel = document.getElementById(id); if (!sel) return;
|
||||||
|
const val = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">선택</option>';
|
||||||
|
workplaceCategories.forEach(c => { const o = document.createElement('option'); o.value = c.category_id; o.textContent = c.category_name; sel.appendChild(o); });
|
||||||
|
sel.value = val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkplaces() {
|
||||||
|
await loadWorkplaceCategories();
|
||||||
|
try {
|
||||||
|
const r = await api('/workplaces'); workplaces = r.data || r;
|
||||||
|
workplacesLoaded = true;
|
||||||
|
renderSidebar();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('wpSidebarContent').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
const c = document.getElementById('wpSidebarContent');
|
||||||
|
if (!c) return;
|
||||||
|
let html = '';
|
||||||
|
if (wpNavLevel === 'categories') {
|
||||||
|
// 공장 목록 레벨
|
||||||
|
html += '<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">공장 선택</div>';
|
||||||
|
html += `<button onclick="openAddWorkplaceModal()" class="w-full px-3 py-2 mb-3 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium flex items-center justify-center gap-1.5"><i class="fas fa-plus text-xs"></i>작업장 추가</button>`;
|
||||||
|
if (!workplaceCategories.length) {
|
||||||
|
html += '<p class="text-gray-400 text-center py-4 text-sm">등록된 공장이 없습니다.</p>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="space-y-1.5 flex-1 overflow-y-auto">';
|
||||||
|
workplaceCategories.forEach(cat => {
|
||||||
|
const count = workplaces.filter(w => w.category_id == cat.category_id).length;
|
||||||
|
html += `<div onclick="drillIntoCategory(${cat.category_id},'${(cat.category_name||'').replace(/'/g,"\\'")}')" class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer bg-gray-50">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<i class="fas fa-industry text-gray-400 text-sm"></i>
|
||||||
|
<span class="text-sm font-medium text-gray-800 truncate">${cat.category_name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span class="text-xs text-gray-400">${count}</span>
|
||||||
|
<i class="fas fa-chevron-right text-gray-300 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
// 미분류 작업장
|
||||||
|
const uncategorized = workplaces.filter(w => !w.category_id);
|
||||||
|
if (uncategorized.length) {
|
||||||
|
html += `<div onclick="drillIntoCategory(0,'미분류')" class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer bg-gray-50">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<i class="fas fa-folder-open text-gray-300 text-sm"></i>
|
||||||
|
<span class="text-sm font-medium text-gray-500 truncate">미분류</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span class="text-xs text-gray-400">${uncategorized.length}</span>
|
||||||
|
<i class="fas fa-chevron-right text-gray-300 text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 작업장 목록 레벨 (특정 공장 내)
|
||||||
|
html += `<button onclick="backToCategories()" class="flex items-center gap-1.5 text-sm text-slate-600 hover:text-slate-800 mb-2 px-1 py-1 rounded hover:bg-gray-100 transition-colors"><i class="fas fa-arrow-left text-xs"></i>전체 공장</button>`;
|
||||||
|
html += `<div class="text-sm font-semibold text-gray-800 mb-2 px-1"><i class="fas fa-industry text-gray-400 mr-1.5"></i>${wpNavCategoryName}</div>`;
|
||||||
|
html += `<button onclick="openAddWorkplaceModal()" class="w-full px-3 py-2 mb-3 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium flex items-center justify-center gap-1.5"><i class="fas fa-plus text-xs"></i>작업장 추가</button>`;
|
||||||
|
const filtered = wpNavCategoryId === 0
|
||||||
|
? workplaces.filter(w => !w.category_id)
|
||||||
|
: workplaces.filter(w => w.category_id == wpNavCategoryId);
|
||||||
|
if (!filtered.length) {
|
||||||
|
html += '<p class="text-gray-400 text-center py-4 text-sm">등록된 작업장이 없습니다.</p>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="space-y-1.5 flex-1 overflow-y-auto">';
|
||||||
|
filtered.forEach(w => {
|
||||||
|
html += `<div class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer ${selectedWorkplaceId === w.workplace_id ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'}" onclick="selectWorkplaceForEquipments(${w.workplace_id},'${(w.workplace_name||'').replace(/'/g,"\\'")}')">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-building mr-1.5 text-gray-400 text-xs"></i>${w.workplace_name}</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
${purposeBadge(w.workplace_purpose)}
|
||||||
|
${w.is_active === 0 || w.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="event.stopPropagation(); editWorkplace(${w.workplace_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
${w.is_active !== 0 && w.is_active !== false ? `<button onclick="event.stopPropagation(); deactivateWorkplace(${w.workplace_id},'${(w.workplace_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drillIntoCategory(categoryId, categoryName) {
|
||||||
|
wpNavLevel = 'workplaces';
|
||||||
|
wpNavCategoryId = categoryId;
|
||||||
|
wpNavCategoryName = categoryName;
|
||||||
|
selectedWorkplaceId = null;
|
||||||
|
renderSidebar();
|
||||||
|
showZoneMapForCategory(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToCategories() {
|
||||||
|
wpNavLevel = 'categories';
|
||||||
|
wpNavCategoryId = null;
|
||||||
|
wpNavCategoryName = '';
|
||||||
|
selectedWorkplaceId = null;
|
||||||
|
renderSidebar();
|
||||||
|
showEmptyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyState() {
|
||||||
|
document.getElementById('workplaceEmptyState')?.classList.remove('hidden');
|
||||||
|
document.getElementById('equipmentSection')?.classList.add('hidden');
|
||||||
|
document.getElementById('zoneMapSection')?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showZoneMapForCategory(categoryId) {
|
||||||
|
document.getElementById('workplaceEmptyState')?.classList.add('hidden');
|
||||||
|
document.getElementById('equipmentSection')?.classList.add('hidden');
|
||||||
|
document.getElementById('zoneMapSection')?.classList.remove('hidden');
|
||||||
|
const catName = categoryId === 0 ? '미분류' : (workplaceCategories.find(c => c.category_id == categoryId)?.category_name || '');
|
||||||
|
document.getElementById('zoneMapTitle').innerHTML = `<i class="fas fa-map text-slate-400 mr-2"></i>${catName} - 구역지도`;
|
||||||
|
selectedMapCategoryId = categoryId;
|
||||||
|
if (categoryId === 0) {
|
||||||
|
document.getElementById('layoutPreviewArea').classList.remove('hidden');
|
||||||
|
document.getElementById('layoutPreviewCanvas').classList.add('hidden');
|
||||||
|
document.getElementById('layoutPreviewArea').innerHTML = '<i class="fas fa-info-circle text-2xl mb-2"></i><p>미분류 작업장에는 구역지도가 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadLayoutPreview(categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToCategory() {
|
||||||
|
if (!wpNavCategoryId && wpNavCategoryId !== 0) { backToCategories(); return; }
|
||||||
|
selectedWorkplaceId = null;
|
||||||
|
renderSidebar();
|
||||||
|
showZoneMapForCategory(wpNavCategoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddWorkplaceModal() {
|
||||||
|
populateCategorySelects();
|
||||||
|
document.getElementById('addWorkplaceForm').reset();
|
||||||
|
// 공장 드릴다운 상태이면 카테고리 자동 선택
|
||||||
|
if (wpNavLevel === 'workplaces' && wpNavCategoryId && wpNavCategoryId !== 0) {
|
||||||
|
document.getElementById('newWorkplaceCategory').value = wpNavCategoryId;
|
||||||
|
}
|
||||||
|
document.getElementById('addWorkplaceModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeAddWorkplaceModal() { document.getElementById('addWorkplaceModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
document.getElementById('addWorkplaceForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api('/workplaces', { method: 'POST', body: JSON.stringify({
|
||||||
|
workplace_name: document.getElementById('newWorkplaceName').value.trim(),
|
||||||
|
category_id: document.getElementById('newWorkplaceCategory').value ? parseInt(document.getElementById('newWorkplaceCategory').value) : null,
|
||||||
|
workplace_purpose: document.getElementById('newWorkplacePurpose').value || null,
|
||||||
|
description: document.getElementById('newWorkplaceDesc').value.trim() || null,
|
||||||
|
display_priority: parseInt(document.getElementById('newWorkplacePriority').value) || 0
|
||||||
|
})});
|
||||||
|
showToast('작업장이 추가되었습니다.'); document.getElementById('addWorkplaceForm').reset(); closeAddWorkplaceModal(); await loadWorkplaces();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function editWorkplace(id) {
|
||||||
|
const w = workplaces.find(x => x.workplace_id === id); if (!w) return;
|
||||||
|
document.getElementById('editWorkplaceId').value = w.workplace_id;
|
||||||
|
document.getElementById('editWorkplaceName').value = w.workplace_name;
|
||||||
|
document.getElementById('editWorkplaceDesc').value = w.description || '';
|
||||||
|
document.getElementById('editWorkplacePriority').value = w.display_priority || 0;
|
||||||
|
document.getElementById('editWorkplaceActive').value = (w.is_active === 0 || w.is_active === false) ? '0' : '1';
|
||||||
|
document.getElementById('editWorkplacePurpose').value = w.workplace_purpose || '';
|
||||||
|
populateCategorySelects();
|
||||||
|
document.getElementById('editWorkplaceCategory').value = w.category_id || '';
|
||||||
|
document.getElementById('editWorkplaceModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeWorkplaceModal() { document.getElementById('editWorkplaceModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
document.getElementById('editWorkplaceForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await api(`/workplaces/${document.getElementById('editWorkplaceId').value}`, { method: 'PUT', body: JSON.stringify({
|
||||||
|
workplace_name: document.getElementById('editWorkplaceName').value.trim(),
|
||||||
|
category_id: document.getElementById('editWorkplaceCategory').value ? parseInt(document.getElementById('editWorkplaceCategory').value) : null,
|
||||||
|
workplace_purpose: document.getElementById('editWorkplacePurpose').value || null,
|
||||||
|
description: document.getElementById('editWorkplaceDesc').value.trim() || null,
|
||||||
|
display_priority: parseInt(document.getElementById('editWorkplacePriority').value) || 0,
|
||||||
|
is_active: document.getElementById('editWorkplaceActive').value === '1'
|
||||||
|
})});
|
||||||
|
showToast('수정되었습니다.'); closeWorkplaceModal(); await loadWorkplaces();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deactivateWorkplace(id, name) {
|
||||||
|
if (!confirm(`"${name}" 작업장을 비활성화?`)) return;
|
||||||
|
try { await api(`/workplaces/${id}`, { method: 'DELETE' }); showToast('작업장 비활성화 완료'); await loadWorkplaces(); } catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Equipment CRUD ===== */
|
||||||
|
let eqMapImg = null, eqMapCanvas = null, eqMapCtx = null, eqDetailEqId = null;
|
||||||
|
|
||||||
|
function eqStatusBadge(status) {
|
||||||
|
const map = { active:'bg-emerald-50 text-emerald-600', maintenance:'bg-amber-50 text-amber-600', inactive:'bg-gray-100 text-gray-500', external:'bg-blue-50 text-blue-600', repair_external:'bg-blue-50 text-blue-600', repair_needed:'bg-red-50 text-red-600' };
|
||||||
|
const labels = { active:'가동중', maintenance:'점검중', inactive:'비활성', external:'외부반출', repair_external:'수리외주', repair_needed:'수리필요' };
|
||||||
|
return `<span class="px-1.5 py-0.5 rounded text-xs ${map[status] || 'bg-gray-100 text-gray-500'}">${labels[status] || status || ''}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkplaceForEquipments(id, name) {
|
||||||
|
selectedWorkplaceId = id;
|
||||||
|
selectedWorkplaceName = name;
|
||||||
|
// 카테고리 레벨에서 직접 호출된 경우, 해당 카테고리로 드릴인
|
||||||
|
if (wpNavLevel === 'categories') {
|
||||||
|
const wp = workplaces.find(w => w.workplace_id === id);
|
||||||
|
if (wp && wp.category_id) {
|
||||||
|
wpNavLevel = 'workplaces';
|
||||||
|
wpNavCategoryId = wp.category_id;
|
||||||
|
wpNavCategoryName = wp.category_name || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderSidebar();
|
||||||
|
document.getElementById('workplaceEmptyState')?.classList.add('hidden');
|
||||||
|
document.getElementById('zoneMapSection')?.classList.add('hidden');
|
||||||
|
document.getElementById('equipmentSection').classList.remove('hidden');
|
||||||
|
document.getElementById('eqWorkplaceName').textContent = name;
|
||||||
|
// 뒤로가기 버튼 표시 (공장 구역지도로 돌아가기)
|
||||||
|
const backBtn = document.getElementById('eqBackToCategory');
|
||||||
|
if (backBtn && wpNavCategoryId !== null) {
|
||||||
|
document.getElementById('eqBackLabel').textContent = `${wpNavCategoryName} 구역지도`;
|
||||||
|
backBtn.classList.remove('hidden');
|
||||||
|
} else if (backBtn) {
|
||||||
|
backBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
loadEquipments();
|
||||||
|
loadEquipmentTypes();
|
||||||
|
loadEqMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEquipments() {
|
||||||
|
try {
|
||||||
|
const r = await api(`/equipments/workplace/${selectedWorkplaceId}`);
|
||||||
|
equipments = r.data || [];
|
||||||
|
displayEquipments();
|
||||||
|
drawEqMapEquipments();
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('equipmentList').innerHTML = `<div class="text-red-500 text-center py-4"><p class="text-sm">${e.message}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayEquipments() {
|
||||||
|
const statusFilter = document.getElementById('eqStatusFilter').value;
|
||||||
|
const typeFilter = document.getElementById('eqTypeFilter').value;
|
||||||
|
let filtered = equipments;
|
||||||
|
if (statusFilter) filtered = filtered.filter(e => e.status === statusFilter);
|
||||||
|
if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter);
|
||||||
|
const c = document.getElementById('equipmentList');
|
||||||
|
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">설비가 없습니다.</p>'; return; }
|
||||||
|
c.innerHTML = filtered.map(e => `
|
||||||
|
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" onclick="openEqDetailModal(${e.equipment_id})">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-800 truncate">
|
||||||
|
<span class="text-xs text-gray-400 font-mono mr-1.5">${e.equipment_code || ''}</span>${e.equipment_name}
|
||||||
|
${e.is_temporarily_moved ? '<i class="fas fa-arrows-alt text-blue-400 ml-1" title="임시이동중"></i>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||||
|
${e.equipment_type ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-600">${e.equipment_type}</span>` : ''}
|
||||||
|
${e.manufacturer ? `<span class="text-gray-400">${e.manufacturer}</span>` : ''}
|
||||||
|
${eqStatusBadge(e.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button onclick="event.stopPropagation(); editEquipment(${e.equipment_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
|
||||||
|
<button onclick="event.stopPropagation(); deleteEquipment(${e.equipment_id},'${(e.equipment_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEquipments() { displayEquipments(); }
|
||||||
|
|
||||||
|
async function loadEquipmentTypes() {
|
||||||
|
try {
|
||||||
|
const r = await api('/equipments/types'); equipmentTypes = r.data || [];
|
||||||
|
const sel = document.getElementById('eqTypeFilter'); const val = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">전체 유형</option>';
|
||||||
|
equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; sel.appendChild(o); });
|
||||||
|
sel.value = val;
|
||||||
|
const dl = document.getElementById('eqTypeDatalist');
|
||||||
|
if (dl) { dl.innerHTML = ''; equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; dl.appendChild(o); }); }
|
||||||
|
} catch(e) { console.warn('설비 유형 로드 실패:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEquipmentModal(editId) {
|
||||||
|
document.getElementById('eqEditId').value = '';
|
||||||
|
document.getElementById('equipmentForm').reset();
|
||||||
|
if (editId) {
|
||||||
|
document.getElementById('eqModalTitle').textContent = '설비 수정';
|
||||||
|
const eq = equipments.find(e => e.equipment_id === editId);
|
||||||
|
if (!eq) return;
|
||||||
|
document.getElementById('eqEditId').value = eq.equipment_id;
|
||||||
|
document.getElementById('eqCode').value = eq.equipment_code || '';
|
||||||
|
document.getElementById('eqName').value = eq.equipment_name || '';
|
||||||
|
document.getElementById('eqType').value = eq.equipment_type || '';
|
||||||
|
document.getElementById('eqStatus').value = eq.status || 'active';
|
||||||
|
document.getElementById('eqManufacturer').value = eq.manufacturer || '';
|
||||||
|
document.getElementById('eqModel').value = eq.model_name || '';
|
||||||
|
document.getElementById('eqSupplier').value = eq.supplier || '';
|
||||||
|
document.getElementById('eqPrice').value = eq.purchase_price || '';
|
||||||
|
document.getElementById('eqInstallDate').value = eq.installation_date ? eq.installation_date.substring(0, 10) : '';
|
||||||
|
document.getElementById('eqSerial').value = eq.serial_number || '';
|
||||||
|
document.getElementById('eqSpecs').value = eq.specifications || '';
|
||||||
|
document.getElementById('eqNotes').value = eq.notes || '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('eqModalTitle').textContent = '설비 추가';
|
||||||
|
generateEquipmentCode();
|
||||||
|
}
|
||||||
|
document.getElementById('equipmentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeEquipmentModal() { document.getElementById('equipmentModal').classList.add('hidden'); }
|
||||||
|
async function generateEquipmentCode() { try { const r = await api('/equipments/next-code?prefix=TKP'); document.getElementById('eqCode').value = r.data || ''; } catch(e) {} }
|
||||||
|
function editEquipment(id) { openEquipmentModal(id); }
|
||||||
|
async function deleteEquipment(id, name) {
|
||||||
|
if (!confirm(`"${name}" 설비를 삭제하시겠습니까?`)) return;
|
||||||
|
try { await api(`/equipments/${id}`, { method: 'DELETE' }); showToast('설비가 삭제되었습니다.'); await loadEquipments(); } catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('equipmentForm').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const editId = document.getElementById('eqEditId').value;
|
||||||
|
const body = {
|
||||||
|
equipment_code: document.getElementById('eqCode').value.trim(),
|
||||||
|
equipment_name: document.getElementById('eqName').value.trim(),
|
||||||
|
equipment_type: document.getElementById('eqType').value.trim() || null,
|
||||||
|
status: document.getElementById('eqStatus').value,
|
||||||
|
manufacturer: document.getElementById('eqManufacturer').value.trim() || null,
|
||||||
|
model_name: document.getElementById('eqModel').value.trim() || null,
|
||||||
|
supplier: document.getElementById('eqSupplier').value.trim() || null,
|
||||||
|
purchase_price: document.getElementById('eqPrice').value ? parseFloat(document.getElementById('eqPrice').value) : null,
|
||||||
|
installation_date: document.getElementById('eqInstallDate').value || null,
|
||||||
|
serial_number: document.getElementById('eqSerial').value.trim() || null,
|
||||||
|
specifications: document.getElementById('eqSpecs').value.trim() || null,
|
||||||
|
notes: document.getElementById('eqNotes').value.trim() || null,
|
||||||
|
workplace_id: selectedWorkplaceId
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (editId) { await api(`/equipments/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); showToast('설비가 수정되었습니다.'); }
|
||||||
|
else { await api('/equipments', { method: 'POST', body: JSON.stringify(body) }); showToast('설비가 추가되었습니다.'); }
|
||||||
|
closeEquipmentModal(); await loadEquipments(); loadEquipmentTypes();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ===== Equipment Map (설비 배치도) ===== */
|
||||||
|
function loadEqMap() {
|
||||||
|
const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId);
|
||||||
|
if (!wp || !wp.layout_image) {
|
||||||
|
document.getElementById('eqMapArea').classList.remove('hidden');
|
||||||
|
document.getElementById('eqMapCanvasWrap').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('eqMapArea').classList.add('hidden');
|
||||||
|
document.getElementById('eqMapCanvasWrap').classList.remove('hidden');
|
||||||
|
eqMapCanvas = document.getElementById('eqMapCanvas');
|
||||||
|
eqMapCtx = eqMapCanvas.getContext('2d');
|
||||||
|
const imgUrl = wp.layout_image.startsWith('/') ? '/uploads/' + wp.layout_image.replace(/^\/uploads\//, '') : wp.layout_image;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
const maxW = 780; const scale = img.width > maxW ? maxW / img.width : 1;
|
||||||
|
eqMapCanvas.width = img.width * scale; eqMapCanvas.height = img.height * scale;
|
||||||
|
eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
||||||
|
eqMapImg = img;
|
||||||
|
drawEqMapEquipments();
|
||||||
|
eqMapCanvas.onclick = onEqMapClick;
|
||||||
|
};
|
||||||
|
img.src = imgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawEqMapEquipments() {
|
||||||
|
if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return;
|
||||||
|
eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
||||||
|
eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
|
||||||
|
equipments.forEach(eq => {
|
||||||
|
if (eq.map_x_percent == null || eq.map_y_percent == null) return;
|
||||||
|
const x = (eq.map_x_percent / 100) * eqMapCanvas.width;
|
||||||
|
const y = (eq.map_y_percent / 100) * eqMapCanvas.height;
|
||||||
|
const w = ((eq.map_width_percent || 3) / 100) * eqMapCanvas.width;
|
||||||
|
const h = ((eq.map_height_percent || 3) / 100) * eqMapCanvas.height;
|
||||||
|
const colors = { active:'#10b981', maintenance:'#f59e0b', inactive:'#94a3b8', external:'#3b82f6', repair_external:'#3b82f6', repair_needed:'#ef4444' };
|
||||||
|
const color = colors[eq.status] || '#64748b';
|
||||||
|
eqMapCtx.fillStyle = color + '33'; eqMapCtx.fillRect(x - w/2, y - h/2, w, h);
|
||||||
|
eqMapCtx.strokeStyle = color; eqMapCtx.lineWidth = 2; eqMapCtx.strokeRect(x - w/2, y - h/2, w, h);
|
||||||
|
eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'center';
|
||||||
|
eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, x, y - h/2 - 3);
|
||||||
|
eqMapCtx.textAlign = 'start';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let eqMapPlacingId = null;
|
||||||
|
function onEqMapClick(e) {
|
||||||
|
if (!eqMapPlacingId) return;
|
||||||
|
const r = eqMapCanvas.getBoundingClientRect();
|
||||||
|
const scaleX = eqMapCanvas.width / r.width; const scaleY = eqMapCanvas.height / r.height;
|
||||||
|
const px = (e.clientX - r.left) * scaleX; const py = (e.clientY - r.top) * scaleY;
|
||||||
|
const xPct = (px / eqMapCanvas.width * 100).toFixed(2); const yPct = (py / eqMapCanvas.height * 100).toFixed(2);
|
||||||
|
saveEqMapPosition(eqMapPlacingId, xPct, yPct);
|
||||||
|
eqMapPlacingId = null; eqMapCanvas.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEqMapPosition(eqId, x, y) {
|
||||||
|
try {
|
||||||
|
await api(`/equipments/${eqId}/map-position`, { method: 'PATCH', body: JSON.stringify({ map_x_percent: parseFloat(x), map_y_percent: parseFloat(y), map_width_percent: 3, map_height_percent: 3 }) });
|
||||||
|
showToast('설비 위치가 저장되었습니다.'); await loadEquipments();
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPlaceEquipment(eqId) {
|
||||||
|
eqMapPlacingId = eqId; if(eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair';
|
||||||
|
showToast('배치도에서 위치를 클릭하세요');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWorkplaceLayoutImage() {
|
||||||
|
const file = document.getElementById('wpLayoutImageFile').files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const fd = new FormData(); fd.append('image', file); const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/workplaces/${selectedWorkplaceId}/layout-image`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd });
|
||||||
|
const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패');
|
||||||
|
showToast('배치도 이미지가 업로드되었습니다.');
|
||||||
|
const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId);
|
||||||
|
if (wp) wp.layout_image = result.data.image_path;
|
||||||
|
loadEqMap();
|
||||||
|
} catch(e) { showToast(e.message || '업로드 실패', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Equipment Detail Modal ===== */
|
||||||
|
async function openEqDetailModal(eqId) {
|
||||||
|
eqDetailEqId = eqId;
|
||||||
|
const eq = equipments.find(e => e.equipment_id === eqId);
|
||||||
|
if (!eq) return;
|
||||||
|
document.getElementById('eqDetailTitle').textContent = `${eq.equipment_code} - ${eq.equipment_name}`;
|
||||||
|
document.getElementById('eqReturnBtn').classList.toggle('hidden', !eq.is_temporarily_moved);
|
||||||
|
const fmt = v => v || '-';
|
||||||
|
const fmtDate = v => v ? v.substring(0, 10) : '-';
|
||||||
|
const fmtPrice = v => v ? Number(v).toLocaleString() + '원' : '-';
|
||||||
|
document.getElementById('eqDetailContent').innerHTML = `
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-400">유형:</span> ${fmt(eq.equipment_type)}</div>
|
||||||
|
<div><span class="text-gray-400">상태:</span> ${eqStatusBadge(eq.status)}</div>
|
||||||
|
<div><span class="text-gray-400">제조사:</span> ${fmt(eq.manufacturer)}</div>
|
||||||
|
<div><span class="text-gray-400">모델:</span> ${fmt(eq.model_name)}</div>
|
||||||
|
<div><span class="text-gray-400">공급업체:</span> ${fmt(eq.supplier)}</div>
|
||||||
|
<div><span class="text-gray-400">구매가격:</span> ${fmtPrice(eq.purchase_price)}</div>
|
||||||
|
<div><span class="text-gray-400">설치일:</span> ${fmtDate(eq.installation_date)}</div>
|
||||||
|
<div><span class="text-gray-400">시리얼:</span> ${fmt(eq.serial_number)}</div>
|
||||||
|
<div class="col-span-2"><span class="text-gray-400">사양:</span> ${fmt(eq.specifications)}</div>
|
||||||
|
<div class="col-span-2"><span class="text-gray-400">비고:</span> ${fmt(eq.notes)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2"><button onclick="startPlaceEquipment(${eq.equipment_id}); closeEqDetailModal();" class="text-xs text-blue-600 hover:underline"><i class="fas fa-map-pin mr-1"></i>배치도에 위치 지정</button></div>`;
|
||||||
|
loadEqPhotos(eqId);
|
||||||
|
document.getElementById('eqDetailModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
function closeEqDetailModal() { document.getElementById('eqDetailModal').classList.add('hidden'); }
|
||||||
|
|
||||||
|
async function loadEqPhotos(eqId) {
|
||||||
|
const c = document.getElementById('eqPhotoGrid');
|
||||||
|
try {
|
||||||
|
const r = await api(`/equipments/${eqId}/photos`); const photos = r.data || [];
|
||||||
|
if (!photos.length) { c.innerHTML = '<p class="text-gray-400 text-xs col-span-4 text-center py-2">사진 없음</p>'; return; }
|
||||||
|
c.innerHTML = photos.map(p => {
|
||||||
|
const fname = (p.photo_path||'').replace(/^\/uploads\//, '');
|
||||||
|
return `
|
||||||
|
<div class="relative group cursor-pointer" onclick="document.getElementById('photoViewImage').src='/uploads/${fname}'; document.getElementById('photoViewModal').classList.remove('hidden');">
|
||||||
|
<img src="/uploads/${fname}" class="w-full h-20 object-cover rounded">
|
||||||
|
<button onclick="event.stopPropagation(); deleteEqPhoto(${p.photo_id})" class="absolute top-0.5 right-0.5 bg-red-500 text-white rounded-full w-4 h-4 text-[10px] leading-4 text-center opacity-0 group-hover:opacity-100">×</button>
|
||||||
|
</div>`; }).join('');
|
||||||
|
} catch(e) { c.innerHTML = '<p class="text-red-400 text-xs col-span-4">로드 실패</p>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadEqPhoto() {
|
||||||
|
const file = document.getElementById('eqPhotoFile').files[0]; if (!file || !eqDetailEqId) return;
|
||||||
|
try {
|
||||||
|
const fd = new FormData(); fd.append('photo', file); const token = getToken();
|
||||||
|
const res = await fetch(`${API_BASE}/equipments/${eqDetailEqId}/photos`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd });
|
||||||
|
const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패');
|
||||||
|
showToast('사진이 추가되었습니다.'); loadEqPhotos(eqDetailEqId);
|
||||||
|
} catch(e) { showToast(e.message, 'error'); }
|
||||||
|
document.getElementById('eqPhotoFile').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEqPhoto(photoId) {
|
||||||
|
if (!confirm('사진을 삭제하시겠습니까?')) return;
|
||||||
|
try { await api(`/equipments/photos/${photoId}`, { method: 'DELETE' }); showToast('삭제됨'); loadEqPhotos(eqDetailEqId); } catch(e) { showToast(e.message, 'error'); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user