feat: 3-System 분리 프로젝트 초기 코드 작성

TK-FB(공장관리+신고)와 M-Project(부적합관리)를 3개 독립 시스템으로
분리하기 위한 전체 코드 구조 작성.
- SSO 인증 서비스 (bcrypt + pbkdf2 이중 해시 지원)
- System 1: 공장관리 (TK-FB 기반, 신고 코드 제거)
- System 2: 신고 (TK-FB에서 workIssue 코드 추출)
- System 3: 부적합관리 (M-Project 기반)
- Gateway 포털 (path-based 라우팅)
- 통합 docker-compose.yml 및 배포 스크립트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:40:11 +09:00
commit 550633b89d
824 changed files with 1071683 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
FROM nginx:alpine
COPY . /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,913 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 페이지 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@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: 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);
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
color: #4b5563;
transition: all 0.2s;
white-space: nowrap;
}
.nav-link:hover {
background-color: #f3f4f6;
color: #1f2937;
}
.nav-link.active {
background-color: #3b82f6;
color: white;
}
.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);
}
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8 max-w-6xl content-fade-in" style="padding-top: 80px;">
<div class="grid md:grid-cols-2 gap-6">
<!-- 사용자 추가 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-user-plus text-blue-500 mr-2"></i>사용자 추가
</h2>
<form id="addUserForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 ID</label>
<input
type="text"
id="newUsername"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="한글 가능 (예: 홍길동)"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input
type="text"
id="newFullName"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="실명 입력"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
<input
type="password"
id="newPassword"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="초기 비밀번호"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">부서</label>
<select id="newDepartment" class="input-field w-full px-3 py-2 rounded-lg">
<option value="">부서 선택 (선택사항)</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">권한</label>
<select id="newRole" class="input-field w-full px-3 py-2 rounded-lg">
<option value="user">일반 사용자</option>
<option value="admin">관리자</option>
</select>
</div>
<button
type="submit"
class="w-full 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>사용자 추가
</button>
</form>
</div>
<!-- 사용자 목록 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-users text-green-500 mr-2"></i>사용자 목록
</h2>
<div id="userList" class="space-y-3">
<!-- 사용자 목록이 여기에 표시됩니다 -->
<div class="text-gray-500 text-center py-8">
<i class="fas fa-spinner fa-spin text-3xl"></i>
<p>로딩 중...</p>
</div>
</div>
</div>
</div>
<!-- 페이지 권한 관리 섹션 (관리자용) -->
<div id="pagePermissionSection" class="mt-6">
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-shield-alt text-purple-500 mr-2"></i>페이지 접근 권한 관리
</h2>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">사용자 선택</label>
<select id="permissionUserSelect" class="input-field w-full max-w-xs px-3 py-2 rounded-lg">
<option value="">사용자를 선택하세요</option>
</select>
</div>
<div id="pagePermissionGrid" class="hidden">
<h3 class="text-md font-medium text-gray-700 mb-3">페이지별 접근 권한</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 페이지 권한 체크박스들이 여기에 동적으로 생성됩니다 -->
</div>
<div class="mt-4 pt-4 border-t">
<button
id="savePermissionsBtn"
class="px-4 py-2 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
>
<i class="fas fa-save mr-2"></i>권한 저장
</button>
<span id="permissionSaveStatus" class="ml-3 text-sm"></span>
</div>
</div>
</div>
</div>
<!-- 비밀번호 변경 섹션 (사용자용) -->
<div id="passwordChangeSection" class="hidden mt-6">
<div class="bg-white rounded-xl shadow-sm p-6 max-w-md mx-auto">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-key text-yellow-500 mr-2"></i>비밀번호 변경
</h2>
<form id="changePasswordForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">현재 비밀번호</label>
<input
type="password"
id="currentPassword"
class="input-field w-full px-3 py-2 rounded-lg"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input
type="password"
id="newPasswordChange"
class="input-field w-full px-3 py-2 rounded-lg"
required
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input
type="password"
id="confirmPassword"
class="input-field w-full px-3 py-2 rounded-lg"
required
>
</div>
<button
type="submit"
class="w-full px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors"
>
<i class="fas fa-save mr-2"></i>비밀번호 변경
</button>
</form>
</div>
</div>
</main>
<!-- 사용자 편집 모달 -->
<div id="editUserModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">사용자 정보 수정</h3>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form id="editUserForm" class="space-y-4">
<input type="hidden" id="editUserId">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">사용자 ID</label>
<input
type="text"
id="editUsername"
class="input-field w-full px-3 py-2 rounded-lg bg-gray-100"
readonly
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
<input
type="text"
id="editFullName"
class="input-field w-full px-3 py-2 rounded-lg"
placeholder="실명 입력"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">부서</label>
<select id="editDepartment" class="input-field w-full px-3 py-2 rounded-lg">
<option value="">부서 선택 (선택사항)</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">권한</label>
<select id="editRole" class="input-field w-full px-3 py-2 rounded-lg">
<option value="user">일반 사용자</option>
<option value="admin">관리자</option>
</select>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
onclick="closeEditModal()"
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
>
취소
</button>
<button
type="submit"
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<i class="fas fa-save mr-2"></i>저장
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
let currentUser = null;
let users = [];
// 애니메이션 함수들
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 initializeAdmin() {
const token = localStorage.getItem('access_token');
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, 'users_manage');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크
setTimeout(() => {
if (!canAccessPage('users_manage')) {
alert('사용자 관리 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
return;
}
// 관리자가 아니면 비밀번호 변경만 표시
if (currentUser.role !== 'admin') {
document.querySelector('.grid').style.display = 'none';
document.getElementById('passwordChangeSection').classList.remove('hidden');
} else {
// 관리자면 사용자 목록 로드
await loadUsers();
}
}
// 사용자 추가
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userData = {
username: document.getElementById('newUsername').value.trim(),
full_name: document.getElementById('newFullName').value.trim(),
password: document.getElementById('newPassword').value,
department: document.getElementById('newDepartment').value || null,
role: document.getElementById('newRole').value
};
try {
await AuthAPI.createUser(userData);
// 성공
alert('사용자가 추가되었습니다.');
// 폼 초기화
document.getElementById('addUserForm').reset();
// 목록 새로고침
await loadUsers();
} catch (error) {
alert(error.message || '사용자 추가에 실패했습니다.');
}
});
// 비밀번호 변경
document.getElementById('changePasswordForm').addEventListener('submit', async (e) => {
e.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPasswordChange').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (newPassword !== confirmPassword) {
alert('새 비밀번호가 일치하지 않습니다.');
return;
}
try {
await AuthAPI.changePassword(currentPassword, newPassword);
alert('비밀번호가 변경되었습니다. 다시 로그인해주세요.');
AuthAPI.logout();
} catch (error) {
alert(error.message || '비밀번호 변경에 실패했습니다.');
}
});
// 사용자 목록 로드
async function loadUsers() {
try {
// 백엔드 API에서 사용자 목록 로드
users = await AuthAPI.getUsers();
displayUsers();
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
// API 실패 시 빈 배열로 초기화
users = [];
displayUsers();
}
}
// 사용자 목록 표시
function displayUsers() {
const container = document.getElementById('userList');
if (users.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center">등록된 사용자가 없습니다.</p>';
return;
}
container.innerHTML = users.map(user => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex-1">
<div class="font-medium text-gray-800">
<i class="fas fa-user mr-2 text-gray-500"></i>
${user.full_name || user.username}
</div>
<div class="text-sm text-gray-600 flex items-center gap-3">
<span>ID: ${user.username}</span>
${user.department ? `
<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-700">
<i class="fas fa-building mr-1"></i>${AuthAPI.getDepartmentLabel(user.department)}
</span>
` : ''}
<span class="px-2 py-0.5 rounded text-xs ${
user.role === 'admin'
? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}">
${user.role === 'admin' ? '관리자' : '사용자'}
</span>
</div>
</div>
<div class="flex gap-2">
<button
onclick="editUser(${user.id})"
class="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm"
>
<i class="fas fa-edit mr-1"></i>편집
</button>
<button
onclick="resetPassword('${user.username}')"
class="px-3 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 transition-colors text-sm"
>
<i class="fas fa-key mr-1"></i>비밀번호 초기화
</button>
${user.username !== 'hyungi' ? `
<button
onclick="deleteUser('${user.username}')"
class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
>
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
</div>
`).join('');
}
// 비밀번호 초기화
async function resetPassword(username) {
if (!confirm(`${username} 사용자의 비밀번호를 "000000"으로 초기화하시겠습니까?`)) {
return;
}
try {
// 사용자 ID 찾기
const user = users.find(u => u.username === username);
if (!user) {
alert('사용자를 찾을 수 없습니다.');
return;
}
// 백엔드 API로 비밀번호 초기화
await AuthAPI.resetPassword(user.id, '000000');
alert(`${username} 사용자의 비밀번호가 "000000"으로 초기화되었습니다.`);
// 목록 새로고침
await loadUsers();
} catch (error) {
alert('비밀번호 초기화에 실패했습니다: ' + error.message);
}
}
// 사용자 삭제
async function deleteUser(username) {
if (!confirm(`정말 ${username} 사용자를 삭제하시겠습니까?`)) {
return;
}
try {
await AuthAPI.deleteUser(username);
alert('사용자가 삭제되었습니다.');
await loadUsers();
} catch (error) {
alert(error.message || '삭제에 실패했습니다.');
}
}
// 사용자 편집 모달 열기
function editUser(userId) {
const user = users.find(u => u.id === userId);
if (!user) {
alert('사용자를 찾을 수 없습니다.');
return;
}
// 모달 필드에 현재 값 설정
document.getElementById('editUserId').value = user.id;
document.getElementById('editUsername').value = user.username;
document.getElementById('editFullName').value = user.full_name || '';
document.getElementById('editDepartment').value = user.department || '';
document.getElementById('editRole').value = user.role;
// 모달 표시
document.getElementById('editUserModal').classList.remove('hidden');
}
// 사용자 편집 모달 닫기
function closeEditModal() {
document.getElementById('editUserModal').classList.add('hidden');
}
// 사용자 편집 폼 제출
document.getElementById('editUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userId = document.getElementById('editUserId').value;
const userData = {
full_name: document.getElementById('editFullName').value.trim() || null,
department: document.getElementById('editDepartment').value || null,
role: document.getElementById('editRole').value
};
try {
await AuthAPI.updateUser(userId, userData);
alert('사용자 정보가 수정되었습니다.');
closeEditModal();
await loadUsers(); // 목록 새로고침
} catch (error) {
alert(error.message || '사용자 정보 수정에 실패했습니다.');
}
});
// 페이지 권한 관리 기능
let selectedUserId = null;
let currentPermissions = {};
// AuthAPI를 사용하여 사용자 목록 로드
async function loadUsers() {
try {
users = await AuthAPI.getUsers();
displayUsers();
updatePermissionUserSelect(); // 권한 관리 드롭다운 업데이트
} catch (error) {
console.error('사용자 로드 실패:', error);
document.getElementById('userList').innerHTML = `
<div class="text-red-500 text-center py-8">
<i class="fas fa-exclamation-triangle text-3xl"></i>
<p>사용자 목록을 불러올 수 없습니다.</p>
<p class="text-sm mt-2">오류: ${error.message}</p>
</div>
`;
}
}
// 권한 관리 사용자 선택 드롭다운 업데이트
function updatePermissionUserSelect() {
const select = document.getElementById('permissionUserSelect');
select.innerHTML = '<option value="">사용자를 선택하세요</option>';
// 일반 사용자만 표시 (admin 제외)
const regularUsers = users.filter(user => user.role === 'user');
regularUsers.forEach(user => {
const option = document.createElement('option');
option.value = user.id;
option.textContent = `${user.full_name || user.username} (${user.username})`;
select.appendChild(option);
});
}
// 사용자 선택 시 페이지 권한 그리드 표시
document.getElementById('permissionUserSelect').addEventListener('change', async (e) => {
selectedUserId = e.target.value;
if (selectedUserId) {
await loadUserPagePermissions(selectedUserId);
showPagePermissionGrid();
} else {
hidePagePermissionGrid();
}
});
// 사용자의 페이지 권한 로드
async function loadUserPagePermissions(userId) {
try {
// 기본 페이지 목록 가져오기
const defaultPages = {
'issues_create': { title: '부적합 등록', defaultAccess: true },
'issues_view': { title: '부적합 조회', defaultAccess: true },
'issues_manage': { title: '부적합 관리', defaultAccess: true },
'issues_inbox': { title: '수신함', defaultAccess: true },
'issues_management': { title: '관리함', defaultAccess: false },
'issues_archive': { title: '폐기함', defaultAccess: false },
'projects_manage': { title: '프로젝트 관리', defaultAccess: false },
'daily_work': { title: '일일 공수', defaultAccess: false },
'reports': { title: '보고서', defaultAccess: false }
};
// 기본값으로 초기화
currentPermissions = {};
Object.keys(defaultPages).forEach(pageName => {
currentPermissions[pageName] = defaultPages[pageName].defaultAccess;
});
// 실제 API 호출로 사용자별 설정된 권한 가져오기
try {
const response = await fetch(`/api/users/${userId}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
const permissions = await response.json();
permissions.forEach(perm => {
currentPermissions[perm.page_name] = perm.can_access;
});
console.log('사용자 권한 로드 완료:', currentPermissions);
} else {
console.warn('사용자 권한 로드 실패, 기본값 사용');
}
} catch (apiError) {
console.warn('API 호출 실패, 기본값 사용:', apiError);
}
} catch (error) {
console.error('페이지 권한 로드 실패:', error);
}
}
// 페이지 권한 그리드 표시
function showPagePermissionGrid() {
const grid = document.getElementById('pagePermissionGrid');
const gridContainer = grid.querySelector('.grid');
// 페이지 권한 체크박스 생성 (카테고리별로 그룹화)
const pageCategories = {
'부적합 관리': {
'issues_create': { title: '부적합 등록', icon: 'fas fa-plus-circle', color: 'text-green-600' },
'issues_view': { title: '부적합 조회', icon: 'fas fa-search', color: 'text-purple-600' },
'issues_manage': { title: '목록 관리 (통합)', icon: 'fas fa-tasks', color: 'text-orange-600' }
},
'목록 관리 세부': {
'issues_inbox': { title: '📥 수신함', icon: 'fas fa-inbox', color: 'text-blue-600' },
'issues_management': { title: '⚙️ 관리함', icon: 'fas fa-cog', color: 'text-green-600' },
'issues_archive': { title: '🗃️ 폐기함', icon: 'fas fa-archive', color: 'text-gray-600' }
},
'시스템 관리': {
'projects_manage': { title: '프로젝트 관리', icon: 'fas fa-folder-open', color: 'text-indigo-600' },
'daily_work': { title: '일일 공수', icon: 'fas fa-calendar-check', color: 'text-blue-600' },
'reports': { title: '보고서', icon: 'fas fa-chart-bar', color: 'text-red-600' },
'users_manage': { title: '사용자 관리', icon: 'fas fa-users-cog', color: 'text-purple-600' }
}
};
let html = '';
// 카테고리별로 그룹화하여 표시
Object.entries(pageCategories).forEach(([categoryName, pages]) => {
html += `
<div class="col-span-full">
<h4 class="text-sm font-semibold text-gray-800 mb-3 pb-2 border-b border-gray-200">
${categoryName}
</h4>
</div>
`;
Object.entries(pages).forEach(([pageName, pageInfo]) => {
const isChecked = currentPermissions[pageName] || false;
const isDefault = currentPermissions[pageName] === undefined ?
(pageInfo.title.includes('등록') || pageInfo.title.includes('조회') || pageInfo.title.includes('수신함')) : false;
html += `
<div class="flex items-center p-3 border rounded-lg hover:bg-gray-50 transition-colors ${isChecked ? 'border-blue-300 bg-blue-50' : 'border-gray-200'}">
<input
type="checkbox"
id="perm_${pageName}"
${isChecked ? 'checked' : ''}
class="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
onchange="this.parentElement.classList.toggle('border-blue-300', this.checked); this.parentElement.classList.toggle('bg-blue-50', this.checked);"
>
<label for="perm_${pageName}" class="flex-1 cursor-pointer">
<div class="flex items-center">
<i class="${pageInfo.icon} ${pageInfo.color} mr-2"></i>
<span class="text-sm font-medium text-gray-700">${pageInfo.title}</span>
${isDefault ? '<span class="ml-2 text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">기본</span>' : ''}
</div>
</label>
</div>
`;
});
});
gridContainer.innerHTML = html;
grid.classList.remove('hidden');
}
// 페이지 권한 그리드 숨기기
function hidePagePermissionGrid() {
document.getElementById('pagePermissionGrid').classList.add('hidden');
}
// 권한 저장
document.getElementById('savePermissionsBtn').addEventListener('click', async () => {
if (!selectedUserId) return;
const saveBtn = document.getElementById('savePermissionsBtn');
const statusSpan = document.getElementById('permissionSaveStatus');
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...';
statusSpan.textContent = '';
try {
// 체크박스 상태 수집 (모든 페이지 포함)
const allPages = [
'issues_create', 'issues_view', 'issues_manage',
'issues_inbox', 'issues_management', 'issues_archive',
'projects_manage', 'daily_work', 'reports', 'users_manage'
];
const permissions = {};
allPages.forEach(pageName => {
const checkbox = document.getElementById(`perm_${pageName}`);
if (checkbox) {
permissions[pageName] = checkbox.checked;
}
});
// 실제 API 호출로 권한 저장
const permissionArray = Object.entries(permissions).map(([pageName, canAccess]) => ({
page_name: pageName,
can_access: canAccess
}));
const response = await fetch('/api/page-permissions/bulk-grant', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
user_id: parseInt(selectedUserId),
permissions: permissionArray
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || '권한 저장에 실패했습니다.');
}
const result = await response.json();
console.log('권한 저장 완료:', result);
statusSpan.textContent = '✅ 권한이 저장되었습니다.';
statusSpan.className = 'ml-3 text-sm text-green-600';
setTimeout(() => {
statusSpan.textContent = '';
}, 3000);
} catch (error) {
console.error('권한 저장 실패:', error);
statusSpan.textContent = '❌ 권한 저장에 실패했습니다.';
statusSpan.className = 'ml-3 text-sm text-red-600';
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="fas fa-save mr-2"></i>권한 저장';
}
});
// 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 스크립트 로드 완료 (admin.html)');
// API 로드 후 초기화 시작
initializeAdmin();
};
script.onerror = function() {
console.error('❌ API 스크립트 로드 실패');
};
document.head.appendChild(script);
</script>
</body>
</html>

View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업보고서 시스템</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@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: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
min-height: 100vh;
}
.sidebar {
transition: transform 0.3s ease-in-out;
}
.sidebar.collapsed {
transform: translateX(-100%);
}
.main-content {
transition: margin-left 0.3s ease-in-out;
}
.main-content.expanded {
margin-left: 0;
}
.nav-item {
transition: all 0.2s ease;
}
.nav-item:hover {
background-color: rgba(59, 130, 246, 0.1);
transform: translateX(4px);
}
.nav-item.active {
background-color: #3b82f6;
color: white;
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-overlay.active {
display: flex;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
transition: all 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.input-field {
border: 1px solid #d1d5db;
background: white;
transition: all 0.2s ease;
}
.input-field:focus {
border-color: #3b82f6;
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 모바일 반응형 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 1000;
height: 100vh;
}
.main-content {
margin-left: 0 !important;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: none;
}
.mobile-overlay.active {
display: block;
}
}
</style>
</head>
<body>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="loading-overlay">
<div class="bg-white rounded-xl p-8 text-center">
<div class="mb-4">
<i class="fas fa-spinner fa-spin text-5xl text-blue-500"></i>
</div>
<p class="text-gray-700 font-medium text-lg">처리 중입니다...</p>
<p class="text-gray-500 text-sm mt-2">잠시만 기다려주세요</p>
</div>
</div>
<!-- 모바일 오버레이 -->
<div id="mobileOverlay" class="mobile-overlay" onclick="toggleSidebar()"></div>
<!-- 사이드바 -->
<aside id="sidebar" class="sidebar fixed left-0 top-0 h-full w-64 bg-white shadow-lg z-50">
<!-- 헤더 -->
<div class="p-6 border-b">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-clipboard-check text-2xl text-blue-500 mr-3"></i>
<h1 class="text-xl font-bold text-gray-800">작업보고서</h1>
</div>
<button onclick="toggleSidebar()" class="md:hidden text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 사용자 정보 -->
<div class="p-4 border-b bg-gray-50">
<div class="flex items-center">
<div class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold">
<span id="userInitial">U</span>
</div>
<div class="ml-3">
<p class="font-medium text-gray-800" id="userDisplayName">사용자</p>
<p class="text-sm text-gray-500" id="userRole">user</p>
</div>
</div>
</div>
<!-- 네비게이션 메뉴 -->
<nav class="p-4">
<ul id="navigationMenu" class="space-y-2">
<!-- 메뉴 항목들이 동적으로 생성됩니다 -->
</ul>
</nav>
<!-- 하단 메뉴 -->
<div class="absolute bottom-0 left-0 right-0 p-4 border-t bg-gray-50">
<button onclick="CommonHeader.showPasswordModal()" class="w-full text-left p-2 rounded-lg hover:bg-gray-100 transition-colors">
<i class="fas fa-key mr-3 text-gray-500"></i>
<span class="text-gray-700">비밀번호 변경</span>
</button>
<button onclick="logout()" class="w-full text-left p-2 rounded-lg hover:bg-red-50 text-red-600 transition-colors mt-2">
<i class="fas fa-sign-out-alt mr-3"></i>
<span>로그아웃</span>
</button>
</div>
</aside>
<!-- 메인 콘텐츠 -->
<main id="mainContent" class="main-content ml-64 min-h-screen">
<!-- 상단 바 -->
<header class="bg-white shadow-sm border-b p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<button onclick="toggleSidebar()" class="md:hidden mr-4 text-gray-500 hover:text-gray-700">
<i class="fas fa-bars"></i>
</button>
<h2 id="pageTitle" class="text-xl font-semibold text-gray-800">대시보드</h2>
</div>
<div class="flex items-center space-x-4">
<button class="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100">
<i class="fas fa-bell"></i>
</button>
<button class="p-2 text-gray-500 hover:text-gray-700 rounded-lg hover:bg-gray-100">
<i class="fas fa-cog"></i>
</button>
</div>
</div>
</header>
<!-- 페이지 콘텐츠 -->
<div id="pageContent" class="p-6">
<!-- 기본 대시보드 -->
<div id="dashboard" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- 통계 카드들 -->
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">총 부적합 사항</p>
<p class="text-2xl font-bold text-gray-800" id="totalIssues">0</p>
</div>
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">진행 중인 프로젝트</p>
<p class="text-2xl font-bold text-gray-800" id="activeProjects">0</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-folder-open text-blue-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">이번 달 공수</p>
<p class="text-2xl font-bold text-gray-800" id="monthlyHours">0</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-clock text-green-500"></i>
</div>
</div>
</div>
<div class="card p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600">완료율</p>
<p class="text-2xl font-bold text-gray-800" id="completionRate">0%</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-chart-pie text-purple-500"></i>
</div>
</div>
</div>
</div>
<!-- 최근 활동 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 부적합 사항</h3>
<div id="recentIssues" class="space-y-3">
<!-- 최근 부적합 사항 목록 -->
</div>
</div>
<div class="card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">프로젝트 현황</h3>
<div id="projectStatus" class="space-y-3">
<!-- 프로젝트 현황 -->
</div>
</div>
</div>
</div>
<!-- 동적 콘텐츠 영역 -->
<div id="dynamicContent" class="hidden">
<!-- 각 모듈의 콘텐츠가 여기에 로드됩니다 -->
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/utils/date-utils.js"></script>
<script src="/static/js/utils/image-utils.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 데이터 확인</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
pre {
background: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}
.info {
background: #e3f2fd;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
button {
background: #3b82f6;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
margin: 5px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>프로젝트 데이터 확인</h1>
<div class="info">
<p><strong>화면 크기:</strong> <span id="screenSize"></span></p>
<p><strong>User Agent:</strong> <span id="userAgent"></span></p>
<p><strong>현재 시간:</strong> <span id="currentTime"></span></p>
</div>
<h2>localStorage 데이터</h2>
<pre id="localStorageData"></pre>
<h2>프로젝트 목록</h2>
<div id="projectList"></div>
<h2>액션</h2>
<button onclick="createDefaultProjects()">기본 프로젝트 생성</button>
<button onclick="clearProjects()">프로젝트 초기화</button>
<button onclick="location.reload()">새로고침</button>
<button onclick="location.href='index.html'">메인으로</button>
<script>
// 화면 정보 표시
document.getElementById('screenSize').textContent = `${window.innerWidth} x ${window.innerHeight}`;
document.getElementById('userAgent').textContent = navigator.userAgent;
document.getElementById('currentTime').textContent = new Date().toLocaleString('ko-KR');
// localStorage 데이터 표시
function showLocalStorageData() {
const data = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
try {
data[key] = JSON.parse(localStorage.getItem(key));
} catch (e) {
data[key] = localStorage.getItem(key);
}
}
document.getElementById('localStorageData').textContent = JSON.stringify(data, null, 2);
}
// 프로젝트 목록 표시
function showProjects() {
const saved = localStorage.getItem('work-report-projects');
const projectListDiv = document.getElementById('projectList');
if (saved) {
try {
const projects = JSON.parse(saved);
let html = `<p>총 ${projects.length}개의 프로젝트</p><ul>`;
projects.forEach(p => {
html += `<li>${p.jobNo} - ${p.projectName} (${p.isActive ? '활성' : '비활성'})</li>`;
});
html += '</ul>';
projectListDiv.innerHTML = html;
} catch (e) {
projectListDiv.innerHTML = '<p style="color: red;">프로젝트 데이터 파싱 에러: ' + e.message + '</p>';
}
} else {
projectListDiv.innerHTML = '<p style="color: orange;">프로젝트 데이터가 없습니다.</p>';
}
}
// 기본 프로젝트 생성
function createDefaultProjects() {
alert('프로젝트 관리 페이지에서 프로젝트를 생성하세요.');
location.href = 'project-management.html';
}
// 프로젝트 초기화
function clearProjects() {
if (confirm('정말로 모든 프로젝트를 삭제하시겠습니까?')) {
localStorage.removeItem('work-report-projects');
alert('프로젝트가 초기화되었습니다.');
location.reload();
}
}
// 초기 로드
showLocalStorageData();
showProjects();
</script>
</body>
</html>

View File

@@ -0,0 +1,661 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일 공수 입력</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.btn-success {
background-color: var(--success);
color: white;
transition: all 0.2s;
}
.btn-success:hover {
background-color: #059669;
transform: translateY(-1px);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.work-card {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.work-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.summary-card {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
color: #4b5563;
transition: all 0.2s;
white-space: nowrap;
}
.nav-link:hover {
background-color: #f3f4f6;
color: #1f2937;
}
.nav-link.active {
background-color: #3b82f6;
color: 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);
}
</style>
</head>
<body>
<div class="min-h-screen bg-gray-50">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-6 max-w-2xl content-fade-in" style="padding-top: 80px;">
<!-- 입력 카드 -->
<div class="work-card p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-6">
<i class="fas fa-edit text-blue-500 mr-2"></i>공수 입력
</h2>
<form id="dailyWorkForm" class="space-y-6">
<!-- 날짜 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
<i class="fas fa-calendar mr-1"></i>날짜
</label>
<input
type="date"
id="workDate"
class="input-field w-full px-4 py-3 rounded-lg text-lg"
required
onchange="loadExistingData()"
>
</div>
<!-- 프로젝트별 시간 입력 섹션 -->
<div>
<div class="flex items-center justify-between mb-4">
<label class="text-sm font-medium text-gray-700">
<i class="fas fa-folder-open mr-1"></i>프로젝트별 작업 시간
</label>
<button
type="button"
id="addProjectBtn"
class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onclick="addProjectEntry()"
>
<i class="fas fa-plus mr-2"></i>프로젝트 추가
</button>
</div>
<div id="projectEntries" class="space-y-3">
<!-- 프로젝트 입력 항목들이 여기에 추가됩니다 -->
</div>
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-blue-900">총 작업 시간:</span>
<span id="totalHours" class="text-lg font-bold text-blue-600">0시간</span>
</div>
</div>
</div>
<!-- 총 공수 표시 -->
<div class="bg-blue-50 rounded-lg p-4">
<div class="flex justify-between items-center">
<span class="text-gray-700 font-medium">예상 총 공수</span>
<span class="text-2xl font-bold text-blue-600" id="totalHours">0시간</span>
</div>
</div>
<!-- 저장 버튼 -->
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
<i class="fas fa-save mr-2"></i>저장하기
</button>
</form>
</div>
<!-- 최근 입력 내역 -->
<div class="work-card p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-history text-gray-500 mr-2"></i>최근 입력 내역
</h3>
<div id="recentEntries" class="space-y-3">
<!-- 최근 입력 내역이 여기에 표시됩니다 -->
</div>
</div>
</main>
</div>
<script>
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 스크립트 로드 완료');
initializeDailyWork();
};
document.head.appendChild(script);
</script>
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
let currentUser = null;
let projects = [];
let dailyWorkData = [];
let projectEntryCounter = 0;
// 애니메이션 함수들
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 initializeDailyWork() {
const token = localStorage.getItem('access_token');
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, 'daily_work');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크 (일일 공수 페이지)
setTimeout(() => {
if (!canAccessPage('daily_work')) {
alert('일일 공수 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return;
}
}, 500);
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
return;
}
// 사용자 정보는 공통 헤더에서 표시됨
// 네비게이션은 공통 헤더에서 처리됨
// 프로젝트 및 일일 공수 데이터 로드
await loadProjects();
loadDailyWorkData();
// 오늘 날짜로 초기화
document.getElementById('workDate').valueAsDate = new Date();
// 첫 번째 프로젝트 입력 항목 추가
addProjectEntry();
// 최근 내역 로드
await loadRecentEntries();
}
// DOM 로드 완료 시 대기 (API 스크립트가 로드되면 initializeDailyWork 호출됨)
window.addEventListener('DOMContentLoaded', () => {
console.log('📄 DOM 로드 완료 - API 스크립트 로딩 대기 중...');
});
// 네비게이션은 공통 헤더에서 처리됨
// 프로젝트 데이터 로드
async function loadProjects() {
try {
// API에서 최신 프로젝트 데이터 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
projects = await response.json();
console.log('프로젝트 로드 완료:', projects.length, '개');
console.log('활성 프로젝트:', projects.filter(p => p.is_active).length, '개');
// localStorage에도 캐시 저장
localStorage.setItem('work-report-projects', JSON.stringify(projects));
} else {
console.error('프로젝트 로드 실패:', response.status);
// 실패 시 localStorage에서 로드
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
console.log('캐시에서 프로젝트 로드:', projects.length, '개');
}
}
} catch (error) {
console.error('프로젝트 로드 오류:', error);
// 오류 시 localStorage에서 로드
const saved = localStorage.getItem('work-report-projects');
if (saved) {
projects = JSON.parse(saved);
console.log('캐시에서 프로젝트 로드:', projects.length, '개');
}
}
}
// 일일 공수 데이터 로드
function loadDailyWorkData() {
const saved = localStorage.getItem('daily-work-data');
if (saved) {
dailyWorkData = JSON.parse(saved);
}
}
// 일일 공수 데이터 저장
function saveDailyWorkData() {
localStorage.setItem('daily-work-data', JSON.stringify(dailyWorkData));
}
// 활성 프로젝트만 필터링
function getActiveProjects() {
return projects.filter(p => p.is_active);
}
// 프로젝트 입력 항목 추가
function addProjectEntry() {
const activeProjects = getActiveProjects();
if (activeProjects.length === 0) {
alert('활성 프로젝트가 없습니다. 먼저 프로젝트를 생성해주세요.');
return;
}
projectEntryCounter++;
const entryId = `project-entry-${projectEntryCounter}`;
const entryHtml = `
<div id="${entryId}" class="flex gap-3 items-center p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<select class="input-field w-full px-3 py-2 rounded-lg" onchange="updateTotalHours()">
<option value="">프로젝트 선택</option>
${activeProjects.map(p => `<option value="${p.id}">${p.jobNo} - ${p.projectName}</option>`).join('')}
</select>
</div>
<div class="w-32">
<input
type="number"
placeholder="시간"
min="0"
step="0.5"
class="input-field w-full px-3 py-2 rounded-lg text-center"
onchange="updateTotalHours()"
>
</div>
<div class="w-16 text-center text-gray-600">시간</div>
<button
type="button"
onclick="removeProjectEntry('${entryId}')"
class="text-red-500 hover:text-red-700 p-2"
title="제거"
>
<i class="fas fa-times"></i>
</button>
</div>
`;
document.getElementById('projectEntries').insertAdjacentHTML('beforeend', entryHtml);
updateTotalHours();
}
// 프로젝트 입력 항목 제거
function removeProjectEntry(entryId) {
const entry = document.getElementById(entryId);
if (entry) {
entry.remove();
updateTotalHours();
}
}
// 총 시간 계산 및 업데이트
function updateTotalHours() {
const entries = document.querySelectorAll('#projectEntries > div');
let totalHours = 0;
entries.forEach(entry => {
const hoursInput = entry.querySelector('input[type="number"]');
const hours = parseFloat(hoursInput.value) || 0;
totalHours += hours;
});
document.getElementById('totalHours').textContent = `${totalHours}시간`;
}
// 기존 데이터 로드 (날짜 선택 시)
function loadExistingData() {
const selectedDate = document.getElementById('workDate').value;
if (!selectedDate) return;
const existingData = dailyWorkData.find(d => d.date === selectedDate);
if (existingData) {
// 기존 프로젝트 입력 항목들 제거
document.getElementById('projectEntries').innerHTML = '';
projectEntryCounter = 0;
// 기존 데이터로 프로젝트 입력 항목들 생성
existingData.projects.forEach(projectData => {
addProjectEntry();
const lastEntry = document.querySelector('#projectEntries > div:last-child');
const select = lastEntry.querySelector('select');
const input = lastEntry.querySelector('input[type="number"]');
select.value = projectData.projectId;
input.value = projectData.hours;
});
updateTotalHours();
} else {
// 새로운 날짜인 경우 초기화
document.getElementById('projectEntries').innerHTML = '';
projectEntryCounter = 0;
addProjectEntry();
}
}
// 폼 제출
document.getElementById('dailyWorkForm').addEventListener('submit', async (e) => {
e.preventDefault();
const selectedDate = document.getElementById('workDate').value;
const entries = document.querySelectorAll('#projectEntries > div');
const projectData = [];
let hasValidEntry = false;
entries.forEach(entry => {
const select = entry.querySelector('select');
const input = entry.querySelector('input[type="number"]');
const projectId = select.value;
const hours = parseFloat(input.value) || 0;
if (projectId && hours > 0) {
const project = projects.find(p => p.id == projectId);
projectData.push({
projectId: projectId,
projectName: project ? `${project.jobNo} - ${project.projectName}` : '알 수 없음',
hours: hours
});
hasValidEntry = true;
}
});
if (!hasValidEntry) {
alert('최소 하나의 프로젝트에 시간을 입력해주세요.');
return;
}
try {
// 기존 데이터 업데이트 또는 새로 추가
const existingIndex = dailyWorkData.findIndex(d => d.date === selectedDate);
const newData = {
date: selectedDate,
projects: projectData,
totalHours: projectData.reduce((sum, p) => sum + p.hours, 0),
createdAt: new Date().toISOString(),
createdBy: currentUser.username || currentUser
};
if (existingIndex >= 0) {
dailyWorkData[existingIndex] = newData;
} else {
dailyWorkData.push(newData);
}
saveDailyWorkData();
// 성공 메시지
showSuccessMessage();
// 최근 내역 갱신
await loadRecentEntries();
} catch (error) {
alert(error.message || '저장에 실패했습니다.');
}
});
// 최근 데이터 로드
async function loadRecentEntries() {
try {
// 최근 7일 데이터 표시
const recentData = dailyWorkData
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 7);
displayRecentEntries(recentData);
} catch (error) {
console.error('데이터 로드 실패:', error);
document.getElementById('recentEntries').innerHTML =
'<p class="text-gray-500 text-center py-4">데이터를 불러올 수 없습니다.</p>';
}
}
// 성공 메시지
function showSuccessMessage() {
const button = document.querySelector('button[type="submit"]');
const originalHTML = button.innerHTML;
button.innerHTML = '<i class="fas fa-check-circle mr-2"></i>저장 완료!';
button.classList.remove('btn-primary');
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('btn-success');
button.classList.add('btn-primary');
}, 2000);
}
// 최근 입력 내역 표시
function displayRecentEntries(entries) {
const container = document.getElementById('recentEntries');
if (!entries || entries.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-4">최근 입력 내역이 없습니다.</p>';
return;
}
container.innerHTML = entries.map(item => {
const date = new Date(item.date);
const dateStr = `${date.getMonth() + 1}/${date.getDate()} (${['일','월','화','수','목','금','토'][date.getDay()]})`;
return `
<div class="p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex justify-between items-start mb-2">
<p class="font-medium text-gray-800">${dateStr}</p>
<div class="text-right">
<p class="text-lg font-bold text-blue-600">${item.totalHours}시간</p>
${currentUser && (currentUser.role === 'admin' || currentUser.username === 'hyungi') ? `
<button
onclick="deleteDailyWork('${item.date}')"
class="mt-1 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs"
>
<i class="fas fa-trash mr-1"></i>삭제
</button>
` : ''}
</div>
</div>
<div class="space-y-1">
${item.projects.map(p => `
<div class="flex justify-between text-sm text-gray-600">
<span>${p.projectName}</span>
<span>${p.hours}시간</span>
</div>
`).join('')}
</div>
</div>
`;
}).join('');
}
// 일일 공수 삭제 (관리자만)
async function deleteDailyWork(date) {
if (!currentUser || (currentUser.role !== 'admin' && currentUser.username !== 'hyungi')) {
alert('관리자만 삭제할 수 있습니다.');
return;
}
if (!confirm('정말로 이 일일 공수 기록을 삭제하시겠습니까?')) {
return;
}
try {
const index = dailyWorkData.findIndex(d => d.date === date);
if (index >= 0) {
dailyWorkData.splice(index, 1);
saveDailyWorkData();
await loadRecentEntries();
alert('삭제되었습니다.');
}
} catch (error) {
alert('삭제에 실패했습니다.');
}
}
// 로그아웃
function logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,607 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>폐기함 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 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="/static/css/mobile-calendar.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.issue-card {
transition: all 0.2s ease;
opacity: 0.8;
}
.issue-card:hover {
opacity: 1;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.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%);
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-archived { background: #f3f4f6; color: #374151; }
.badge-cancelled { background: #fee2e2; color: #991b1b; }
.chart-container {
position: relative;
height: 300px;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-archive text-gray-500 mr-3"></i>
폐기함
</h1>
<p class="text-gray-600 mt-1">완료되거나 폐기된 부적합 사항을 보관하고 분석하세요</p>
</div>
<div class="flex items-center space-x-3">
<button onclick="generateReport()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-chart-bar mr-2"></i>
통계 보고서
</button>
<button onclick="cleanupArchive()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors">
<i class="fas fa-trash mr-2"></i>
정리하기
</button>
</div>
</div>
<!-- 아카이브 통계 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-green-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-green-600">완료</p>
<p class="text-2xl font-bold text-green-700" id="completedCount">0</p>
</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-archive text-gray-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-gray-600">보관</p>
<p class="text-2xl font-bold text-gray-700" id="archivedCount">0</p>
</div>
</div>
</div>
<div class="bg-red-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-times-circle text-red-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-red-600">취소</p>
<p class="text-2xl font-bold text-red-700" id="cancelledCount">0</p>
</div>
</div>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<div class="flex items-center">
<i class="fas fa-calendar-alt text-purple-500 text-xl mr-3"></i>
<div>
<p class="text-sm text-purple-600">이번 달</p>
<p class="text-2xl font-bold text-purple-700" id="thisMonthCount">0</p>
</div>
</div>
</div>
</div>
</div>
<!-- 필터 및 검색 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<!-- 프로젝트 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📁 프로젝트</label>
<select id="projectFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 프로젝트</option>
</select>
</div>
<!-- 상태 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📋 상태</label>
<select id="statusFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체</option>
<option value="completed">완료</option>
<option value="archived">보관</option>
<option value="cancelled">취소</option>
</select>
</div>
<!-- 기간 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">📅 기간</label>
<select id="periodFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 기간</option>
<option value="week">이번 주</option>
<option value="month">이번 달</option>
<option value="quarter">이번 분기</option>
<option value="year">올해</option>
</select>
</div>
<!-- 카테고리 필터 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🏷️ 카테고리</label>
<select id="categoryFilter" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500" onchange="filterIssues()">
<option value="">전체 카테고리</option>
<option value="material_missing">자재 누락</option>
<option value="design_error">설계 오류</option>
<option value="incoming_defect">반입 불량</option>
<option value="inspection_miss">검사 누락</option>
<option value="etc">기타</option>
</select>
</div>
<!-- 검색 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">🔍 검색</label>
<input type="text" id="searchInput" placeholder="설명 또는 등록자 검색..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-gray-500"
onkeyup="filterIssues()">
</div>
</div>
</div>
<!-- 통계 차트 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- 월별 완료 현황 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">월별 완료 현황</h3>
<div class="chart-container">
<canvas id="monthlyChart"></canvas>
</div>
</div>
<!-- 카테고리별 분포 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
<div class="chart-container">
<canvas id="categoryChart"></canvas>
</div>
</div>
</div>
<!-- 폐기함 목록 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800">보관된 부적합</h2>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">정렬:</span>
<select id="sortOrder" class="text-sm border border-gray-300 rounded px-2 py-1" onchange="sortIssues()">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
<option value="completed">완료일순</option>
<option value="category">카테고리순</option>
</select>
</div>
</div>
</div>
<div id="issuesList" class="divide-y divide-gray-200">
<!-- 부적합 목록이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 빈 상태 -->
<div id="emptyState" class="hidden p-12 text-center">
<i class="fas fa-archive text-6xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">폐기함이 비어있습니다</h3>
<p class="text-gray-500">완료되거나 폐기된 부적합이 있으면 여기에 표시됩니다.</p>
</div>
</div>
</main>
<!-- Scripts -->
<script src="/static/js/date-utils.js?v=20250917"></script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
let currentUser = null;
let issues = [];
let projects = [];
let filteredIssues = [];
// API 로드 후 초기화 함수
async function initializeArchive() {
const token = localStorage.getItem('access_token');
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);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
}
}
// 프로젝트 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`,
'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 ${localStorage.getItem('access_token')}`,
'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>
</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

View File

@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>모바일 프로젝트 문제 해결</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
margin: 0;
background: #f5f5f5;
}
.container {
max-width: 100%;
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 20px;
}
.section {
margin-bottom: 30px;
padding: 15px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
font-size: 14px;
}
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
.warning { background: #fff3cd; color: #856404; }
button {
background: #3b82f6;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
margin: 5px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
button:active {
background: #2563eb;
}
select {
width: 100%;
padding: 12px;
font-size: 16px;
border: 2px solid #3b82f6;
border-radius: 8px;
background: white;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 20px;
}
pre {
background: #f4f4f4;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
font-size: 12px;
}
.test-select {
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🔧 모바일 프로젝트 문제 해결</h1>
<div class="section">
<h2>📱 디바이스 정보</h2>
<div id="deviceInfo"></div>
</div>
<div class="section">
<h2>💾 localStorage 상태</h2>
<div id="storageStatus"></div>
<button onclick="checkStorage()">localStorage 확인</button>
<button onclick="fixProjects()">프로젝트 복구</button>
</div>
<div class="section">
<h2>🧪 드롭다운 테스트</h2>
<div class="test-select">
<label>테스트 드롭다운:</label>
<select id="testSelect">
<option value="">선택하세요</option>
</select>
</div>
<button onclick="testDropdown()">드롭다운 테스트</button>
</div>
<div class="section">
<h2>📊 실제 프로젝트 드롭다운</h2>
<div class="test-select">
<label>프로젝트 선택:</label>
<select id="projectSelect">
<option value="">프로젝트를 선택하세요</option>
</select>
</div>
<button onclick="loadProjects()">프로젝트 로드</button>
</div>
<div class="section">
<h2>🔍 디버그 로그</h2>
<pre id="debugLog"></pre>
<button onclick="clearLog()">로그 지우기</button>
</div>
<div style="margin-top: 30px;">
<button onclick="location.href='index.html'">메인으로</button>
<button onclick="location.reload()">새로고침</button>
</div>
</div>
<script>
let logContent = '';
function log(message) {
const time = new Date().toLocaleTimeString('ko-KR');
logContent += `[${time}] ${message}\n`;
document.getElementById('debugLog').textContent = logContent;
}
function clearLog() {
logContent = '';
document.getElementById('debugLog').textContent = '';
}
// 디바이스 정보
function showDeviceInfo() {
const info = `
<div class="status info">
<strong>화면 크기:</strong> ${window.innerWidth} x ${window.innerHeight}<br>
<strong>User Agent:</strong> ${navigator.userAgent}<br>
<strong>플랫폼:</strong> ${navigator.platform}<br>
<strong>모바일 여부:</strong> ${window.innerWidth <= 768 ? '예' : '아니오'}
</div>
`;
document.getElementById('deviceInfo').innerHTML = info;
log('디바이스 정보 표시 완료');
}
// localStorage 확인
function checkStorage() {
log('localStorage 확인 시작');
const statusDiv = document.getElementById('storageStatus');
try {
// 프로젝트 데이터 확인
const projectData = localStorage.getItem('work-report-projects');
if (projectData) {
const projects = JSON.parse(projectData);
statusDiv.innerHTML = `
<div class="status success">
✅ 프로젝트 데이터 있음: ${projects.length}
</div>
<pre>${JSON.stringify(projects, null, 2)}</pre>
`;
log(`프로젝트 ${projects.length}개 발견`);
} else {
statusDiv.innerHTML = `
<div class="status warning">
⚠️ 프로젝트 데이터 없음
</div>
`;
log('프로젝트 데이터 없음');
}
// 사용자 데이터 확인
const userData = localStorage.getItem('currentUser');
if (userData) {
const user = JSON.parse(userData);
statusDiv.innerHTML += `
<div class="status success">
✅ 사용자: ${user.username} (${user.role})
</div>
`;
log(`사용자: ${user.username}`);
}
} catch (error) {
statusDiv.innerHTML = `
<div class="status error">
❌ 에러: ${error.message}
</div>
`;
log(`에러: ${error.message}`);
}
}
// 프로젝트 복구
function fixProjects() {
log('프로젝트 복구 시작');
const projects = [
{
id: 1,
jobNo: 'TKR-25009R',
projectName: 'M Project',
isActive: true,
createdAt: new Date().toISOString(),
createdByName: '관리자'
},
{
id: 2,
jobNo: 'TKG-24011P',
projectName: 'TKG Project',
isActive: true,
createdAt: new Date().toISOString(),
createdByName: '관리자'
}
];
try {
localStorage.setItem('work-report-projects', JSON.stringify(projects));
log('프로젝트 데이터 저장 완료');
alert('프로젝트가 복구되었습니다!');
checkStorage();
loadProjects();
} catch (error) {
log(`복구 실패: ${error.message}`);
alert('복구 실패: ' + error.message);
}
}
// 드롭다운 테스트
function testDropdown() {
log('드롭다운 테스트 시작');
const select = document.getElementById('testSelect');
// 옵션 추가
select.innerHTML = '<option value="">선택하세요</option>';
for (let i = 1; i <= 5; i++) {
const option = document.createElement('option');
option.value = i;
option.textContent = `테스트 옵션 ${i}`;
select.appendChild(option);
}
log(`테스트 옵션 ${select.options.length - 1}개 추가됨`);
// 이벤트 리스너
select.onchange = function() {
log(`선택됨: ${this.value} - ${this.options[this.selectedIndex].text}`);
};
}
// 프로젝트 로드
function loadProjects() {
log('프로젝트 로드 시작');
const select = document.getElementById('projectSelect');
try {
const saved = localStorage.getItem('work-report-projects');
if (!saved) {
log('localStorage에 프로젝트 없음');
alert('프로젝트 데이터가 없습니다. "프로젝트 복구" 버튼을 눌러주세요.');
return;
}
const projects = JSON.parse(saved);
const activeProjects = projects.filter(p => p.isActive);
log(`활성 프로젝트 ${activeProjects.length}개 발견`);
// 드롭다운 초기화
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
// 프로젝트 옵션 추가
activeProjects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = `${project.jobNo} - ${project.projectName}`;
select.appendChild(option);
log(`옵션 추가: ${project.jobNo} - ${project.projectName}`);
});
log(`드롭다운에 ${select.options.length - 1}개 프로젝트 표시됨`);
// 이벤트 리스너
select.onchange = function() {
log(`프로젝트 선택됨: ${this.value}`);
};
} catch (error) {
log(`프로젝트 로드 에러: ${error.message}`);
alert('프로젝트 로드 실패: ' + error.message);
}
}
// 페이지 로드 시 실행
window.onload = function() {
log('페이지 로드 완료');
showDeviceInfo();
checkStorage();
testDropdown();
loadProjects();
};
</script>
</body>
</html>

View File

@@ -0,0 +1,51 @@
server {
listen 80;
server_name _;
client_max_body_size 10M;
root /usr/share/nginx/html;
index index.html;
# HTML 캐시 비활성화
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# JS/CSS 캐시 비활성화
location ~* \.(js|css)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# 정적 파일 (이미지 등)
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
expires 1h;
add_header Cache-Control "public, no-transform";
}
# API 프록시 (System 3 API)
location /api/ {
proxy_pass http://system3-api:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# 업로드 파일
location /uploads/ {
alias /usr/share/nginx/html/uploads/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 관리 - 작업보고서 시스템</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary: #3b82f6;
--primary-dark: #2563eb;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
}
body {
background-color: var(--gray-50);
}
.btn-primary {
background-color: var(--primary);
color: white;
transition: all 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* 부드러운 페이드인 애니메이션 */
.fade-in {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 헤더 전용 빠른 페이드인 */
.header-fade-in {
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
}
.header-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
/* 본문 컨텐츠 지연 페이드인 */
.content-fade-in {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
transition-delay: 0.2s;
}
.content-fade-in.visible {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- 메인 컨텐츠 -->
<main class="container mx-auto px-4 py-8 max-w-4xl content-fade-in" style="padding-top: 80px;">
<!-- 프로젝트 생성 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-plus text-green-500 mr-2"></i>새 프로젝트 생성
</h2>
<form id="projectForm" class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Job No.</label>
<input
type="text"
id="jobNo"
class="input-field w-full px-4 py-2 rounded-lg"
placeholder="예: JOB-2024-001"
required
maxlength="50"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">프로젝트 이름</label>
<input
type="text"
id="projectName"
class="input-field w-full px-4 py-2 rounded-lg"
placeholder="프로젝트 이름을 입력하세요"
required
maxlength="200"
>
</div>
<div class="md:col-span-2">
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>프로젝트 생성
</button>
</div>
</form>
</div>
<!-- 프로젝트 목록 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">프로젝트 목록</h2>
<button onclick="loadProjects()" class="text-blue-600 hover:text-blue-800">
<i class="fas fa-refresh mr-1"></i>새로고침
</button>
</div>
<div id="projectsList" class="space-y-3">
<!-- 프로젝트 목록이 여기에 표시됩니다 -->
</div>
</div>
<!-- 비활성화된 프로젝트 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mt-6">
<div class="flex justify-between items-center mb-4 cursor-pointer" onclick="toggleInactiveProjects()">
<div class="flex items-center space-x-2">
<i id="inactiveToggleIcon" class="fas fa-chevron-down transition-transform duration-200"></i>
<h2 class="text-lg font-semibold text-gray-600">비활성화된 프로젝트</h2>
<span id="inactiveProjectCount" class="bg-gray-100 text-gray-600 px-2 py-1 rounded-full text-sm font-medium">0</span>
</div>
<div class="text-sm text-gray-500">
<i class="fas fa-info-circle mr-1"></i>클릭하여 펼치기/접기
</div>
</div>
<div id="inactiveProjectsList" class="space-y-3">
<!-- 비활성화된 프로젝트 목록이 여기에 표시됩니다 -->
</div>
</div>
</main>
<!-- API 스크립트 먼저 로드 (최강 캐시 무력화) -->
<script>
// 브라우저 캐시 완전 무력화
const timestamp = new Date().getTime();
const random1 = Math.random() * 1000000;
const random2 = Math.floor(Math.random() * 1000000);
const cacheBuster = `${timestamp}-${random1}-${random2}`;
const script = document.createElement('script');
script.src = `/static/js/api.js?force-reload=${cacheBuster}&no-cache=${timestamp}&bust=${random2}`;
script.onload = function() {
console.log('✅ API 스크립트 로드 완료');
console.log('🔍 API_BASE_URL:', typeof API_BASE_URL !== 'undefined' ? API_BASE_URL : 'undefined');
console.log('🌐 현재 hostname:', window.location.hostname);
console.log('🔗 현재 protocol:', window.location.protocol);
// API 로드 후 인증 체크 시작
setTimeout(checkAdminAccess, 100);
};
script.setAttribute('cache-control', 'no-cache, no-store, must-revalidate');
script.setAttribute('pragma', 'no-cache');
script.setAttribute('expires', '0');
document.head.appendChild(script);
console.log('🚀 캐시 버스터:', cacheBuster);
</script>
<!-- 메인 스크립트 -->
<script>
// 관리자 권한 확인 함수
async function checkAdminAccess() {
try {
const currentUser = await AuthAPI.getCurrentUser();
if (!currentUser || currentUser.role !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/index.html';
return;
}
// 권한 확인 후 페이지 초기화
initializeProjectManagement();
} catch (error) {
console.error('권한 확인 실패:', error);
alert('로그인이 필요합니다.');
window.location.href = '/index.html';
}
}
// 프로젝트 관리 페이지 초기화 함수
async function initializeProjectManagement() {
try {
console.log('🚀 프로젝트 관리 페이지 초기화 시작');
// 헤더 애니메이션 시작
animateHeaderAppearance();
// 프로젝트 목록 로드
await loadProjects();
console.log('✅ 프로젝트 관리 페이지 초기화 완료');
} catch (error) {
console.error('❌ 프로젝트 관리 페이지 초기화 실패:', error);
alert('페이지 로드 중 오류가 발생했습니다.');
}
}
</script>
<script src="/static/js/core/permissions.js?v=20251025"></script>
<script src="/static/js/components/common-header.js?v=20251025"></script>
<script src="/static/js/core/page-manager.js?v=20251025"></script>
<script>
// 전역 변수
let currentUser = null;
// 애니메이션 함수들
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씩 지연
});
}
async function initAuth() {
console.log('인증 초기화 시작');
const token = localStorage.getItem('access_token');
console.log('토큰 존재:', !!token);
if (!token) {
console.log('토큰 없음 - 로그인 페이지로 이동');
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return false;
}
try {
console.log('API로 사용자 정보 가져오는 중...');
const user = await AuthAPI.getCurrentUser();
console.log('사용자 정보:', user);
currentUser = user;
localStorage.setItem('currentUser', JSON.stringify(user));
return true;
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
alert('로그인이 필요합니다.');
window.location.href = 'index.html';
return false;
}
}
async function checkAdminAccess() {
const authSuccess = await initAuth();
if (!authSuccess) return;
// 공통 헤더 초기화
await window.commonHeader.init(currentUser, 'projects_manage');
// 헤더 초기화 후 부드러운 애니메이션 시작
setTimeout(() => {
animateHeaderAppearance();
}, 100);
// 페이지 접근 권한 체크 (프로젝트 관리 페이지)
setTimeout(() => {
if (!canAccessPage('projects_manage')) {
alert('프로젝트 관리 페이지에 접근할 권한이 없습니다.');
window.location.href = 'index.html';
return;
}
}, 500);
// 사용자 정보는 공통 헤더에서 표시됨
// 프로젝트 로드
loadProjects();
}
let projects = [];
// 프로젝트 데이터 로드 (API 기반)
async function loadProjects() {
console.log('프로젝트 로드 시작 (API)');
try {
// API에서 모든 프로젝트 로드 (활성/비활성 모두)
const apiProjects = await ProjectsAPI.getAll(false);
// API 데이터를 그대로 사용 (필드명 통일)
projects = apiProjects;
console.log('API에서 프로젝트 로드:', projects.length, '개');
} catch (error) {
console.error('API 로드 실패:', error);
projects = [];
}
displayProjectList();
}
// 프로젝트 데이터 저장 (더 이상 사용하지 않음 - API 기반)
// function saveProjects() {
// localStorage.setItem('work-report-projects', JSON.stringify(projects));
// }
// 프로젝트 생성 폼 처리
document.getElementById('projectForm').addEventListener('submit', async (e) => {
e.preventDefault();
const jobNo = document.getElementById('jobNo').value.trim();
const projectName = document.getElementById('projectName').value.trim();
// 중복 Job No. 확인
if (projects.some(p => p.job_no === jobNo)) {
alert('이미 존재하는 Job No.입니다.');
return;
}
try {
// API를 통한 프로젝트 생성
const newProject = await ProjectsAPI.create({
job_no: jobNo,
project_name: projectName
});
// 성공 메시지
alert('프로젝트가 생성되었습니다.');
// 폼 초기화
document.getElementById('projectForm').reset();
// 목록 새로고침
await loadProjects();
displayProjectList();
} catch (error) {
console.error('프로젝트 생성 실패:', error);
alert('프로젝트 생성에 실패했습니다: ' + error.message);
}
});
// 프로젝트 목록 표시
function displayProjectList() {
const activeContainer = document.getElementById('projectsList');
const inactiveContainer = document.getElementById('inactiveProjectsList');
const inactiveCount = document.getElementById('inactiveProjectCount');
activeContainer.innerHTML = '';
inactiveContainer.innerHTML = '';
if (projects.length === 0) {
activeContainer.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 프로젝트가 없습니다.</p>';
inactiveCount.textContent = '0';
return;
}
// 활성 프로젝트와 비활성 프로젝트 분리
const activeProjects = projects.filter(p => p.is_active);
const inactiveProjects = projects.filter(p => !p.is_active);
console.log('전체 프로젝트:', projects.length, '개');
console.log('활성 프로젝트:', activeProjects.length, '개');
console.log('비활성 프로젝트:', inactiveProjects.length, '개');
// 비활성 프로젝트 개수 업데이트
inactiveCount.textContent = inactiveProjects.length;
// 활성 프로젝트 표시
if (activeProjects.length > 0) {
activeProjects.forEach(project => {
const div = document.createElement('div');
div.className = 'border border-green-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-green-50';
div.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-800">${project.job_no}</h3>
<span class="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">활성</span>
</div>
<p class="text-gray-600 mb-2">${project.project_name}</p>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span><i class="fas fa-user mr-1"></i>${project.created_by?.full_name || '관리자'}</span>
<span><i class="fas fa-calendar mr-1"></i>${new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="flex gap-2">
<button onclick="editProject(${project.id})" class="text-blue-600 hover:text-blue-800 p-2" title="수정">
<i class="fas fa-edit"></i>
</button>
<button onclick="toggleProjectStatus(${project.id})" class="text-orange-600 hover:text-orange-800 p-2" title="비활성화">
<i class="fas fa-pause"></i>
</button>
<button onclick="deleteProject(${project.id})" class="text-red-600 hover:text-red-800 p-2" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
activeContainer.appendChild(div);
});
} else {
activeContainer.innerHTML = '<p class="text-gray-500 text-center py-8">활성 프로젝트가 없습니다.</p>';
}
// 비활성 프로젝트 표시
if (inactiveProjects.length > 0) {
inactiveProjects.forEach(project => {
const div = document.createElement('div');
div.className = 'border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow mb-3 bg-gray-50';
div.innerHTML = `
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-gray-600">${project.job_no}</h3>
<span class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">비활성</span>
</div>
<p class="text-gray-500 mb-2">${project.project_name}</p>
<div class="flex items-center gap-4 text-sm text-gray-400">
<span><i class="fas fa-user mr-1"></i>${project.created_by?.full_name || '관리자'}</span>
<span><i class="fas fa-calendar mr-1"></i>${new Date(project.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="flex gap-2">
<button onclick="toggleProjectStatus(${project.id})" class="text-green-600 hover:text-green-800 p-2" title="활성화">
<i class="fas fa-play"></i>
</button>
<button onclick="deleteProject(${project.id})" class="text-red-600 hover:text-red-800 p-2" title="완전 삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`;
inactiveContainer.appendChild(div);
});
} else {
inactiveContainer.innerHTML = '<p class="text-gray-500 text-center py-8">비활성화된 프로젝트가 없습니다.</p>';
}
}
// 비활성 프로젝트 섹션 토글
function toggleInactiveProjects() {
const inactiveList = document.getElementById('inactiveProjectsList');
const toggleIcon = document.getElementById('inactiveToggleIcon');
if (inactiveList.style.display === 'none') {
// 펼치기
inactiveList.style.display = 'block';
toggleIcon.classList.remove('fa-chevron-right');
toggleIcon.classList.add('fa-chevron-down');
} else {
// 접기
inactiveList.style.display = 'none';
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-right');
}
}
// 프로젝트 편집
async function editProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const newName = prompt('프로젝트 이름을 수정하세요:', project.project_name);
if (newName && newName.trim() && newName.trim() !== project.project_name) {
try {
// API를 통한 프로젝트 업데이트
await ProjectsAPI.update(projectId, {
project_name: newName.trim()
});
// 목록 새로고침
await loadProjects();
displayProjectList();
alert('프로젝트가 수정되었습니다.');
} catch (error) {
console.error('프로젝트 수정 실패:', error);
alert('프로젝트 수정에 실패했습니다: ' + error.message);
}
}
}
// 프로젝트 활성/비활성 토글
async function toggleProjectStatus(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const action = project.is_active ? '비활성화' : '활성화';
if (confirm(`"${project.job_no}" 프로젝트를 ${action}하시겠습니까?`)) {
try {
// API를 통한 프로젝트 상태 업데이트
await ProjectsAPI.update(projectId, {
is_active: !project.is_active
});
// 목록 새로고침
await loadProjects();
displayProjectList();
alert(`프로젝트가 ${action}되었습니다.`);
} catch (error) {
console.error('프로젝트 상태 변경 실패:', error);
alert('프로젝트 상태 변경에 실패했습니다: ' + error.message);
}
}
}
// 프로젝트 삭제 (완전 삭제)
async function deleteProject(projectId) {
const project = projects.find(p => p.id === projectId);
if (!project) return;
const confirmMessage = project.is_active
? `"${project.job_no}" 프로젝트를 완전히 삭제하시겠습니까?\n\n※ 활성 프로젝트입니다. 먼저 비활성화를 권장합니다.`
: `"${project.job_no}" 프로젝트를 완전히 삭제하시겠습니까?\n\n※ 이 작업은 되돌릴 수 없습니다.`;
if (confirm(confirmMessage)) {
try {
// API를 통한 프로젝트 삭제
await ProjectsAPI.delete(projectId);
// 목록 새로고침
await loadProjects();
displayProjectList();
alert('프로젝트가 완전히 삭제되었습니다.');
} catch (error) {
console.error('프로젝트 삭제 실패:', error);
alert('프로젝트 삭제에 실패했습니다: ' + error.message);
}
}
}
// DOMContentLoaded 이벤트 제거 - API 스크립트 로드 후 checkAdminAccess() 호출됨
</script>
</body>
</html>

View File

@@ -0,0 +1,501 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.report-card {
transition: all 0.2s ease;
border-left: 4px solid transparent;
}
.report-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-left-color: #10b981;
}
.stats-card {
transition: all 0.2s ease;
}
.stats-card:hover {
transform: translateY(-1px);
}
.issue-row {
transition: all 0.2s ease;
}
.issue-row:hover {
background-color: #f9fafb;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-file-excel text-green-500 mr-3"></i>
일일보고서
</h1>
<p class="text-gray-600 mt-1">프로젝트별 진행중/완료 항목을 엑셀로 내보내세요</p>
</div>
</div>
</div>
<!-- 프로젝트 선택 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="space-y-6">
<!-- 프로젝트 선택 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-3">
<i class="fas fa-folder text-blue-500 mr-2"></i>보고서 생성할 프로젝트 선택
</label>
<select id="reportProjectSelect" class="w-full max-w-md px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-lg">
<option value="">프로젝트를 선택하세요</option>
</select>
<p class="text-sm text-gray-500 mt-2">
<i class="fas fa-info-circle mr-1"></i>
진행 중인 항목 + 완료되고 한번도 추출 안된 항목이 포함됩니다.
</p>
</div>
<!-- 버튼 -->
<div class="flex items-center space-x-4">
<button id="previewBtn"
onclick="loadPreview()"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-eye mr-2"></i>미리보기
</button>
<button id="generateReportBtn"
onclick="generateDailyReport()"
class="px-6 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed hidden">
<i class="fas fa-download mr-2"></i>일일보고서 생성
</button>
</div>
</div>
</div>
<!-- 미리보기 섹션 -->
<div id="previewSection" class="hidden">
<!-- 통계 카드 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-chart-bar text-blue-500 mr-2"></i>추출 항목 통계
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="stats-card bg-blue-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-blue-600 mb-1" id="previewTotalCount">0</div>
<div class="text-sm text-blue-700 font-medium">총 추출 수량</div>
</div>
<div class="stats-card bg-orange-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-orange-600 mb-1" id="previewInProgressCount">0</div>
<div class="text-sm text-orange-700 font-medium">진행 중</div>
</div>
<div class="stats-card bg-green-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-green-600 mb-1" id="previewCompletedCount">0</div>
<div class="text-sm text-green-700 font-medium">완료 (미추출)</div>
</div>
<div class="stats-card bg-red-50 p-4 rounded-lg text-center">
<div class="text-3xl font-bold text-red-600 mb-1" id="previewDelayedCount">0</div>
<div class="text-sm text-red-700 font-medium">지연 중</div>
</div>
</div>
</div>
<!-- 항목 목록 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-500 mr-2"></i>추출될 항목 목록
</h2>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr class="border-b">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">No</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">부적합명</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">추출이력</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">담당부서</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">신고일</th>
</tr>
</thead>
<tbody id="previewTableBody" class="divide-y divide-gray-200">
<!-- 동적으로 채워짐 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 포함 항목 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list-check text-gray-500 mr-2"></i>보고서 포함 항목 안내
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="report-card bg-blue-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-check-circle text-blue-500 mr-2"></i>
<span class="font-medium text-blue-800">진행 중 항목</span>
</div>
<p class="text-sm text-blue-600">모든 진행 중인 항목이 포함됩니다 (추출 이력과 무관)</p>
</div>
<div class="report-card bg-green-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="font-medium text-green-800">완료됨 항목</span>
</div>
<p class="text-sm text-green-600">한번도 추출 안된 완료 항목만 포함, 이후 자동 제외</p>
</div>
<div class="report-card bg-yellow-50 p-4 rounded-lg">
<div class="flex items-center mb-2">
<i class="fas fa-info-circle text-yellow-500 mr-2"></i>
<span class="font-medium text-yellow-800">추출 이력 기록</span>
</div>
<p class="text-sm text-yellow-600">추출 시 자동으로 이력이 기록됩니다</p>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
let projects = [];
let selectedProjectId = null;
let previewData = null;
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('일일보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 프로젝트 목록 로드
await loadProjects();
// 공통 헤더 초기화
try {
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_daily');
}
} catch (headerError) {
console.error('공통 헤더 초기화 오류:', headerError);
}
console.log('일일보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
// 프로젝트 목록 로드
async function loadProjects() {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/projects/`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
projects = await response.json();
populateProjectSelect();
} else {
console.error('프로젝트 로드 실패:', response.status);
}
} catch (error) {
console.error('프로젝트 로드 오류:', error);
}
}
// 프로젝트 선택 옵션 채우기
function populateProjectSelect() {
const select = document.getElementById('reportProjectSelect');
if (!select) {
console.error('reportProjectSelect 요소를 찾을 수 없습니다!');
return;
}
select.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
projects.forEach(project => {
const option = document.createElement('option');
option.value = project.id;
option.textContent = project.project_name || project.name;
select.appendChild(option);
});
}
// 프로젝트 선택 시 이벤트
document.addEventListener('change', async function(e) {
if (e.target.id === 'reportProjectSelect') {
selectedProjectId = e.target.value;
const previewBtn = document.getElementById('previewBtn');
const generateBtn = document.getElementById('generateReportBtn');
const previewSection = document.getElementById('previewSection');
if (selectedProjectId) {
previewBtn.classList.remove('hidden');
generateBtn.classList.remove('hidden');
previewSection.classList.add('hidden');
previewData = null;
} else {
previewBtn.classList.add('hidden');
generateBtn.classList.add('hidden');
previewSection.classList.add('hidden');
previewData = null;
}
}
});
// 미리보기 로드
async function loadPreview() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
previewData = await response.json();
displayPreview(previewData);
} else {
alert('미리보기 로드에 실패했습니다.');
}
} catch (error) {
console.error('미리보기 로드 오류:', error);
alert('미리보기 로드 중 오류가 발생했습니다.');
}
}
// 미리보기 표시
function displayPreview(data) {
// 통계 업데이트
const inProgressCount = data.issues.filter(i => i.review_status === 'in_progress').length;
const completedCount = data.issues.filter(i => i.review_status === 'completed').length;
document.getElementById('previewTotalCount').textContent = data.total_issues;
document.getElementById('previewInProgressCount').textContent = inProgressCount;
document.getElementById('previewCompletedCount').textContent = completedCount;
document.getElementById('previewDelayedCount').textContent = data.stats.delayed_count;
// 테이블 업데이트
const tbody = document.getElementById('previewTableBody');
tbody.innerHTML = '';
data.issues.forEach(issue => {
const row = document.createElement('tr');
row.className = 'issue-row';
const statusBadge = getStatusBadge(issue);
const exportBadge = getExportBadge(issue);
const department = getDepartmentText(issue.responsible_department);
const reportDate = issue.report_date ? new Date(issue.report_date).toLocaleDateString('ko-KR') : '-';
row.innerHTML = `
<td class="px-4 py-3 text-sm text-gray-900">${issue.project_sequence_no || issue.id}</td>
<td class="px-4 py-3 text-sm text-gray-900">${issue.final_description || issue.description || '-'}</td>
<td class="px-4 py-3 text-sm">${statusBadge}</td>
<td class="px-4 py-3 text-sm">${exportBadge}</td>
<td class="px-4 py-3 text-sm text-gray-900">${department}</td>
<td class="px-4 py-3 text-sm text-gray-500">${reportDate}</td>
`;
tbody.appendChild(row);
});
// 미리보기 섹션 표시
document.getElementById('previewSection').classList.remove('hidden');
}
// 상태 배지 (지연/진행중/완료 구분)
function getStatusBadge(issue) {
// 완료됨
if (issue.review_status === 'completed') {
return '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">완료됨</span>';
}
// 진행 중인 경우 지연 여부 확인
if (issue.review_status === 'in_progress') {
if (issue.expected_completion_date) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const expectedDate = new Date(issue.expected_completion_date);
expectedDate.setHours(0, 0, 0, 0);
if (expectedDate < today) {
return '<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">지연중</span>';
}
}
return '<span class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-800 rounded">진행중</span>';
}
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">' + (issue.review_status || '-') + '</span>';
}
// 추출 이력 배지
function getExportBadge(issue) {
if (issue.last_exported_at) {
const exportDate = new Date(issue.last_exported_at).toLocaleDateString('ko-KR');
return `<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">추출됨 (${issue.export_count || 1}회)</span>`;
} else {
return '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded">미추출</span>';
}
}
// 부서명 변환
function getDepartmentText(department) {
const map = {
'production': '생산',
'quality': '품질',
'purchasing': '구매',
'design': '설계',
'sales': '영업'
};
return map[department] || '-';
}
// 일일보고서 생성
async function generateDailyReport() {
if (!selectedProjectId) {
alert('프로젝트를 선택해주세요.');
return;
}
// 미리보기 데이터가 있고 항목이 0개인 경우
if (previewData && previewData.total_issues === 0) {
alert('추출할 항목이 없습니다.');
return;
}
try {
const button = document.getElementById('generateReportBtn');
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>생성 중...';
button.disabled = true;
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/reports/daily-export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
project_id: parseInt(selectedProjectId)
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
// 파일명 생성
const project = projects.find(p => p.id == selectedProjectId);
const today = new Date().toISOString().split('T')[0];
a.download = `${project.project_name}_일일보고서_${today}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// 성공 메시지
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
// 미리보기 새로고침
if (previewData) {
setTimeout(() => loadPreview(), 1000);
}
} else {
const error = await response.text();
console.error('보고서 생성 실패:', error);
alert('보고서 생성에 실패했습니다. 다시 시도해주세요.');
}
} catch (error) {
console.error('보고서 생성 오류:', error);
alert('보고서 생성 중 오류가 발생했습니다.');
} finally {
const button = document.getElementById('generateReportBtn');
button.innerHTML = '<i class="fas fa-download mr-2"></i>일일보고서 생성';
button.disabled = false;
}
}
// 성공 메시지 표시
function showSuccessMessage(message) {
const successDiv = document.createElement('div');
successDiv.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
successDiv.innerHTML = `
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(successDiv);
setTimeout(() => {
successDiv.remove();
}, 3000);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>월간보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-calendar-alt text-purple-500 mr-3"></i>
월간보고서
</h1>
<p class="text-gray-600 mt-1">월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합적으로 분석하세요</p>
</div>
</div>
</div>
<!-- 준비중 안내 -->
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-calendar-alt text-purple-500 text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">월간보고서 준비중</h2>
<p class="text-gray-600 mb-6">
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서 기능을 준비하고 있습니다.
</p>
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="font-semibold text-purple-800 mb-2">예정 기능</h3>
<ul class="text-sm text-purple-700 space-y-1">
<li>• 월간 부적합 발생 현황</li>
<li>• 월간 처리 완료 현황</li>
<li>• 부서별 성과 분석</li>
<li>• 월간 트렌드 및 개선사항</li>
<li>• 경영진 보고용 요약</li>
</ul>
</div>
<div class="mt-6">
<button onclick="window.history.back()"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('월간보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_monthly');
}
console.log('월간보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>주간보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-calendar-week text-blue-500 mr-3"></i>
주간보고서
</h1>
<p class="text-gray-600 mt-1">주간 단위로 집계된 부적합 현황 및 처리 결과를 확인하세요</p>
</div>
</div>
</div>
<!-- 준비중 안내 -->
<div class="bg-white rounded-xl shadow-sm p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-calendar-week text-blue-500 text-3xl"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">주간보고서 준비중</h2>
<p class="text-gray-600 mb-6">
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서 기능을 준비하고 있습니다.
</p>
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="font-semibold text-blue-800 mb-2">예정 기능</h3>
<ul class="text-sm text-blue-700 space-y-1">
<li>• 주간 부적합 발생 현황</li>
<li>• 주간 처리 완료 현황</li>
<li>• 부서별 처리 성과</li>
<li>• 주간 트렌드 분석</li>
</ul>
</div>
<div class="mt-6">
<button onclick="window.history.back()"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
</button>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('주간보고서 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports_weekly');
}
console.log('주간보고서 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>보고서 - 작업보고서</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Custom Styles -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body {
font-family: 'Inter', sans-serif;
}
.report-card {
transition: all 0.3s ease;
border-left: 4px solid transparent;
}
.report-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15);
border-left-color: #3b82f6;
}
.report-card.daily-report {
border-left-color: #10b981;
}
.report-card.daily-report:hover {
border-left-color: #059669;
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.stats-card {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
</style>
</head>
<body class="bg-gray-50">
<!-- 공통 헤더 -->
<div id="commonHeader"></div>
<!-- Main Content -->
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
<!-- 페이지 헤더 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
<i class="fas fa-chart-bar text-red-500 mr-3"></i>
보고서
</h1>
<p class="text-gray-600 mt-1">다양한 보고서를 생성하고 관리할 수 있습니다</p>
</div>
</div>
</div>
<!-- 보고서 카테고리 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-list text-gray-500 mr-2"></i>보고서 유형 선택
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 일일보고서 -->
<a href="/reports-daily.html" class="report-card bg-green-50 p-4 rounded-lg hover:bg-green-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<i class="fas fa-file-excel text-green-600"></i>
</div>
<span class="bg-green-100 text-green-800 text-xs font-medium px-2 py-1 rounded-full">
사용 가능
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">일일보고서</h3>
<p class="text-sm text-gray-600 mb-3">
관리함 데이터를 기반으로 품질팀용 일일보고서를 엑셀 형태로 생성합니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-green-600 font-medium">
<i class="fas fa-check-circle mr-1"></i>진행중 항목 포함
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
<!-- 주간보고서 -->
<a href="/reports-weekly.html" class="report-card bg-blue-50 p-4 rounded-lg hover:bg-blue-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-week text-blue-600"></i>
</div>
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
준비중
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">주간보고서</h3>
<p class="text-sm text-gray-600 mb-3">
주간 단위로 집계된 부적합 현황 및 처리 결과를 정리한 보고서입니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-blue-600 font-medium">
<i class="fas fa-calendar mr-1"></i>주간 집계
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
<!-- 월간보고서 -->
<a href="/reports-monthly.html" class="report-card bg-purple-50 p-4 rounded-lg hover:bg-purple-100 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<i class="fas fa-calendar-alt text-purple-600"></i>
</div>
<span class="bg-yellow-100 text-yellow-800 text-xs font-medium px-2 py-1 rounded-full">
준비중
</span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">월간보고서</h3>
<p class="text-sm text-gray-600 mb-3">
월간 부적합 발생 현황, 처리 성과 및 개선사항을 종합한 보고서입니다.
</p>
<div class="flex items-center justify-between">
<span class="text-xs text-purple-600 font-medium">
<i class="fas fa-chart-line mr-1"></i>월간 분석
</span>
<i class="fas fa-arrow-right text-gray-400"></i>
</div>
</a>
</div>
</div>
<!-- 보고서 안내 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-info-circle text-blue-500 mr-2"></i>보고서 이용 안내
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="font-semibold text-gray-800">📊 일일보고서</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>• 관리함의 진행 중 항목 무조건 포함</li>
<li>• 완료됨 항목은 첫 내보내기에만 포함</li>
<li>• 프로젝트별 개별 생성</li>
<li>• 엑셀 형태로 다운로드</li>
</ul>
</div>
<div class="space-y-3">
<h3 class="font-semibold text-gray-800">🚀 향후 계획</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>• 주간보고서: 주간 집계 및 트렌드 분석</li>
<li>• 월간보고서: 월간 성과 및 개선사항</li>
<li>• 자동 이메일 발송 기능</li>
<li>• 대시보드 형태의 실시간 리포트</li>
</ul>
</div>
</div>
</div>
</main>
<!-- JavaScript -->
<script src="/static/js/core/auth-manager.js"></script>
<script src="/static/js/core/permissions.js"></script>
<script src="/static/js/components/common-header.js"></script>
<script src="/static/js/api.js"></script>
<script>
// 페이지 초기화
document.addEventListener('DOMContentLoaded', function() {
console.log('보고서 메인 페이지 로드 시작');
// AuthManager 로드 대기
const checkAuthManager = async () => {
if (window.authManager) {
try {
// 인증 확인
const isAuthenticated = await window.authManager.checkAuth();
if (!isAuthenticated) {
window.location.href = '/login.html';
return;
}
// 공통 헤더 초기화
const user = JSON.parse(localStorage.getItem('currentUser') || '{}');
if (window.commonHeader && user.id) {
await window.commonHeader.init(user, 'reports');
}
console.log('보고서 메인 페이지 로드 완료');
} catch (error) {
console.error('페이지 초기화 오류:', error);
}
} else {
setTimeout(checkAuthManager, 100);
}
};
checkAuthManager();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,297 @@
/* 모바일 캘린더 스타일 */
.mobile-calendar {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* 빠른 선택 버튼들 */
.quick-select-buttons {
scrollbar-width: none;
-ms-overflow-style: none;
}
.quick-select-buttons::-webkit-scrollbar {
display: none;
}
.quick-btn {
flex-shrink: 0;
padding: 8px 16px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 20px;
font-size: 14px;
font-weight: 500;
color: #374151;
transition: all 0.2s ease;
white-space: nowrap;
}
.quick-btn:hover,
.quick-btn:active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
transform: scale(0.95);
}
.quick-btn.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
/* 캘린더 헤더 */
.calendar-header {
padding: 0 8px;
}
.nav-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f9fafb;
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
transition: all 0.2s ease;
}
.nav-btn:hover,
.nav-btn:active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
transform: scale(0.9);
}
.month-year {
color: #1f2937;
font-weight: 600;
min-width: 120px;
text-align: center;
}
/* 요일 헤더 */
.weekdays {
margin-bottom: 8px;
}
.weekday {
text-align: center;
font-size: 12px;
font-weight: 600;
color: #6b7280;
padding: 8px 4px;
text-transform: uppercase;
}
.weekday:first-child {
color: #ef4444; /* 일요일 빨간색 */
}
.weekday:last-child {
color: #3b82f6; /* 토요일 파란색 */
}
/* 캘린더 그리드 */
.calendar-grid {
gap: 2px;
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
background: white;
border: 1px solid transparent;
min-height: 40px;
}
.calendar-day:hover {
background: #eff6ff;
border-color: #bfdbfe;
transform: scale(1.05);
}
.calendar-day:active {
transform: scale(0.95);
}
/* 다른 달 날짜 */
.calendar-day.other-month {
color: #d1d5db;
background: #f9fafb;
}
.calendar-day.other-month:hover {
background: #f3f4f6;
color: #9ca3af;
}
/* 오늘 날짜 */
.calendar-day.today {
background: #fef3c7;
color: #92400e;
font-weight: 700;
border-color: #f59e0b;
}
.calendar-day.today:hover {
background: #fde68a;
}
/* 선택된 날짜 */
.calendar-day.selected {
background: #dbeafe;
color: #1e40af;
border-color: #3b82f6;
}
/* 범위 시작/끝 */
.calendar-day.range-start,
.calendar-day.range-end {
background: #3b82f6;
color: white;
font-weight: 700;
}
.calendar-day.range-start:hover,
.calendar-day.range-end:hover {
background: #2563eb;
}
/* 범위 시작일에 표시 */
.calendar-day.range-start::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
}
/* 범위 끝일에 표시 */
.calendar-day.range-end::after {
content: '';
position: absolute;
bottom: 2px;
right: 2px;
width: 6px;
height: 6px;
background: #ef4444;
border-radius: 50%;
}
/* 선택된 범위 표시 */
.selected-range {
border: 1px solid #bfdbfe;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.clear-btn {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: all 0.2s ease;
}
.clear-btn:hover {
background: rgba(59, 130, 246, 0.1);
}
/* 사용법 안내 */
.usage-hint {
opacity: 0.7;
line-height: 1.4;
}
/* 터치 디바이스 최적화 */
@media (hover: none) and (pointer: coarse) {
.calendar-day {
min-height: 44px; /* 터치 타겟 최소 크기 */
}
.nav-btn {
min-width: 44px;
min-height: 44px;
}
.quick-btn {
min-height: 44px;
padding: 12px 16px;
}
}
/* 작은 화면 최적화 */
@media (max-width: 375px) {
.calendar-day {
font-size: 13px;
min-height: 36px;
}
.quick-btn {
padding: 6px 12px;
font-size: 13px;
}
.month-year {
font-size: 16px;
}
}
/* 다크 모드 지원 */
@media (prefers-color-scheme: dark) {
.mobile-calendar {
color: #f9fafb;
}
.calendar-day {
background: #374151;
color: #f9fafb;
}
.calendar-day:hover {
background: #4b5563;
}
.nav-btn {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.quick-btn {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
}

View File

@@ -0,0 +1,346 @@
// API 기본 설정 (Cloudflare 터널 + 로컬 환경 지원)
const API_BASE_URL = (() => {
const hostname = window.location.hostname;
const protocol = window.location.protocol;
const port = window.location.port;
console.log('🔧 API URL 생성 - hostname:', hostname, 'protocol:', protocol, 'port:', port);
// 로컬 환경 (포트 있음)
if (port === '16080') {
const url = `${protocol}//${hostname}:${port}/api`;
console.log('🏠 로컬 환경 URL:', url);
return url;
}
// Cloudflare 터널 환경 (m.hyungi.net) - 강제 HTTPS
if (hostname === 'm.hyungi.net') {
const url = `https://m-api.hyungi.net/api`;
console.log('☁️ Cloudflare 환경 URL:', url);
return url;
}
// 기타 환경
const url = '/api';
console.log('🌐 기타 환경 URL:', url);
return url;
})();
// 토큰 관리
const TokenManager = {
getToken: () => localStorage.getItem('access_token'),
setToken: (token) => localStorage.setItem('access_token', token),
removeToken: () => localStorage.removeItem('access_token'),
getUser: () => {
const userStr = localStorage.getItem('current_user');
return userStr ? JSON.parse(userStr) : null;
},
setUser: (user) => localStorage.setItem('current_user', JSON.stringify(user)),
removeUser: () => localStorage.removeItem('current_user')
};
// API 요청 헬퍼
async function apiRequest(endpoint, options = {}) {
const token = TokenManager.getToken();
const defaultHeaders = {
'Content-Type': 'application/json',
};
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}
const config = {
...options,
headers: {
...defaultHeaders,
...options.headers
}
};
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
if (response.status === 401) {
// 인증 실패 시 로그인 페이지로
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
return;
}
if (!response.ok) {
const error = await response.json();
console.error('API Error Response:', error);
console.error('Error details:', JSON.stringify(error, null, 2));
// 422 에러의 경우 validation 에러 메시지 추출
if (response.status === 422 && error.detail && Array.isArray(error.detail)) {
const validationErrors = error.detail.map(err =>
`${err.loc ? err.loc.join('.') : 'field'}: ${err.msg}`
).join(', ');
throw new Error(`입력값 검증 오류: ${validationErrors}`);
}
throw new Error(error.detail || 'API 요청 실패');
}
return await response.json();
} catch (error) {
console.error('API 요청 에러:', error);
throw error;
}
}
// Auth API
const AuthAPI = {
login: async (username, password) => {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
});
if (!response.ok) {
const error = await response.json();
console.error('로그인 에러:', error);
throw new Error(error.detail || '로그인 실패');
}
const data = await response.json();
TokenManager.setToken(data.access_token);
TokenManager.setUser(data.user);
return data;
} catch (error) {
console.error('로그인 요청 에러:', error);
throw error;
}
},
logout: () => {
TokenManager.removeToken();
TokenManager.removeUser();
window.location.href = '/index.html';
},
getMe: () => apiRequest('/auth/me'),
getCurrentUser: () => apiRequest('/auth/me'),
createUser: (userData) => apiRequest('/auth/users', {
method: 'POST',
body: JSON.stringify(userData)
}),
getUsers: () => {
console.log('🔍 AuthAPI.getUsers 호출 - 엔드포인트: /auth/users');
return apiRequest('/auth/users');
},
updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData)
}),
deleteUser: (userId) => apiRequest(`/auth/users/${userId}`, {
method: 'DELETE'
}),
changePassword: (currentPassword, newPassword) => apiRequest('/auth/change-password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
}),
resetPassword: (userId, newPassword = '000000') => apiRequest(`/auth/users/${userId}/reset-password`, {
method: 'POST',
body: JSON.stringify({
new_password: newPassword
})
}),
// 부서 목록 가져오기
getDepartments: () => [
{ value: 'production', label: '생산' },
{ value: 'quality', label: '품질' },
{ value: 'purchasing', label: '구매' },
{ value: 'design', label: '설계' },
{ value: 'sales', label: '영업' }
],
// 부서명 변환
getDepartmentLabel: (departmentValue) => {
const departments = AuthAPI.getDepartments();
const dept = departments.find(d => d.value === departmentValue);
return dept ? dept.label : departmentValue || '미지정';
}
};
// Issues API
const IssuesAPI = {
create: async (issueData) => {
// photos 배열 처리 (최대 5장)
const dataToSend = {
category: issueData.category,
description: issueData.description,
project_id: issueData.project_id,
photo: issueData.photos && issueData.photos.length > 0 ? issueData.photos[0] : null,
photo2: issueData.photos && issueData.photos.length > 1 ? issueData.photos[1] : null,
photo3: issueData.photos && issueData.photos.length > 2 ? issueData.photos[2] : null,
photo4: issueData.photos && issueData.photos.length > 3 ? issueData.photos[3] : null,
photo5: issueData.photos && issueData.photos.length > 4 ? issueData.photos[4] : null
};
return apiRequest('/issues/', {
method: 'POST',
body: JSON.stringify(dataToSend)
});
},
getAll: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
},
get: (id) => apiRequest(`/issues/${id}`),
update: (id, issueData) => apiRequest(`/issues/${id}`, {
method: 'PUT',
body: JSON.stringify(issueData)
}),
delete: (id) => apiRequest(`/issues/${id}`, {
method: 'DELETE'
}),
getStats: () => apiRequest('/issues/stats/summary')
};
// Daily Work API
const DailyWorkAPI = {
create: (workData) => apiRequest('/daily-work/', {
method: 'POST',
body: JSON.stringify(workData)
}),
getAll: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
return apiRequest(`/daily-work/${queryString ? '?' + queryString : ''}`);
},
get: (id) => apiRequest(`/daily-work/${id}`),
update: (id, workData) => apiRequest(`/daily-work/${id}`, {
method: 'PUT',
body: JSON.stringify(workData)
}),
delete: (id) => apiRequest(`/daily-work/${id}`, {
method: 'DELETE'
}),
getStats: (params = {}) => {
const queryString = new URLSearchParams(params).toString();
return apiRequest(`/daily-work/stats/summary${queryString ? '?' + queryString : ''}`);
}
};
// Reports API
const ReportsAPI = {
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
method: 'POST',
body: JSON.stringify({
start_date: startDate,
end_date: endDate
})
}),
getIssues: (startDate, endDate) => {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate
}).toString();
return apiRequest(`/reports/issues?${params}`);
},
getDailyWorks: (startDate, endDate) => {
const params = new URLSearchParams({
start_date: startDate,
end_date: endDate
}).toString();
return apiRequest(`/reports/daily-works?${params}`);
}
};
// 권한 체크
function checkAuth() {
const user = TokenManager.getUser();
if (!user) {
window.location.href = '/index.html';
return null;
}
return user;
}
function checkAdminAuth() {
const user = checkAuth();
if (user && user.role !== 'admin') {
alert('관리자 권한이 필요합니다.');
window.location.href = '/index.html';
return null;
}
return user;
}
// 페이지 접근 권한 체크 함수 (새로 추가)
function checkPageAccess(pageName) {
const user = checkAuth();
if (!user) return null;
// admin은 모든 페이지 접근 가능
if (user.role === 'admin') return user;
// 페이지별 권한 체크는 pagePermissionManager에서 처리
if (window.pagePermissionManager && !window.pagePermissionManager.canAccessPage(pageName)) {
alert('이 페이지에 접근할 권한이 없습니다.');
window.location.href = '/index.html';
return null;
}
return user;
}
// 프로젝트 API
const ProjectsAPI = {
getAll: (activeOnly = false) => {
const params = `?active_only=${activeOnly}`;
return apiRequest(`/projects/${params}`);
},
get: (id) => apiRequest(`/projects/${id}`),
create: (projectData) => apiRequest('/projects/', {
method: 'POST',
body: JSON.stringify(projectData)
}),
update: (id, projectData) => apiRequest(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(projectData)
}),
delete: (id) => apiRequest(`/projects/${id}`, {
method: 'DELETE'
})
};

View File

@@ -0,0 +1,441 @@
/**
* 메인 애플리케이션 JavaScript
* 통합된 SPA 애플리케이션의 핵심 로직
*/
class App {
constructor() {
this.currentUser = null;
this.currentPage = 'dashboard';
this.modules = new Map();
this.sidebarCollapsed = false;
this.init();
}
/**
* 애플리케이션 초기화
*/
async init() {
try {
// 인증 확인
await this.checkAuth();
// API 스크립트 로드
await this.loadAPIScript();
// 권한 시스템 초기화
window.pagePermissionManager.setUser(this.currentUser);
// UI 초기화
this.initializeUI();
// 라우터 초기화
this.initializeRouter();
// 대시보드 데이터 로드
await this.loadDashboardData();
} catch (error) {
console.error('앱 초기화 실패:', error);
this.redirectToLogin();
}
}
/**
* 인증 확인
*/
async checkAuth() {
const token = localStorage.getItem('access_token');
if (!token) {
throw new Error('토큰 없음');
}
// 임시로 localStorage에서 사용자 정보 가져오기
const storedUser = localStorage.getItem('currentUser');
if (storedUser) {
this.currentUser = JSON.parse(storedUser);
} else {
throw new Error('사용자 정보 없음');
}
}
/**
* API 스크립트 동적 로드
*/
async loadAPIScript() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `/static/js/api.js?v=${Date.now()}`;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* UI 초기화
*/
initializeUI() {
// 사용자 정보 표시
this.updateUserDisplay();
// 네비게이션 메뉴 생성
this.createNavigationMenu();
// 이벤트 리스너 등록
this.registerEventListeners();
}
/**
* 사용자 정보 표시 업데이트
*/
updateUserDisplay() {
const userInitial = document.getElementById('userInitial');
const userDisplayName = document.getElementById('userDisplayName');
const userRole = document.getElementById('userRole');
const displayName = this.currentUser.full_name || this.currentUser.username;
const initial = displayName.charAt(0).toUpperCase();
userInitial.textContent = initial;
userDisplayName.textContent = displayName;
userRole.textContent = this.getRoleDisplayName(this.currentUser.role);
}
/**
* 역할 표시명 가져오기
*/
getRoleDisplayName(role) {
const roleNames = {
'admin': '관리자',
'user': '사용자'
};
return roleNames[role] || role;
}
/**
* 네비게이션 메뉴 생성
*/
createNavigationMenu() {
const menuConfig = window.pagePermissionManager.getMenuConfig();
const navigationMenu = document.getElementById('navigationMenu');
navigationMenu.innerHTML = '';
menuConfig.forEach(item => {
const menuItem = this.createMenuItem(item);
navigationMenu.appendChild(menuItem);
});
}
/**
* 메뉴 아이템 생성
*/
createMenuItem(item) {
const li = document.createElement('li');
// 단순한 단일 메뉴 아이템만 지원
li.innerHTML = `
<div class="nav-item p-3 rounded-lg cursor-pointer" onclick="app.navigateTo('${item.path}')">
<div class="flex items-center">
<i class="${item.icon} mr-3 text-gray-500"></i>
<span class="text-gray-700">${item.title}</span>
</div>
</div>
`;
return li;
}
/**
* 라우터 초기화
*/
initializeRouter() {
// 해시 변경 감지
window.addEventListener('hashchange', () => {
this.handleRouteChange();
});
// 초기 라우트 처리
this.handleRouteChange();
}
/**
* 라우트 변경 처리
*/
async handleRouteChange() {
const hash = window.location.hash.substring(1) || 'dashboard';
const [module, action] = hash.split('/');
try {
await this.loadModule(module, action);
this.updateActiveNavigation(hash);
this.updatePageTitle(module, action);
} catch (error) {
console.error('라우트 처리 실패:', error);
this.showError('페이지를 로드할 수 없습니다.');
}
}
/**
* 모듈 로드
*/
async loadModule(module, action = 'list') {
if (module === 'dashboard') {
this.showDashboard();
return;
}
// 모듈이 이미 로드되어 있는지 확인
if (!this.modules.has(module)) {
await this.loadModuleScript(module);
}
// 모듈 실행
const moduleInstance = this.modules.get(module);
if (moduleInstance && typeof moduleInstance.render === 'function') {
const content = await moduleInstance.render(action);
this.showDynamicContent(content);
}
}
/**
* 모듈 스크립트 로드
*/
async loadModuleScript(module) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = `/static/js/modules/${module}/${module}.js?v=${Date.now()}`;
script.onload = () => {
// 모듈이 전역 객체에 등록되었는지 확인
const moduleClass = window[module.charAt(0).toUpperCase() + module.slice(1) + 'Module'];
if (moduleClass) {
this.modules.set(module, new moduleClass());
}
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* 대시보드 표시
*/
showDashboard() {
document.getElementById('dashboard').classList.remove('hidden');
document.getElementById('dynamicContent').classList.add('hidden');
this.currentPage = 'dashboard';
}
/**
* 동적 콘텐츠 표시
*/
showDynamicContent(content) {
document.getElementById('dashboard').classList.add('hidden');
const dynamicContent = document.getElementById('dynamicContent');
dynamicContent.innerHTML = content;
dynamicContent.classList.remove('hidden');
}
/**
* 네비게이션 활성화 상태 업데이트
*/
updateActiveNavigation(hash) {
// 모든 네비게이션 아이템에서 active 클래스 제거
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
});
// 현재 페이지에 해당하는 네비게이션 아이템에 active 클래스 추가
// 구현 필요
}
/**
* 페이지 제목 업데이트
*/
updatePageTitle(module, action) {
const titles = {
'dashboard': '대시보드',
'issues': '부적합 사항',
'projects': '프로젝트',
'daily_work': '일일 공수',
'reports': '보고서',
'users': '사용자 관리'
};
const title = titles[module] || module;
document.getElementById('pageTitle').textContent = title;
}
/**
* 대시보드 데이터 로드
*/
async loadDashboardData() {
try {
// 통계 데이터 로드 (임시 데이터)
document.getElementById('totalIssues').textContent = '0';
document.getElementById('activeProjects').textContent = '0';
document.getElementById('monthlyHours').textContent = '0';
document.getElementById('completionRate').textContent = '0%';
// 실제 API 호출로 대체 예정
// const stats = await API.getDashboardStats();
// this.updateDashboardStats(stats);
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
}
}
/**
* 이벤트 리스너 등록
*/
registerEventListeners() {
// 비밀번호 변경은 CommonHeader에서 처리
// 모바일 반응형
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
this.hideMobileOverlay();
}
});
}
/**
* 페이지 이동
*/
navigateTo(path) {
window.location.hash = path.startsWith('#') ? path.substring(1) : path;
// 모바일에서 사이드바 닫기
if (window.innerWidth < 768) {
this.toggleSidebar();
}
}
/**
* 사이드바 토글
*/
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const mainContent = document.getElementById('mainContent');
const mobileOverlay = document.getElementById('mobileOverlay');
if (window.innerWidth < 768) {
// 모바일
if (sidebar.classList.contains('collapsed')) {
sidebar.classList.remove('collapsed');
mobileOverlay.classList.add('active');
} else {
sidebar.classList.add('collapsed');
mobileOverlay.classList.remove('active');
}
} else {
// 데스크톱
if (this.sidebarCollapsed) {
sidebar.classList.remove('collapsed');
mainContent.classList.remove('expanded');
this.sidebarCollapsed = false;
} else {
sidebar.classList.add('collapsed');
mainContent.classList.add('expanded');
this.sidebarCollapsed = true;
}
}
}
/**
* 모바일 오버레이 숨기기
*/
hideMobileOverlay() {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('mobileOverlay').classList.remove('active');
}
// 비밀번호 변경 기능은 CommonHeader.js에서 처리됩니다.
/**
* 로그아웃
*/
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
this.redirectToLogin();
}
/**
* 로그인 페이지로 리다이렉트
*/
redirectToLogin() {
window.location.href = '/index.html';
}
/**
* 로딩 표시
*/
showLoading() {
document.getElementById('loadingOverlay').classList.add('active');
}
/**
* 로딩 숨기기
*/
hideLoading() {
document.getElementById('loadingOverlay').classList.remove('active');
}
/**
* 성공 메시지 표시
*/
showSuccess(message) {
this.showToast(message, 'success');
}
/**
* 에러 메시지 표시
*/
showError(message) {
this.showToast(message, 'error');
}
/**
* 토스트 메시지 표시
*/
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' :
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
}`;
toast.innerHTML = `
<div class="flex items-center">
<i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-2"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
}
// 전역 함수들 (HTML에서 호출)
function toggleSidebar() {
window.app.toggleSidebar();
}
// 비밀번호 변경 기능은 CommonHeader.showPasswordModal()을 사용합니다.
function logout() {
window.app.logout();
}
// 앱 초기화
document.addEventListener('DOMContentLoaded', () => {
window.app = new App();
});

View File

@@ -0,0 +1,712 @@
/**
* 공통 헤더 컴포넌트
* 권한 기반으로 메뉴를 동적으로 생성하고 부드러운 페이지 전환을 제공
*/
class CommonHeader {
constructor() {
this.currentUser = null;
this.currentPage = '';
this.menuItems = this.initMenuItems();
}
/**
* 메뉴 아이템 정의
*/
initMenuItems() {
return [
{
id: 'daily_work',
title: '일일 공수',
icon: 'fas fa-calendar-check',
url: '/daily-work.html',
pageName: 'daily_work',
color: 'text-blue-600',
bgColor: 'bg-blue-50 hover:bg-blue-100'
},
{
id: 'issues_create',
title: '부적합 등록',
icon: 'fas fa-plus-circle',
url: '/index.html',
pageName: 'issues_create',
color: 'text-green-600',
bgColor: 'bg-green-50 hover:bg-green-100'
},
{
id: 'issues_view',
title: '신고내용조회',
icon: 'fas fa-search',
url: '/issue-view.html',
pageName: 'issues_view',
color: 'text-purple-600',
bgColor: 'bg-purple-50 hover:bg-purple-100'
},
{
id: 'issues_manage',
title: '목록 관리',
icon: 'fas fa-tasks',
url: '/index.html#list',
pageName: 'issues_manage',
color: 'text-orange-600',
bgColor: 'bg-orange-50 hover:bg-orange-100',
subMenus: [
{
id: 'issues_inbox',
title: '수신함',
icon: 'fas fa-inbox',
url: '/issues-inbox.html',
pageName: 'issues_inbox',
color: 'text-blue-600'
},
{
id: 'issues_management',
title: '관리함',
icon: 'fas fa-cog',
url: '/issues-management.html',
pageName: 'issues_management',
color: 'text-green-600'
},
{
id: 'issues_archive',
title: '폐기함',
icon: 'fas fa-archive',
url: '/issues-archive.html',
pageName: 'issues_archive',
color: 'text-gray-600'
}
]
},
{
id: 'issues_dashboard',
title: '현황판',
icon: 'fas fa-chart-line',
url: '/issues-dashboard.html',
pageName: 'issues_dashboard',
color: 'text-purple-600',
bgColor: 'bg-purple-50 hover:bg-purple-100'
},
{
id: 'reports',
title: '보고서',
icon: 'fas fa-chart-bar',
url: '/reports.html',
pageName: 'reports',
color: 'text-red-600',
bgColor: 'bg-red-50 hover:bg-red-100',
subMenus: [
{
id: 'reports_daily',
title: '일일보고서',
icon: 'fas fa-file-excel',
url: '/reports-daily.html',
pageName: 'reports_daily',
color: 'text-green-600'
},
{
id: 'reports_weekly',
title: '주간보고서',
icon: 'fas fa-calendar-week',
url: '/reports-weekly.html',
pageName: 'reports_weekly',
color: 'text-blue-600'
},
{
id: 'reports_monthly',
title: '월간보고서',
icon: 'fas fa-calendar-alt',
url: '/reports-monthly.html',
pageName: 'reports_monthly',
color: 'text-purple-600'
}
]
},
{
id: 'projects_manage',
title: '프로젝트 관리',
icon: 'fas fa-folder-open',
url: '/project-management.html',
pageName: 'projects_manage',
color: 'text-indigo-600',
bgColor: 'bg-indigo-50 hover:bg-indigo-100'
},
{
id: 'users_manage',
title: '사용자 관리',
icon: 'fas fa-users-cog',
url: '/admin.html',
pageName: 'users_manage',
color: 'text-gray-600',
bgColor: 'bg-gray-50 hover:bg-gray-100'
}
];
}
/**
* 헤더 초기화
* @param {Object} user - 현재 사용자 정보
* @param {string} currentPage - 현재 페이지 ID
*/
async init(user, currentPage = '') {
this.currentUser = user;
this.currentPage = currentPage;
// 권한 시스템이 로드될 때까지 대기
await this.waitForPermissionSystem();
this.render();
this.bindEvents();
// 키보드 단축키 초기화
this.initializeKeyboardShortcuts();
// 페이지 프리로더 초기화
this.initializePreloader();
}
/**
* 권한 시스템 로드 대기
*/
async waitForPermissionSystem() {
let attempts = 0;
const maxAttempts = 50; // 5초 대기
while (!window.pagePermissionManager && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (window.pagePermissionManager && this.currentUser) {
window.pagePermissionManager.setUser(this.currentUser);
// 권한 로드 대기
await new Promise(resolve => setTimeout(resolve, 300));
}
}
/**
* 헤더 렌더링
*/
render() {
const headerHTML = this.generateHeaderHTML();
// 기존 헤더가 있으면 교체, 없으면 body 상단에 추가
let headerContainer = document.getElementById('common-header');
if (headerContainer) {
headerContainer.innerHTML = headerHTML;
} else {
headerContainer = document.createElement('div');
headerContainer.id = 'common-header';
headerContainer.innerHTML = headerHTML;
document.body.insertBefore(headerContainer, document.body.firstChild);
}
}
/**
* 현재 페이지 업데이트
* @param {string} pageName - 새로운 페이지 이름
*/
updateCurrentPage(pageName) {
this.currentPage = pageName;
this.render();
}
/**
* 헤더 HTML 생성
*/
generateHeaderHTML() {
const accessibleMenus = this.getAccessibleMenus();
const userDisplayName = this.currentUser?.full_name || this.currentUser?.username || '사용자';
const userRole = this.getUserRoleDisplay();
return `
<header class="bg-white shadow-sm border-b sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- 로고 및 제목 -->
<div class="flex items-center">
<div class="flex-shrink-0 flex items-center">
<i class="fas fa-clipboard-check text-2xl text-blue-600 mr-3"></i>
<h1 class="text-xl font-bold text-gray-900">작업보고서</h1>
</div>
</div>
<!-- 네비게이션 메뉴 -->
<nav class="hidden md:flex space-x-2">
${accessibleMenus.map(menu => this.generateMenuItemHTML(menu)).join('')}
</nav>
<!-- 사용자 정보 및 메뉴 -->
<div class="flex items-center space-x-4">
<!-- 사용자 정보 -->
<div class="flex items-center space-x-3">
<div class="text-right">
<div class="text-sm font-medium text-gray-900">${userDisplayName}</div>
<div class="text-xs text-gray-500">${userRole}</div>
</div>
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
<span class="text-white text-sm font-semibold">
${userDisplayName.charAt(0).toUpperCase()}
</span>
</div>
</div>
<!-- 드롭다운 메뉴 -->
<div class="relative">
<button id="user-menu-button" class="p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
<i class="fas fa-chevron-down"></i>
</button>
<div id="user-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 ring-1 ring-black ring-opacity-5">
<a href="#" onclick="CommonHeader.showPasswordModal()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-key mr-2"></i>비밀번호 변경
</a>
<a href="#" onclick="CommonHeader.logout()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sign-out-alt mr-2"></i>로그아웃
</a>
</div>
</div>
<!-- 모바일 메뉴 버튼 -->
<button id="mobile-menu-button" class="md:hidden p-2 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-md">
<i class="fas fa-bars"></i>
</button>
</div>
</div>
<!-- 모바일 메뉴 -->
<div id="mobile-menu" class="md:hidden hidden border-t border-gray-200 py-3">
<div class="space-y-1">
${accessibleMenus.map(menu => this.generateMobileMenuItemHTML(menu)).join('')}
</div>
</div>
</div>
</header>
`;
}
/**
* 접근 가능한 메뉴 필터링
*/
getAccessibleMenus() {
return this.menuItems.filter(menu => {
// admin은 모든 메뉴 접근 가능
if (this.currentUser?.role === 'admin') {
// 하위 메뉴가 있는 경우 하위 메뉴도 필터링
if (menu.subMenus) {
menu.accessibleSubMenus = menu.subMenus;
}
return true;
}
// 권한 시스템이 로드되지 않았으면 기본 메뉴만
if (!window.canAccessPage) {
return ['issues_create', 'issues_view'].includes(menu.id);
}
// 메인 메뉴 권한 체크
const hasMainAccess = window.canAccessPage(menu.pageName);
// 하위 메뉴가 있는 경우 접근 가능한 하위 메뉴 필터링
if (menu.subMenus) {
menu.accessibleSubMenus = menu.subMenus.filter(subMenu =>
window.canAccessPage(subMenu.pageName)
);
// 메인 메뉴 접근 권한이 없어도 하위 메뉴 중 하나라도 접근 가능하면 표시
return hasMainAccess || menu.accessibleSubMenus.length > 0;
}
return hasMainAccess;
});
}
/**
* 데스크톱 메뉴 아이템 HTML 생성
*/
generateMenuItemHTML(menu) {
const isActive = this.currentPage === menu.id;
const activeClass = isActive ? 'bg-blue-100 text-blue-700' : `${menu.bgColor} ${menu.color}`;
// 하위 메뉴가 있는 경우 드롭다운 메뉴 생성
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
return `
<div class="relative group">
<button class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
data-page="${menu.id}">
<i class="${menu.icon} mr-2"></i>
${menu.title}
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<!-- 드롭다운 메뉴 -->
<div class="absolute left-0 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
<div class="py-1">
${menu.accessibleSubMenus.map(subMenu => `
<a href="${subMenu.url}"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
data-page="${subMenu.id}"
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
${subMenu.title}
</a>
`).join('')}
</div>
</div>
</div>
`;
}
// 일반 메뉴 아이템
return `
<a href="${menu.url}"
class="nav-item flex items-center px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${activeClass}"
data-page="${menu.id}"
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
<i class="${menu.icon} mr-2"></i>
${menu.title}
</a>
`;
}
/**
* 모바일 메뉴 아이템 HTML 생성
*/
generateMobileMenuItemHTML(menu) {
const isActive = this.currentPage === menu.id;
const activeClass = isActive ? 'bg-blue-50 text-blue-700 border-blue-500' : 'text-gray-700 hover:bg-gray-50';
// 하위 메뉴가 있는 경우
if (menu.accessibleSubMenus && menu.accessibleSubMenus.length > 0) {
return `
<div class="mobile-submenu-container">
<button class="w-full flex items-center justify-between px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
onclick="this.nextElementSibling.classList.toggle('hidden')"
data-page="${menu.id}">
<div class="flex items-center">
<i class="${menu.icon} mr-3"></i>
${menu.title}
</div>
<i class="fas fa-chevron-down text-xs"></i>
</button>
<!-- 하위 메뉴 -->
<div class="hidden ml-6 mt-1 space-y-1">
${menu.accessibleSubMenus.map(subMenu => `
<a href="${subMenu.url}"
class="flex items-center px-3 py-2 rounded-md text-sm font-medium text-gray-600 hover:bg-gray-100 ${this.currentPage === subMenu.id ? 'bg-blue-50 text-blue-700' : ''}"
data-page="${subMenu.id}"
onclick="CommonHeader.navigateToPage(event, '${subMenu.url}', '${subMenu.id}')">
<i class="${subMenu.icon} mr-3 ${subMenu.color}"></i>
${subMenu.title}
</a>
`).join('')}
</div>
</div>
`;
}
// 일반 메뉴 아이템
return `
<a href="${menu.url}"
class="nav-item block px-3 py-2 rounded-md text-base font-medium border-l-4 border-transparent ${activeClass}"
data-page="${menu.id}"
onclick="CommonHeader.navigateToPage(event, '${menu.url}', '${menu.id}')">
<i class="${menu.icon} mr-3"></i>
${menu.title}
</a>
`;
}
/**
* 사용자 역할 표시명 가져오기
*/
getUserRoleDisplay() {
const roleNames = {
'admin': '관리자',
'user': '사용자'
};
return roleNames[this.currentUser?.role] || '사용자';
}
/**
* 이벤트 바인딩
*/
bindEvents() {
// 사용자 메뉴 토글
const userMenuButton = document.getElementById('user-menu-button');
const userMenu = document.getElementById('user-menu');
if (userMenuButton && userMenu) {
userMenuButton.addEventListener('click', (e) => {
e.stopPropagation();
userMenu.classList.toggle('hidden');
});
// 외부 클릭 시 메뉴 닫기
document.addEventListener('click', () => {
userMenu.classList.add('hidden');
});
}
// 모바일 메뉴 토글
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
if (mobileMenuButton && mobileMenu) {
mobileMenuButton.addEventListener('click', () => {
mobileMenu.classList.toggle('hidden');
});
}
}
/**
* 페이지 네비게이션 (부드러운 전환)
*/
static navigateToPage(event, url, pageId) {
event.preventDefault();
// 현재 페이지와 같으면 무시
if (window.commonHeader?.currentPage === pageId) {
return;
}
// 로딩 표시
CommonHeader.showPageTransition();
// 페이지 이동
setTimeout(() => {
window.location.href = url;
}, 150); // 부드러운 전환을 위한 딜레이
}
/**
* 페이지 전환 로딩 표시
*/
static showPageTransition() {
// 기존 로딩이 있으면 제거
const existingLoader = document.getElementById('page-transition-loader');
if (existingLoader) {
existingLoader.remove();
}
const loader = document.createElement('div');
loader.id = 'page-transition-loader';
loader.className = 'fixed inset-0 bg-white bg-opacity-75 flex items-center justify-center z-50';
loader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<p class="mt-2 text-sm text-gray-600">페이지를 로드하는 중...</p>
</div>
`;
document.body.appendChild(loader);
}
/**
* 비밀번호 변경 모달 표시
*/
static showPasswordModal() {
// 기존 모달이 있으면 제거
const existingModal = document.getElementById('passwordChangeModal');
if (existingModal) {
existingModal.remove();
}
// 비밀번호 변경 모달 생성
const modalHTML = `
<div id="passwordChangeModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]">
<div class="bg-white rounded-xl p-6 w-96 max-w-md mx-4 shadow-2xl">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-key mr-2 text-blue-500"></i>비밀번호 변경
</h3>
<button onclick="CommonHeader.hidePasswordModal()" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-lg"></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="currentPasswordInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required placeholder="현재 비밀번호를 입력하세요">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호</label>
<input type="password" id="newPasswordInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required minlength="6" placeholder="새 비밀번호 (최소 6자)">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">새 비밀번호 확인</label>
<input type="password" id="confirmPasswordInput"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required placeholder="새 비밀번호를 다시 입력하세요">
</div>
<div class="flex gap-3 pt-4">
<button type="button" onclick="CommonHeader.hidePasswordModal()"
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
취소
</button>
<button type="submit"
class="flex-1 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
<i class="fas fa-save mr-1"></i>변경
</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// 폼 제출 이벤트 리스너 추가
document.getElementById('passwordChangeForm').addEventListener('submit', CommonHeader.handlePasswordChange);
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
CommonHeader.hidePasswordModal();
}
});
}
/**
* 비밀번호 변경 모달 숨기기
*/
static hidePasswordModal() {
const modal = document.getElementById('passwordChangeModal');
if (modal) {
modal.remove();
}
}
/**
* 비밀번호 변경 처리
*/
static async handlePasswordChange(e) {
e.preventDefault();
const currentPassword = document.getElementById('currentPasswordInput').value;
const newPassword = document.getElementById('newPasswordInput').value;
const confirmPassword = document.getElementById('confirmPasswordInput').value;
// 새 비밀번호 확인
if (newPassword !== confirmPassword) {
CommonHeader.showToast('새 비밀번호가 일치하지 않습니다.', 'error');
return;
}
if (newPassword.length < 6) {
CommonHeader.showToast('새 비밀번호는 최소 6자 이상이어야 합니다.', 'error');
return;
}
try {
// AuthAPI가 있는지 확인
if (typeof AuthAPI === 'undefined') {
throw new Error('AuthAPI가 로드되지 않았습니다.');
}
// API를 통한 비밀번호 변경
await AuthAPI.changePassword(currentPassword, newPassword);
CommonHeader.showToast('비밀번호가 성공적으로 변경되었습니다.', 'success');
CommonHeader.hidePasswordModal();
} catch (error) {
console.error('비밀번호 변경 실패:', error);
CommonHeader.showToast('현재 비밀번호가 올바르지 않거나 변경에 실패했습니다.', 'error');
}
}
/**
* 토스트 메시지 표시
*/
static showToast(message, type = 'success') {
// 기존 토스트 제거
const existingToast = document.querySelector('.toast-message');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg transform transition-all duration-300 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`;
const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
toast.innerHTML = `<i class="fas ${icon} mr-2"></i>${message}`;
document.body.appendChild(toast);
// 애니메이션 효과
setTimeout(() => toast.classList.add('translate-x-0'), 10);
setTimeout(() => {
toast.classList.add('opacity-0', 'translate-x-full');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
/**
* 로그아웃
*/
static logout() {
if (confirm('로그아웃 하시겠습니까?')) {
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
}
}
/**
* 현재 페이지 업데이트
*/
updateCurrentPage(pageId) {
this.currentPage = pageId;
// 활성 메뉴 업데이트
document.querySelectorAll('.nav-item').forEach(item => {
const itemPageId = item.getAttribute('data-page');
if (itemPageId === pageId) {
item.classList.add('bg-blue-100', 'text-blue-700');
item.classList.remove('bg-blue-50', 'hover:bg-blue-100');
} else {
item.classList.remove('bg-blue-100', 'text-blue-700');
}
});
}
/**
* 키보드 단축키 초기화
*/
initializeKeyboardShortcuts() {
if (window.keyboardShortcuts) {
window.keyboardShortcuts.setUser(this.currentUser);
console.log('⌨️ 키보드 단축키 사용자 설정 완료');
}
}
/**
* 페이지 프리로더 초기화
*/
initializePreloader() {
if (window.pagePreloader) {
// 사용자 설정 후 프리로더 초기화
setTimeout(() => {
window.pagePreloader.init();
console.log('🚀 페이지 프리로더 초기화 완료');
}, 1000); // 권한 시스템 로드 후 실행
}
}
}
// 전역 인스턴스
window.commonHeader = new CommonHeader();
// 전역 함수로 노출
window.CommonHeader = CommonHeader;

View File

@@ -0,0 +1,359 @@
/**
* 모바일 친화적 캘린더 컴포넌트
* 터치 및 스와이프 지원, 날짜 범위 선택 기능
*/
class MobileCalendar {
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.options = {
locale: 'ko-KR',
startDate: null,
endDate: null,
maxRange: 90, // 최대 90일 범위
onDateSelect: null,
onRangeSelect: null,
...options
};
this.currentDate = new Date();
this.selectedStartDate = null;
this.selectedEndDate = null;
this.isSelecting = false;
this.touchStartX = 0;
this.touchStartY = 0;
this.init();
}
init() {
this.render();
this.bindEvents();
}
render() {
const calendarHTML = `
<div class="mobile-calendar">
<!-- 빠른 선택 버튼들 -->
<div class="quick-select-buttons mb-4">
<div class="flex gap-2 overflow-x-auto pb-2">
<button class="quick-btn" data-range="today">오늘</button>
<button class="quick-btn" data-range="week">이번 주</button>
<button class="quick-btn" data-range="month">이번 달</button>
<button class="quick-btn" data-range="last7">최근 7일</button>
<button class="quick-btn" data-range="last30">최근 30일</button>
<button class="quick-btn" data-range="all">전체</button>
</div>
</div>
<!-- 캘린더 헤더 -->
<div class="calendar-header flex items-center justify-between mb-4">
<button class="nav-btn" id="prevMonth">
<i class="fas fa-chevron-left"></i>
</button>
<h3 class="month-year text-lg font-semibold" id="monthYear"></h3>
<button class="nav-btn" id="nextMonth">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<!-- 요일 헤더 -->
<div class="weekdays grid grid-cols-7 gap-1 mb-2">
<div class="weekday">일</div>
<div class="weekday">월</div>
<div class="weekday">화</div>
<div class="weekday">수</div>
<div class="weekday">목</div>
<div class="weekday">금</div>
<div class="weekday">토</div>
</div>
<!-- 날짜 그리드 -->
<div class="calendar-grid grid grid-cols-7 gap-1" id="calendarGrid">
<!-- 날짜들이 여기에 동적으로 생성됩니다 -->
</div>
<!-- 선택된 범위 표시 -->
<div class="selected-range mt-4 p-3 bg-blue-50 rounded-lg" id="selectedRange" style="display: none;">
<div class="flex items-center justify-between">
<span class="text-sm text-blue-700" id="rangeText"></span>
<button class="clear-btn text-blue-600 hover:text-blue-800" id="clearRange">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<!-- 사용법 안내 -->
<div class="usage-hint mt-3 text-xs text-gray-500 text-center">
📅 날짜를 터치하여 시작일을 선택하고, 다시 터치하여 종료일을 선택하세요
</div>
</div>
`;
this.container.innerHTML = calendarHTML;
this.updateCalendar();
}
updateCalendar() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// 월/년 표시 업데이트
document.getElementById('monthYear').textContent =
`${year}${month + 1}`;
// 캘린더 그리드 생성
this.generateCalendarGrid(year, month);
}
generateCalendarGrid(year, month) {
const grid = document.getElementById('calendarGrid');
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
let html = '';
const today = new Date();
// 6주 표시 (42일)
for (let i = 0; i < 42; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
const isCurrentMonth = date.getMonth() === month;
const isToday = this.isSameDate(date, today);
const isSelected = this.isDateInRange(date);
const isStart = this.selectedStartDate && this.isSameDate(date, this.selectedStartDate);
const isEnd = this.selectedEndDate && this.isSameDate(date, this.selectedEndDate);
let classes = ['calendar-day'];
if (!isCurrentMonth) classes.push('other-month');
if (isToday) classes.push('today');
if (isSelected) classes.push('selected');
if (isStart) classes.push('range-start');
if (isEnd) classes.push('range-end');
html += `
<div class="${classes.join(' ')}"
data-date="${date.toISOString().split('T')[0]}"
data-timestamp="${date.getTime()}">
${date.getDate()}
</div>
`;
}
grid.innerHTML = html;
}
bindEvents() {
// 빠른 선택 버튼들
this.container.querySelectorAll('.quick-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const range = e.target.dataset.range;
this.selectQuickRange(range);
});
});
// 월 네비게이션
document.getElementById('prevMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
this.updateCalendar();
});
document.getElementById('nextMonth').addEventListener('click', () => {
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
this.updateCalendar();
});
// 날짜 선택
this.container.addEventListener('click', (e) => {
if (e.target.classList.contains('calendar-day')) {
this.handleDateClick(e.target);
}
});
// 터치 이벤트 (스와이프 지원)
this.container.addEventListener('touchstart', (e) => {
this.touchStartX = e.touches[0].clientX;
this.touchStartY = e.touches[0].clientY;
});
this.container.addEventListener('touchend', (e) => {
if (!this.touchStartX || !this.touchStartY) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = this.touchStartX - touchEndX;
const diffY = this.touchStartY - touchEndY;
// 수평 스와이프가 수직 스와이프보다 클 때만 처리
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (diffX > 0) {
// 왼쪽으로 스와이프 - 다음 달
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
} else {
// 오른쪽으로 스와이프 - 이전 달
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
}
this.updateCalendar();
}
this.touchStartX = 0;
this.touchStartY = 0;
});
// 범위 지우기
document.getElementById('clearRange').addEventListener('click', () => {
this.clearSelection();
});
}
handleDateClick(dayElement) {
const dateStr = dayElement.dataset.date;
const date = new Date(dateStr + 'T00:00:00');
if (!this.selectedStartDate || (this.selectedStartDate && this.selectedEndDate)) {
// 새로운 선택 시작
this.selectedStartDate = date;
this.selectedEndDate = null;
this.isSelecting = true;
} else if (this.selectedStartDate && !this.selectedEndDate) {
// 종료일 선택
if (date < this.selectedStartDate) {
// 시작일보다 이전 날짜를 선택하면 시작일로 설정
this.selectedEndDate = this.selectedStartDate;
this.selectedStartDate = date;
} else {
this.selectedEndDate = date;
}
this.isSelecting = false;
// 범위가 너무 크면 제한
const daysDiff = Math.abs(this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24);
if (daysDiff > this.options.maxRange) {
alert(`최대 ${this.options.maxRange}일까지만 선택할 수 있습니다.`);
this.clearSelection();
return;
}
}
this.updateCalendar();
this.updateSelectedRange();
// 콜백 호출
if (this.selectedStartDate && this.selectedEndDate && this.options.onRangeSelect) {
this.options.onRangeSelect(this.selectedStartDate, this.selectedEndDate);
}
}
selectQuickRange(range) {
const today = new Date();
let startDate, endDate;
switch (range) {
case 'today':
startDate = endDate = new Date(today);
break;
case 'week':
startDate = new Date(today);
startDate.setDate(today.getDate() - today.getDay());
endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 6);
break;
case 'month':
startDate = new Date(today.getFullYear(), today.getMonth(), 1);
endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0);
break;
case 'last7':
endDate = new Date(today);
startDate = new Date(today);
startDate.setDate(today.getDate() - 6);
break;
case 'last30':
endDate = new Date(today);
startDate = new Date(today);
startDate.setDate(today.getDate() - 29);
break;
case 'all':
this.clearSelection();
if (this.options.onRangeSelect) {
this.options.onRangeSelect(null, null);
}
return;
}
this.selectedStartDate = startDate;
this.selectedEndDate = endDate;
this.updateCalendar();
this.updateSelectedRange();
if (this.options.onRangeSelect) {
this.options.onRangeSelect(startDate, endDate);
}
}
updateSelectedRange() {
const rangeElement = document.getElementById('selectedRange');
const rangeText = document.getElementById('rangeText');
if (this.selectedStartDate && this.selectedEndDate) {
const startStr = this.formatDate(this.selectedStartDate);
const endStr = this.formatDate(this.selectedEndDate);
const daysDiff = Math.ceil((this.selectedEndDate - this.selectedStartDate) / (1000 * 60 * 60 * 24)) + 1;
rangeText.textContent = `${startStr} ~ ${endStr} (${daysDiff}일)`;
rangeElement.style.display = 'block';
} else if (this.selectedStartDate) {
rangeText.textContent = `시작일: ${this.formatDate(this.selectedStartDate)} (종료일을 선택하세요)`;
rangeElement.style.display = 'block';
} else {
rangeElement.style.display = 'none';
}
}
clearSelection() {
this.selectedStartDate = null;
this.selectedEndDate = null;
this.isSelecting = false;
this.updateCalendar();
this.updateSelectedRange();
}
isDateInRange(date) {
if (!this.selectedStartDate) return false;
if (!this.selectedEndDate) return this.isSameDate(date, this.selectedStartDate);
return date >= this.selectedStartDate && date <= this.selectedEndDate;
}
isSameDate(date1, date2) {
return date1.toDateString() === date2.toDateString();
}
formatDate(date) {
return date.toLocaleDateString('ko-KR', {
month: 'short',
day: 'numeric'
});
}
// 외부에서 호출할 수 있는 메서드들
getSelectedRange() {
return {
startDate: this.selectedStartDate,
endDate: this.selectedEndDate
};
}
setSelectedRange(startDate, endDate) {
this.selectedStartDate = startDate;
this.selectedEndDate = endDate;
this.updateCalendar();
this.updateSelectedRange();
}
}
// 전역으로 노출
window.MobileCalendar = MobileCalendar;

View File

@@ -0,0 +1,272 @@
/**
* 중앙화된 인증 관리자
* 페이지 간 이동 시 불필요한 API 호출을 방지하고 인증 상태를 효율적으로 관리
*/
class AuthManager {
constructor() {
this.currentUser = null;
this.isAuthenticated = false;
this.lastAuthCheck = null;
this.authCheckInterval = 5 * 60 * 1000; // 5분마다 토큰 유효성 체크
this.listeners = new Set();
// 초기화
this.init();
}
/**
* 초기화
*/
init() {
console.log('🔐 AuthManager 초기화');
// localStorage에서 사용자 정보 복원
this.restoreUserFromStorage();
// 토큰 만료 체크 타이머 설정
this.setupTokenExpiryCheck();
// 페이지 가시성 변경 시 토큰 체크
document.addEventListener('visibilitychange', () => {
if (!document.hidden && this.shouldCheckAuth()) {
this.refreshAuth();
}
});
}
/**
* localStorage에서 사용자 정보 복원
*/
restoreUserFromStorage() {
const token = localStorage.getItem('access_token');
const userStr = localStorage.getItem('currentUser');
console.log('🔍 localStorage 확인:');
console.log('- 토큰 존재:', !!token);
console.log('- 사용자 정보 존재:', !!userStr);
if (token && userStr) {
try {
this.currentUser = JSON.parse(userStr);
this.isAuthenticated = true;
this.lastAuthCheck = Date.now();
console.log('✅ 저장된 사용자 정보 복원:', this.currentUser.username);
} catch (error) {
console.error('❌ 사용자 정보 복원 실패:', error);
this.clearAuth();
}
} else {
console.log('❌ 토큰 또는 사용자 정보 없음 - 로그인 필요');
}
}
/**
* 인증이 필요한지 확인
*/
shouldCheckAuth() {
if (!this.isAuthenticated) return true;
if (!this.lastAuthCheck) return true;
const timeSinceLastCheck = Date.now() - this.lastAuthCheck;
return timeSinceLastCheck > this.authCheckInterval;
}
/**
* 인증 상태 확인 (필요시에만 API 호출)
*/
async checkAuth() {
console.log('🔍 AuthManager.checkAuth() 호출됨');
console.log('- 현재 인증 상태:', this.isAuthenticated);
console.log('- 현재 사용자:', this.currentUser?.username || 'null');
const token = localStorage.getItem('access_token');
if (!token) {
console.log('❌ 토큰 없음 - 인증 실패');
this.clearAuth();
return null;
}
// 최근에 체크했으면 캐시된 정보 사용
if (this.isAuthenticated && !this.shouldCheckAuth()) {
console.log('✅ 캐시된 인증 정보 사용:', this.currentUser.username);
return this.currentUser;
}
// API 호출이 필요한 경우
console.log('🔄 API 호출 필요 - refreshAuth 실행');
return await this.refreshAuth();
}
/**
* 강제로 인증 정보 새로고침 (API 호출)
*/
async refreshAuth() {
console.log('🔄 인증 정보 새로고침 (API 호출)');
try {
// API가 로드될 때까지 대기
await this.waitForAPI();
const user = await AuthAPI.getCurrentUser();
this.currentUser = user;
this.isAuthenticated = true;
this.lastAuthCheck = Date.now();
// localStorage 업데이트
localStorage.setItem('currentUser', JSON.stringify(user));
console.log('✅ 인증 정보 새로고침 완료:', user.username);
// 리스너들에게 알림
this.notifyListeners('auth-success', user);
return user;
} catch (error) {
console.error('❌ 인증 실패:', error);
this.clearAuth();
this.notifyListeners('auth-failed', error);
throw error;
}
}
/**
* API 로드 대기
*/
async waitForAPI() {
let attempts = 0;
const maxAttempts = 50;
while (typeof AuthAPI === 'undefined' && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (typeof AuthAPI === 'undefined') {
throw new Error('AuthAPI를 로드할 수 없습니다');
}
}
/**
* 인증 정보 클리어
*/
clearAuth() {
console.log('🧹 인증 정보 클리어');
this.currentUser = null;
this.isAuthenticated = false;
this.lastAuthCheck = null;
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
this.notifyListeners('auth-cleared');
}
/**
* 로그인 처리
*/
async login(username, password) {
console.log('🔑 로그인 시도:', username);
try {
await this.waitForAPI();
const data = await AuthAPI.login(username, password);
this.currentUser = data.user;
this.isAuthenticated = true;
this.lastAuthCheck = Date.now();
// localStorage 저장
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('currentUser', JSON.stringify(data.user));
console.log('✅ 로그인 성공:', data.user.username);
this.notifyListeners('login-success', data.user);
return data;
} catch (error) {
console.error('❌ 로그인 실패:', error);
this.clearAuth();
throw error;
}
}
/**
* 로그아웃 처리
*/
logout() {
console.log('🚪 로그아웃');
this.clearAuth();
this.notifyListeners('logout');
// 로그인 페이지로 이동
window.location.href = '/index.html';
}
/**
* 토큰 만료 체크 타이머 설정
*/
setupTokenExpiryCheck() {
// 30분마다 토큰 유효성 체크
setInterval(() => {
if (this.isAuthenticated) {
console.log('⏰ 정기 토큰 유효성 체크');
this.refreshAuth().catch(() => {
console.log('🔄 토큰 만료 - 로그아웃 처리');
this.logout();
});
}
}, 30 * 60 * 1000);
}
/**
* 이벤트 리스너 등록
*/
addEventListener(callback) {
this.listeners.add(callback);
}
/**
* 이벤트 리스너 제거
*/
removeEventListener(callback) {
this.listeners.delete(callback);
}
/**
* 리스너들에게 알림
*/
notifyListeners(event, data = null) {
this.listeners.forEach(callback => {
try {
callback(event, data);
} catch (error) {
console.error('리스너 콜백 오류:', error);
}
});
}
/**
* 현재 사용자 정보 반환
*/
getCurrentUser() {
return this.currentUser;
}
/**
* 인증 상태 반환
*/
isLoggedIn() {
return this.isAuthenticated && !!this.currentUser;
}
}
// 전역 인스턴스 생성
window.authManager = new AuthManager();
console.log('🎯 AuthManager 로드 완료');

View File

@@ -0,0 +1,621 @@
/**
* 키보드 단축키 관리자
* 전역 키보드 단축키를 관리하고 사용자 경험을 향상시킵니다.
*/
class KeyboardShortcutManager {
constructor() {
this.shortcuts = new Map();
this.isEnabled = true;
this.helpModalVisible = false;
this.currentUser = null;
// 기본 단축키 등록
this.registerDefaultShortcuts();
// 이벤트 리스너 등록
this.bindEvents();
}
/**
* 기본 단축키 등록
*/
registerDefaultShortcuts() {
// 전역 단축키
this.register('?', () => this.showHelpModal(), '도움말 표시');
this.register('Escape', () => this.handleEscape(), '모달/메뉴 닫기');
// 네비게이션 단축키
this.register('g h', () => this.navigateToPage('/index.html', 'issues_create'), '홈 (부적합 등록)');
this.register('g v', () => this.navigateToPage('/issue-view.html', 'issues_view'), '부적합 조회');
this.register('g d', () => this.navigateToPage('/daily-work.html', 'daily_work'), '일일 공수');
this.register('g p', () => this.navigateToPage('/project-management.html', 'projects_manage'), '프로젝트 관리');
this.register('g r', () => this.navigateToPage('/reports.html', 'reports'), '보고서');
this.register('g a', () => this.navigateToPage('/admin.html', 'users_manage'), '관리자');
// 액션 단축키
this.register('n', () => this.triggerNewAction(), '새 항목 생성');
this.register('s', () => this.triggerSaveAction(), '저장');
this.register('r', () => this.triggerRefreshAction(), '새로고침');
this.register('f', () => this.focusSearchField(), '검색 포커스');
// 관리자 전용 단축키
this.register('ctrl+shift+u', () => this.navigateToPage('/admin.html', 'users_manage'), '사용자 관리 (관리자)');
console.log('⌨️ 키보드 단축키 등록 완료');
}
/**
* 단축키 등록
* @param {string} combination - 키 조합 (예: 'ctrl+s', 'g h')
* @param {function} callback - 실행할 함수
* @param {string} description - 설명
* @param {object} options - 옵션
*/
register(combination, callback, description, options = {}) {
const normalizedCombo = this.normalizeKeyCombination(combination);
this.shortcuts.set(normalizedCombo, {
callback,
description,
requiresAuth: options.requiresAuth !== false,
adminOnly: options.adminOnly || false,
pageSpecific: options.pageSpecific || null
});
}
/**
* 키 조합 정규화
*/
normalizeKeyCombination(combination) {
return combination
.toLowerCase()
.split(' ')
.map(part => part.trim())
.filter(part => part.length > 0)
.join(' ');
}
/**
* 이벤트 바인딩
*/
bindEvents() {
let keySequence = [];
let sequenceTimer = null;
document.addEventListener('keydown', (e) => {
if (!this.isEnabled) return;
// 입력 필드에서는 일부 단축키만 허용
if (this.isInputField(e.target)) {
this.handleInputFieldShortcuts(e);
return;
}
// 키 조합 생성
const keyCombo = this.createKeyCombo(e);
// 시퀀스 타이머 리셋
if (sequenceTimer) {
clearTimeout(sequenceTimer);
}
// 단일 키 단축키 확인
if (this.handleShortcut(keyCombo, e)) {
return;
}
// 시퀀스 키 처리
keySequence.push(keyCombo);
// 시퀀스 단축키 확인
const sequenceCombo = keySequence.join(' ');
if (this.handleShortcut(sequenceCombo, e)) {
keySequence = [];
return;
}
// 시퀀스 타이머 설정 (1초 후 리셋)
sequenceTimer = setTimeout(() => {
keySequence = [];
}, 1000);
});
}
/**
* 키 조합 생성
*/
createKeyCombo(event) {
const parts = [];
if (event.ctrlKey) parts.push('ctrl');
if (event.altKey) parts.push('alt');
if (event.shiftKey) parts.push('shift');
if (event.metaKey) parts.push('meta');
const key = event.key.toLowerCase();
// 특수 키 처리
const specialKeys = {
' ': 'space',
'enter': 'enter',
'escape': 'escape',
'tab': 'tab',
'backspace': 'backspace',
'delete': 'delete',
'arrowup': 'up',
'arrowdown': 'down',
'arrowleft': 'left',
'arrowright': 'right'
};
const normalizedKey = specialKeys[key] || key;
parts.push(normalizedKey);
return parts.join('+');
}
/**
* 단축키 처리
*/
handleShortcut(combination, event) {
const shortcut = this.shortcuts.get(combination);
if (!shortcut) return false;
// 권한 확인
if (shortcut.requiresAuth && !this.currentUser) {
return false;
}
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') {
return false;
}
// 페이지별 단축키 확인
if (shortcut.pageSpecific && !this.isCurrentPage(shortcut.pageSpecific)) {
return false;
}
// 기본 동작 방지
event.preventDefault();
event.stopPropagation();
// 콜백 실행
try {
shortcut.callback(event);
console.log(`⌨️ 단축키 실행: ${combination}`);
} catch (error) {
console.error('단축키 실행 실패:', combination, error);
}
return true;
}
/**
* 입력 필드 확인
*/
isInputField(element) {
const inputTypes = ['input', 'textarea', 'select'];
const contentEditable = element.contentEditable === 'true';
return inputTypes.includes(element.tagName.toLowerCase()) || contentEditable;
}
/**
* 입력 필드에서의 단축키 처리
*/
handleInputFieldShortcuts(event) {
const keyCombo = this.createKeyCombo(event);
// 입력 필드에서 허용되는 단축키
const allowedInInput = ['escape', 'ctrl+s', 'ctrl+enter'];
if (allowedInInput.includes(keyCombo)) {
this.handleShortcut(keyCombo, event);
}
}
/**
* 현재 페이지 확인
*/
isCurrentPage(pageId) {
return window.commonHeader?.currentPage === pageId;
}
/**
* 페이지 네비게이션
*/
navigateToPage(url, pageId) {
// 권한 확인
if (pageId && window.canAccessPage && !window.canAccessPage(pageId)) {
this.showNotification('해당 페이지에 접근할 권한이 없습니다.', 'warning');
return;
}
// 현재 페이지와 같으면 무시
if (window.location.pathname === url) {
return;
}
// 부드러운 전환
if (window.CommonHeader) {
window.CommonHeader.navigateToPage(
{ preventDefault: () => {}, stopPropagation: () => {} },
url,
pageId
);
} else {
window.location.href = url;
}
}
/**
* 새 항목 생성 액션
*/
triggerNewAction() {
const newButtons = [
'button[onclick*="showAddModal"]',
'button[onclick*="addNew"]',
'#addBtn',
'#add-btn',
'.btn-add',
'button:contains("추가")',
'button:contains("등록")',
'button:contains("새")'
];
for (const selector of newButtons) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
button.click();
this.showNotification('새 항목 생성', 'info');
return;
}
}
this.showNotification('새 항목 생성 버튼을 찾을 수 없습니다.', 'warning');
}
/**
* 저장 액션
*/
triggerSaveAction() {
const saveButtons = [
'button[type="submit"]',
'button[onclick*="save"]',
'#saveBtn',
'#save-btn',
'.btn-save',
'button:contains("저장")',
'button:contains("등록")'
];
for (const selector of saveButtons) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
button.click();
this.showNotification('저장 실행', 'success');
return;
}
}
this.showNotification('저장 버튼을 찾을 수 없습니다.', 'warning');
}
/**
* 새로고침 액션
*/
triggerRefreshAction() {
const refreshButtons = [
'button[onclick*="load"]',
'button[onclick*="refresh"]',
'#refreshBtn',
'#refresh-btn',
'.btn-refresh'
];
for (const selector of refreshButtons) {
const button = document.querySelector(selector);
if (button && !button.disabled) {
button.click();
this.showNotification('새로고침 실행', 'info');
return;
}
}
// 기본 새로고침
window.location.reload();
}
/**
* 검색 필드 포커스
*/
focusSearchField() {
const searchFields = [
'input[type="search"]',
'input[placeholder*="검색"]',
'input[placeholder*="찾기"]',
'#searchInput',
'#search',
'.search-input'
];
for (const selector of searchFields) {
const field = document.querySelector(selector);
if (field) {
field.focus();
field.select();
this.showNotification('검색 필드 포커스', 'info');
return;
}
}
this.showNotification('검색 필드를 찾을 수 없습니다.', 'warning');
}
/**
* Escape 키 처리
*/
handleEscape() {
// 모달 닫기
const modals = document.querySelectorAll('.modal, [id*="modal"], [class*="modal"]');
for (const modal of modals) {
if (!modal.classList.contains('hidden') && modal.style.display !== 'none') {
modal.classList.add('hidden');
this.showNotification('모달 닫기', 'info');
return;
}
}
// 드롭다운 메뉴 닫기
const dropdowns = document.querySelectorAll('[id*="menu"], [class*="dropdown"]');
for (const dropdown of dropdowns) {
if (!dropdown.classList.contains('hidden')) {
dropdown.classList.add('hidden');
return;
}
}
// 포커스 해제
if (document.activeElement && document.activeElement !== document.body) {
document.activeElement.blur();
}
}
/**
* 도움말 모달 표시
*/
showHelpModal() {
if (this.helpModalVisible) {
this.hideHelpModal();
return;
}
const modal = this.createHelpModal();
document.body.appendChild(modal);
this.helpModalVisible = true;
// 외부 클릭으로 닫기
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideHelpModal();
}
});
}
/**
* 도움말 모달 생성
*/
createHelpModal() {
const modal = document.createElement('div');
modal.id = 'keyboard-shortcuts-modal';
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
const shortcuts = this.getAvailableShortcuts();
const shortcutGroups = this.groupShortcuts(shortcuts);
modal.innerHTML = `
<div class="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-900">
<i class="fas fa-keyboard mr-3 text-blue-600"></i>
키보드 단축키
</h2>
<button onclick="keyboardShortcuts.hideHelpModal()"
class="text-gray-400 hover:text-gray-600 text-2xl">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
${Object.entries(shortcutGroups).map(([group, items]) => `
<div>
<h3 class="text-lg font-semibold text-gray-800 mb-4 border-b pb-2">
${group}
</h3>
<div class="space-y-3">
${items.map(item => `
<div class="flex items-center justify-between">
<span class="text-gray-600">${item.description}</span>
<div class="flex space-x-1">
${item.keys.map(key => `
<kbd class="px-2 py-1 bg-gray-100 border border-gray-300 rounded text-sm font-mono">
${key}
</kbd>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
<div class="mt-8 p-4 bg-blue-50 rounded-lg">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-600 mt-1 mr-3"></i>
<div>
<h4 class="font-semibold text-blue-900 mb-2">사용 팁</h4>
<ul class="text-blue-800 text-sm space-y-1">
<li>• 입력 필드에서는 일부 단축키만 작동합니다.</li>
<li>• 'g' 키를 누른 후 다른 키를 눌러 페이지를 이동할 수 있습니다.</li>
<li>• ESC 키로 모달이나 메뉴를 닫을 수 있습니다.</li>
<li>• '?' 키로 언제든 이 도움말을 볼 수 있습니다.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
`;
return modal;
}
/**
* 사용 가능한 단축키 가져오기
*/
getAvailableShortcuts() {
const available = [];
for (const [combination, shortcut] of this.shortcuts) {
// 권한 확인
if (shortcut.requiresAuth && !this.currentUser) continue;
if (shortcut.adminOnly && this.currentUser?.role !== 'admin') continue;
available.push({
combination,
description: shortcut.description,
keys: this.formatKeyCombo(combination)
});
}
return available;
}
/**
* 단축키 그룹화
*/
groupShortcuts(shortcuts) {
const groups = {
'네비게이션': [],
'액션': [],
'전역': []
};
shortcuts.forEach(shortcut => {
if (shortcut.combination.startsWith('g ')) {
groups['네비게이션'].push(shortcut);
} else if (['n', 's', 'r', 'f'].includes(shortcut.combination)) {
groups['액션'].push(shortcut);
} else {
groups['전역'].push(shortcut);
}
});
return groups;
}
/**
* 키 조합 포맷팅
*/
formatKeyCombo(combination) {
return combination
.split(' ')
.map(part => {
return part
.split('+')
.map(key => {
const keyNames = {
'ctrl': 'Ctrl',
'alt': 'Alt',
'shift': 'Shift',
'meta': 'Cmd',
'space': 'Space',
'enter': 'Enter',
'escape': 'Esc',
'tab': 'Tab'
};
return keyNames[key] || key.toUpperCase();
})
.join(' + ');
});
}
/**
* 도움말 모달 숨기기
*/
hideHelpModal() {
const modal = document.getElementById('keyboard-shortcuts-modal');
if (modal) {
modal.remove();
this.helpModalVisible = false;
}
}
/**
* 알림 표시
*/
showNotification(message, type = 'info') {
// 기존 알림 제거
const existing = document.getElementById('shortcut-notification');
if (existing) existing.remove();
const notification = document.createElement('div');
notification.id = 'shortcut-notification';
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-lg shadow-lg z-50 transition-all duration-300 ${this.getNotificationClass(type)}`;
notification.textContent = message;
document.body.appendChild(notification);
// 3초 후 자동 제거
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 3000);
}
/**
* 알림 클래스 가져오기
*/
getNotificationClass(type) {
const classes = {
'info': 'bg-blue-600 text-white',
'success': 'bg-green-600 text-white',
'warning': 'bg-yellow-600 text-white',
'error': 'bg-red-600 text-white'
};
return classes[type] || classes.info;
}
/**
* 사용자 설정
*/
setUser(user) {
this.currentUser = user;
}
/**
* 단축키 활성화/비활성화
*/
setEnabled(enabled) {
this.isEnabled = enabled;
console.log(`⌨️ 키보드 단축키 ${enabled ? '활성화' : '비활성화'}`);
}
/**
* 단축키 제거
*/
unregister(combination) {
const normalizedCombo = this.normalizeKeyCombination(combination);
return this.shortcuts.delete(normalizedCombo);
}
}
// 전역 인스턴스
window.keyboardShortcuts = new KeyboardShortcutManager();

View File

@@ -0,0 +1,368 @@
/**
* 페이지 관리자
* 모듈화된 페이지들의 생명주기를 관리하고 부드러운 전환을 제공
*/
class PageManager {
constructor() {
this.currentPage = null;
this.loadedModules = new Map();
this.pageHistory = [];
}
/**
* 페이지 초기화
* @param {string} pageId - 페이지 식별자
* @param {Object} options - 초기화 옵션
*/
async initializePage(pageId, options = {}) {
try {
// 로딩 표시
this.showPageLoader();
// 사용자 인증 확인
const user = await this.checkAuthentication();
if (!user) return;
// 공통 헤더 초기화
await this.initializeCommonHeader(user, pageId);
// 페이지별 권한 체크
if (!this.checkPagePermission(pageId, user)) {
this.redirectToAccessiblePage();
return;
}
// 페이지 모듈 로드 및 초기화
await this.loadPageModule(pageId, options);
// 페이지 히스토리 업데이트
this.updatePageHistory(pageId);
// 로딩 숨기기
this.hidePageLoader();
} catch (error) {
console.error('페이지 초기화 실패:', error);
this.showErrorPage(error);
}
}
/**
* 사용자 인증 확인
*/
async checkAuthentication() {
const token = localStorage.getItem('access_token');
if (!token) {
window.location.href = '/index.html';
return null;
}
try {
// API가 로드될 때까지 대기
await this.waitForAPI();
const user = await AuthAPI.getCurrentUser();
localStorage.setItem('currentUser', JSON.stringify(user));
return user;
} catch (error) {
console.error('인증 실패:', error);
localStorage.removeItem('access_token');
localStorage.removeItem('currentUser');
window.location.href = '/index.html';
return null;
}
}
/**
* API 로드 대기
*/
async waitForAPI() {
let attempts = 0;
const maxAttempts = 50;
while (!window.AuthAPI && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (!window.AuthAPI) {
throw new Error('API를 로드할 수 없습니다.');
}
}
/**
* 공통 헤더 초기화
*/
async initializeCommonHeader(user, pageId) {
// 권한 시스템 초기화
if (window.pagePermissionManager) {
window.pagePermissionManager.setUser(user);
}
// 공통 헤더 초기화
if (window.commonHeader) {
await window.commonHeader.init(user, pageId);
}
}
/**
* 페이지 권한 체크
*/
checkPagePermission(pageId, user) {
// admin은 모든 페이지 접근 가능
if (user.role === 'admin') {
return true;
}
// 권한 시스템이 로드되지 않았으면 기본 페이지만 허용
if (!window.canAccessPage) {
return ['issues_create', 'issues_view'].includes(pageId);
}
return window.canAccessPage(pageId);
}
/**
* 접근 가능한 페이지로 리다이렉트
*/
redirectToAccessiblePage() {
alert('이 페이지에 접근할 권한이 없습니다.');
// 기본적으로 접근 가능한 페이지로 이동
if (window.canAccessPage && window.canAccessPage('issues_view')) {
window.location.href = '/issue-view.html';
} else {
window.location.href = '/index.html';
}
}
/**
* 페이지 모듈 로드
*/
async loadPageModule(pageId, options) {
// 이미 로드된 모듈이 있으면 재사용
if (this.loadedModules.has(pageId)) {
const module = this.loadedModules.get(pageId);
if (module.reinitialize) {
await module.reinitialize(options);
}
return;
}
// 페이지별 모듈 로드
const module = await this.createPageModule(pageId, options);
if (module) {
this.loadedModules.set(pageId, module);
this.currentPage = pageId;
}
}
/**
* 페이지 모듈 생성
*/
async createPageModule(pageId, options) {
switch (pageId) {
case 'issues_create':
return new IssuesCreateModule(options);
case 'issues_view':
return new IssuesViewModule(options);
case 'issues_manage':
return new IssuesManageModule(options);
case 'projects_manage':
return new ProjectsManageModule(options);
case 'daily_work':
return new DailyWorkModule(options);
case 'reports':
return new ReportsModule(options);
case 'users_manage':
return new UsersManageModule(options);
default:
console.warn(`알 수 없는 페이지 ID: ${pageId}`);
return null;
}
}
/**
* 페이지 히스토리 업데이트
*/
updatePageHistory(pageId) {
this.pageHistory.push({
pageId,
timestamp: new Date(),
url: window.location.href
});
// 히스토리 크기 제한 (최대 10개)
if (this.pageHistory.length > 10) {
this.pageHistory.shift();
}
}
/**
* 페이지 로더 표시
*/
showPageLoader() {
const existingLoader = document.getElementById('page-loader');
if (existingLoader) return;
const loader = document.createElement('div');
loader.id = 'page-loader';
loader.className = 'fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50';
loader.innerHTML = `
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p class="text-lg font-medium text-gray-700">페이지를 로드하는 중...</p>
<p class="text-sm text-gray-500 mt-1">잠시만 기다려주세요</p>
</div>
`;
document.body.appendChild(loader);
}
/**
* 페이지 로더 숨기기
*/
hidePageLoader() {
const loader = document.getElementById('page-loader');
if (loader) {
loader.remove();
}
}
/**
* 에러 페이지 표시
*/
showErrorPage(error) {
this.hidePageLoader();
const errorContainer = document.createElement('div');
errorContainer.className = 'fixed inset-0 bg-gray-50 flex items-center justify-center z-50';
errorContainer.innerHTML = `
<div class="text-center max-w-md mx-auto p-8">
<div class="mb-6">
<i class="fas fa-exclamation-triangle text-6xl text-red-500"></i>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">페이지 로드 실패</h2>
<p class="text-gray-600 mb-6">${error.message || '알 수 없는 오류가 발생했습니다.'}</p>
<div class="space-x-4">
<button onclick="window.location.reload()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
다시 시도
</button>
<button onclick="window.location.href='/index.html'"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700">
홈으로
</button>
</div>
</div>
`;
document.body.appendChild(errorContainer);
}
/**
* 페이지 정리
*/
cleanup() {
if (this.currentPage && this.loadedModules.has(this.currentPage)) {
const module = this.loadedModules.get(this.currentPage);
if (module.cleanup) {
module.cleanup();
}
}
}
}
/**
* 기본 페이지 모듈 클래스
* 모든 페이지 모듈이 상속받아야 하는 기본 클래스
*/
class BasePageModule {
constructor(options = {}) {
this.options = options;
this.initialized = false;
this.eventListeners = [];
}
/**
* 모듈 초기화 (하위 클래스에서 구현)
*/
async initialize() {
throw new Error('initialize 메서드를 구현해야 합니다.');
}
/**
* 모듈 재초기화
*/
async reinitialize(options = {}) {
this.cleanup();
this.options = { ...this.options, ...options };
await this.initialize();
}
/**
* 이벤트 리스너 등록 (자동 정리를 위해)
*/
addEventListener(element, event, handler) {
element.addEventListener(event, handler);
this.eventListeners.push({ element, event, handler });
}
/**
* 모듈 정리
*/
cleanup() {
// 등록된 이벤트 리스너 제거
this.eventListeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.eventListeners = [];
this.initialized = false;
}
/**
* 로딩 표시
*/
showLoading(container, message = '로딩 중...') {
if (typeof container === 'string') {
container = document.getElementById(container);
}
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center py-12">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mb-3"></div>
<p class="text-gray-600">${message}</p>
</div>
</div>
`;
}
}
/**
* 에러 표시
*/
showError(container, message = '오류가 발생했습니다.') {
if (typeof container === 'string') {
container = document.getElementById(container);
}
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center py-12">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-3"></i>
<p class="text-gray-600">${message}</p>
</div>
</div>
`;
}
}
}
// 전역 인스턴스
window.pageManager = new PageManager();
window.BasePageModule = BasePageModule;

View File

@@ -0,0 +1,317 @@
/**
* 페이지 프리로더
* 사용자가 방문할 가능성이 높은 페이지들을 미리 로드하여 성능 향상
*/
class PagePreloader {
constructor() {
this.preloadedPages = new Set();
this.preloadQueue = [];
this.isPreloading = false;
this.preloadCache = new Map();
this.resourceCache = new Map();
}
/**
* 프리로더 초기화
*/
init() {
// 유휴 시간에 프리로딩 시작
this.schedulePreloading();
// 링크 호버 시 프리로딩
this.setupHoverPreloading();
// 서비스 워커 등록 (캐싱용)
this.registerServiceWorker();
}
/**
* 우선순위 기반 프리로딩 스케줄링
*/
schedulePreloading() {
// 현재 사용자 권한에 따른 접근 가능한 페이지들
const accessiblePages = this.getAccessiblePages();
// 우선순위 설정
const priorityPages = this.getPriorityPages(accessiblePages);
// 유휴 시간에 프리로딩 시작
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.startPreloading(priorityPages);
}, { timeout: 2000 });
} else {
// requestIdleCallback 미지원 브라우저
setTimeout(() => {
this.startPreloading(priorityPages);
}, 1000);
}
}
/**
* 접근 가능한 페이지 목록 가져오기
*/
getAccessiblePages() {
const allPages = [
{ id: 'issues_create', url: '/index.html', priority: 1 },
{ id: 'issues_view', url: '/issue-view.html', priority: 1 },
{ id: 'issues_manage', url: '/index.html#list', priority: 2 },
{ id: 'projects_manage', url: '/project-management.html', priority: 3 },
{ id: 'daily_work', url: '/daily-work.html', priority: 2 },
{ id: 'reports', url: '/reports.html', priority: 3 },
{ id: 'users_manage', url: '/admin.html', priority: 4 }
];
// 권한 체크
return allPages.filter(page => {
if (!window.canAccessPage) return false;
return window.canAccessPage(page.id);
});
}
/**
* 우선순위 기반 페이지 정렬
*/
getPriorityPages(pages) {
return pages
.sort((a, b) => a.priority - b.priority)
.slice(0, 3); // 최대 3개 페이지만 프리로드
}
/**
* 프리로딩 시작
*/
async startPreloading(pages) {
if (this.isPreloading) return;
this.isPreloading = true;
console.log('🚀 페이지 프리로딩 시작:', pages.map(p => p.id));
for (const page of pages) {
if (this.preloadedPages.has(page.url)) continue;
try {
await this.preloadPage(page);
// 네트워크 상태 확인 (느린 연결에서는 중단)
if (this.isSlowConnection()) {
console.log('⚠️ 느린 연결 감지, 프리로딩 중단');
break;
}
// CPU 부하 방지를 위한 딜레이
await this.delay(500);
} catch (error) {
console.warn('프리로딩 실패:', page.id, error);
}
}
this.isPreloading = false;
console.log('✅ 페이지 프리로딩 완료');
}
/**
* 개별 페이지 프리로드
*/
async preloadPage(page) {
try {
// HTML 프리로드
const htmlResponse = await fetch(page.url, {
method: 'GET',
headers: { 'Accept': 'text/html' }
});
if (htmlResponse.ok) {
const html = await htmlResponse.text();
this.preloadCache.set(page.url, html);
// 페이지 내 리소스 추출 및 프리로드
await this.preloadPageResources(html, page.url);
this.preloadedPages.add(page.url);
console.log(`📄 프리로드 완료: ${page.id}`);
}
} catch (error) {
console.warn(`프리로드 실패: ${page.id}`, error);
}
}
/**
* 페이지 리소스 프리로드 (CSS, JS)
*/
async preloadPageResources(html, baseUrl) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// CSS 파일 프리로드
const cssLinks = doc.querySelectorAll('link[rel="stylesheet"]');
for (const link of cssLinks) {
const href = this.resolveUrl(link.href, baseUrl);
if (!this.resourceCache.has(href)) {
this.preloadResource(href, 'style');
}
}
// JS 파일 프리로드 (중요한 것만)
const scriptTags = doc.querySelectorAll('script[src]');
for (const script of scriptTags) {
const src = this.resolveUrl(script.src, baseUrl);
if (this.isImportantScript(src) && !this.resourceCache.has(src)) {
this.preloadResource(src, 'script');
}
}
}
/**
* 리소스 프리로드
*/
preloadResource(url, type) {
const link = document.createElement('link');
link.rel = 'preload';
link.href = url;
link.as = type;
link.onload = () => {
this.resourceCache.set(url, true);
};
link.onerror = () => {
console.warn('리소스 프리로드 실패:', url);
};
document.head.appendChild(link);
}
/**
* 중요한 스크립트 판별
*/
isImportantScript(src) {
const importantScripts = [
'api.js',
'permissions.js',
'common-header.js',
'page-manager.js'
];
return importantScripts.some(script => src.includes(script));
}
/**
* URL 해결
*/
resolveUrl(url, baseUrl) {
if (url.startsWith('http') || url.startsWith('//')) {
return url;
}
const base = new URL(baseUrl, window.location.origin);
return new URL(url, base).href;
}
/**
* 호버 시 프리로딩 설정
*/
setupHoverPreloading() {
let hoverTimeout;
document.addEventListener('mouseover', (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
// 300ms 후 프리로드 (실제 클릭 의도 확인)
hoverTimeout = setTimeout(() => {
this.preloadOnHover(href);
}, 300);
});
document.addEventListener('mouseout', (e) => {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = null;
}
});
}
/**
* 호버 시 프리로드
*/
async preloadOnHover(url) {
if (this.preloadedPages.has(url)) return;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'text/html' }
});
if (response.ok) {
const html = await response.text();
this.preloadCache.set(url, html);
this.preloadedPages.add(url);
console.log('🖱️ 호버 프리로드 완료:', url);
}
} catch (error) {
console.warn('호버 프리로드 실패:', url, error);
}
}
/**
* 느린 연결 감지
*/
isSlowConnection() {
if ('connection' in navigator) {
const connection = navigator.connection;
return connection.effectiveType === 'slow-2g' ||
connection.effectiveType === '2g' ||
connection.saveData === true;
}
return false;
}
/**
* 딜레이 유틸리티
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 서비스 워커 등록
*/
async registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('🔧 서비스 워커 등록 완료:', registration);
} catch (error) {
console.log('서비스 워커 등록 실패:', error);
}
}
}
/**
* 프리로드된 페이지 가져오기
*/
getPreloadedPage(url) {
return this.preloadCache.get(url);
}
/**
* 캐시 정리
*/
clearCache() {
this.preloadCache.clear();
this.resourceCache.clear();
this.preloadedPages.clear();
console.log('🗑️ 프리로드 캐시 정리 완료');
}
}
// 전역 인스턴스
window.pagePreloader = new PagePreloader();

View File

@@ -0,0 +1,267 @@
/**
* 단순화된 페이지 권한 관리 시스템
* admin/user 구조에서 페이지별 접근 권한을 관리
*/
class PagePermissionManager {
constructor() {
this.currentUser = null;
this.pagePermissions = new Map();
this.defaultPages = this.initDefaultPages();
}
/**
* 기본 페이지 목록 초기화
*/
initDefaultPages() {
return {
'issues_create': { title: '부적합 등록', defaultAccess: true },
'issues_view': { title: '부적합 조회', defaultAccess: true },
'issues_manage': { title: '부적합 관리', defaultAccess: true },
'issues_inbox': { title: '수신함', defaultAccess: true },
'issues_management': { title: '관리함', defaultAccess: false },
'issues_archive': { title: '폐기함', defaultAccess: false },
'issues_dashboard': { title: '현황판', defaultAccess: true },
'projects_manage': { title: '프로젝트 관리', defaultAccess: false },
'daily_work': { title: '일일 공수', defaultAccess: false },
'reports': { title: '보고서', defaultAccess: false },
'users_manage': { title: '사용자 관리', defaultAccess: false }
};
}
/**
* 사용자 설정
* @param {Object} user - 사용자 객체
*/
setUser(user) {
this.currentUser = user;
this.loadPagePermissions();
}
/**
* 사용자별 페이지 권한 로드
*/
async loadPagePermissions() {
if (!this.currentUser) return;
try {
// API에서 사용자별 페이지 권한 가져오기
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/users/${this.currentUser.id}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
const pagePermissions = await response.json();
this.pagePermissions.clear(); // 기존 권한 초기화
pagePermissions.forEach(perm => {
this.pagePermissions.set(perm.page_name, perm.can_access);
});
console.log('페이지 권한 로드 완료:', this.pagePermissions);
} else {
console.warn('페이지 권한 로드 실패, 기본 권한 사용');
}
} catch (error) {
console.warn('페이지 권한 로드 실패, 기본 권한 사용:', error);
}
}
/**
* 페이지 접근 권한 체크
* @param {string} pageName - 체크할 페이지명
* @returns {boolean} 접근 권한 여부
*/
canAccessPage(pageName) {
if (!this.currentUser) return false;
// admin은 모든 페이지 접근 가능
if (this.currentUser.role === 'admin') {
return true;
}
// 개별 페이지 권한이 설정되어 있으면 우선 적용
if (this.pagePermissions.has(pageName)) {
return this.pagePermissions.get(pageName);
}
// 기본 권한 확인
const pageConfig = this.defaultPages[pageName];
return pageConfig ? pageConfig.defaultAccess : false;
}
/**
* UI 요소 페이지 권한 제어
* @param {string} selector - CSS 선택자
* @param {string} pageName - 필요한 페이지 권한
* @param {string} action - 'show'|'hide'|'disable'|'enable'
*/
controlElement(selector, pageName, action = 'show') {
const elements = document.querySelectorAll(selector);
const hasAccess = this.canAccessPage(pageName);
elements.forEach(element => {
switch (action) {
case 'show':
element.style.display = hasAccess ? '' : 'none';
break;
case 'hide':
element.style.display = hasAccess ? 'none' : '';
break;
case 'disable':
element.disabled = !hasAccess;
if (!hasAccess) {
element.classList.add('opacity-50', 'cursor-not-allowed');
}
break;
case 'enable':
element.disabled = hasAccess;
if (hasAccess) {
element.classList.remove('opacity-50', 'cursor-not-allowed');
}
break;
}
});
}
/**
* 메뉴 구성 생성
* @returns {Array} 페이지 권한에 따른 메뉴 구성
*/
getMenuConfig() {
const menuItems = [
{
id: 'issues_create',
title: '부적합 등록',
icon: 'fas fa-plus-circle',
path: '#issues/create',
pageName: 'issues_create'
},
{
id: 'issues_view',
title: '부적합 조회',
icon: 'fas fa-search',
path: '#issues/view',
pageName: 'issues_view'
},
{
id: 'issues_manage',
title: '부적합 관리',
icon: 'fas fa-tasks',
path: '#issues/manage',
pageName: 'issues_manage'
},
{
id: 'projects_manage',
title: '프로젝트 관리',
icon: 'fas fa-folder-open',
path: '#projects/manage',
pageName: 'projects_manage'
},
{
id: 'daily_work',
title: '일일 공수',
icon: 'fas fa-calendar-check',
path: '#daily-work',
pageName: 'daily_work'
},
{
id: 'reports',
title: '보고서',
icon: 'fas fa-chart-bar',
path: '#reports',
pageName: 'reports'
},
{
id: 'users_manage',
title: '사용자 관리',
icon: 'fas fa-users-cog',
path: '#users/manage',
pageName: 'users_manage'
}
];
// 페이지 권한에 따라 메뉴 필터링
return menuItems.filter(item => this.canAccessPage(item.pageName));
}
/**
* 페이지 권한 부여
* @param {number} userId - 사용자 ID
* @param {string} pageName - 페이지명
* @param {boolean} canAccess - 접근 허용 여부
* @param {string} notes - 메모
*/
async grantPageAccess(userId, pageName, canAccess, notes = '') {
if (this.currentUser.role !== 'admin') {
throw new Error('관리자만 권한을 설정할 수 있습니다.');
}
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/page-permissions/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
user_id: userId,
page_name: pageName,
can_access: canAccess,
notes: notes
})
});
if (!response.ok) {
throw new Error('페이지 권한 설정 실패');
}
return await response.json();
} catch (error) {
console.error('페이지 권한 설정 오류:', error);
throw error;
}
}
/**
* 사용자 페이지 권한 목록 조회
* @param {number} userId - 사용자 ID
* @returns {Array} 페이지 권한 목록
*/
async getUserPagePermissions(userId) {
try {
const apiUrl = window.API_BASE_URL || 'http://localhost:16080/api';
const response = await fetch(`${apiUrl}/users/${userId}/page-permissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (!response.ok) {
throw new Error('페이지 권한 목록 조회 실패');
}
return await response.json();
} catch (error) {
console.error('페이지 권한 목록 조회 오류:', error);
throw error;
}
}
/**
* 모든 페이지 목록과 설명 가져오기
* @returns {Object} 페이지 목록
*/
getAllPages() {
return this.defaultPages;
}
}
// 전역 페이지 권한 관리자 인스턴스
window.pagePermissionManager = new PagePermissionManager();
// 편의 함수들
window.canAccessPage = (pageName) => window.pagePermissionManager.canAccessPage(pageName);
window.controlElement = (selector, pageName, action) => window.pagePermissionManager.controlElement(selector, pageName, action);

View File

@@ -0,0 +1,139 @@
/**
* 날짜 관련 유틸리티 함수들
* 한국 표준시(KST) 기준으로 처리
*/
const DateUtils = {
/**
* UTC 시간을 KST로 변환
* @param {string|Date} dateInput - UTC 날짜 문자열 또는 Date 객체
* @returns {Date} KST 시간대의 Date 객체
*/
toKST(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
// UTC 시간에 9시간 추가 (KST = UTC+9)
return new Date(date.getTime() + (date.getTimezoneOffset() * 60000) + (9 * 3600000));
},
/**
* 현재 KST 시간 가져오기
* @returns {Date} 현재 KST 시간
*/
nowKST() {
const now = new Date();
return this.toKST(now);
},
/**
* KST 날짜를 한국식 문자열로 포맷
* @param {string|Date} dateInput - 날짜
* @param {boolean} includeTime - 시간 포함 여부
* @returns {string} 포맷된 날짜 문자열
*/
formatKST(dateInput, includeTime = false) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'Asia/Seoul'
};
if (includeTime) {
options.hour = '2-digit';
options.minute = '2-digit';
options.hour12 = false;
}
return date.toLocaleString('ko-KR', options);
},
/**
* 상대적 시간 표시 (예: 3분 전, 2시간 전)
* @param {string|Date} dateInput - 날짜
* @returns {string} 상대적 시간 문자열
*/
getRelativeTime(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return '방금 전';
if (diffMin < 60) return `${diffMin}분 전`;
if (diffHour < 24) return `${diffHour}시간 전`;
if (diffDay < 7) return `${diffDay}일 전`;
return this.formatKST(date);
},
/**
* 오늘 날짜인지 확인 (KST 기준)
* @param {string|Date} dateInput - 날짜
* @returns {boolean}
*/
isToday(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const today = new Date();
return date.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' }) ===
today.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul' });
},
/**
* 이번 주인지 확인 (KST 기준, 월요일 시작)
* @param {string|Date} dateInput - 날짜
* @returns {boolean}
*/
isThisWeek(dateInput) {
const date = typeof dateInput === 'string' ? new Date(dateInput) : dateInput;
const now = new Date();
// 주의 시작일 (월요일) 계산
const startOfWeek = new Date(now);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
// 주의 끝일 (일요일) 계산
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
return date >= startOfWeek && date <= endOfWeek;
},
/**
* ISO 문자열을 로컬 date input 값으로 변환
* @param {string} isoString - ISO 날짜 문자열
* @returns {string} YYYY-MM-DD 형식
*/
toDateInputValue(isoString) {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
/**
* 날짜 차이 계산 (일 단위)
* @param {string|Date} date1 - 첫 번째 날짜
* @param {string|Date} date2 - 두 번째 날짜
* @returns {number} 일 수 차이
*/
getDaysDiff(date1, date2) {
const d1 = typeof date1 === 'string' ? new Date(date1) : date1;
const d2 = typeof date2 === 'string' ? new Date(date2) : date2;
const diffMs = Math.abs(d2 - d1);
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
};
// 전역으로 사용 가능하도록 export
window.DateUtils = DateUtils;

View File

@@ -0,0 +1,134 @@
/**
* 이미지 압축 및 최적화 유틸리티
*/
const ImageUtils = {
/**
* 이미지를 압축하고 리사이즈
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
* @param {Object} options - 압축 옵션
* @returns {Promise<String>} - 압축된 base64 이미지
*/
async compressImage(source, options = {}) {
const {
maxWidth = 1024, // 최대 너비
maxHeight = 1024, // 최대 높이
quality = 0.7, // JPEG 품질 (0-1)
format = 'jpeg' // 출력 형식
} = options;
return new Promise((resolve, reject) => {
let img = new Image();
// 이미지 로드 완료 시
img.onload = () => {
// Canvas 생성
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 리사이즈 계산
let { width, height } = this.calculateDimensions(
img.width,
img.height,
maxWidth,
maxHeight
);
// Canvas 크기 설정
canvas.width = width;
canvas.height = height;
// 이미지 그리기
ctx.drawImage(img, 0, 0, width, height);
// 압축된 이미지를 base64로 변환
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('이미지 압축 실패'));
return;
}
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
}, `image/${format}`, quality);
};
img.onerror = () => reject(new Error('이미지 로드 실패'));
// 소스 타입에 따라 처리
if (typeof source === 'string') {
// Base64 문자열인 경우
img.src = source;
} else if (source instanceof File || source instanceof Blob) {
// File 또는 Blob인 경우
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result;
};
reader.onerror = reject;
reader.readAsDataURL(source);
} else {
reject(new Error('지원하지 않는 이미지 형식'));
}
});
},
/**
* 이미지 크기 계산 (비율 유지)
*/
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
// 원본 크기가 제한 내에 있으면 그대로 반환
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
return { width: originalWidth, height: originalHeight };
}
// 비율 계산
const widthRatio = maxWidth / originalWidth;
const heightRatio = maxHeight / originalHeight;
const ratio = Math.min(widthRatio, heightRatio);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
},
/**
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
/**
* Base64 문자열의 크기 계산
*/
getBase64Size(base64String) {
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
return (base64Length * 0.75) - padding;
},
/**
* 이미지 미리보기 생성 (썸네일)
*/
async createThumbnail(source, size = 150) {
return this.compressImage(source, {
maxWidth: size,
maxHeight: size,
quality: 0.8
});
}
};
// 전역으로 사용 가능하도록 export
window.ImageUtils = ImageUtils;

View File

@@ -0,0 +1,335 @@
/**
* 서비스 워커 - 페이지 및 리소스 캐싱
* M-Project 작업보고서 시스템
*/
const CACHE_NAME = 'mproject-v1.0.1';
const STATIC_CACHE = 'mproject-static-v1.0.1';
const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.1';
// 캐시할 정적 리소스
const STATIC_ASSETS = [
'/',
'/index.html',
'/issue-view.html',
'/daily-work.html',
'/project-management.html',
'/admin.html',
'/static/js/api.js',
'/static/js/core/permissions.js',
'/static/js/components/common-header.js',
'/static/js/core/page-manager.js',
'/static/js/core/page-preloader.js',
'/static/js/date-utils.js',
'/static/js/image-utils.js',
'https://cdn.tailwindcss.com',
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
];
// 캐시 전략별 URL 패턴
const CACHE_STRATEGIES = {
// 네트워크 우선 (API 호출)
networkFirst: [
/\/api\//,
/\/auth\//
],
// 캐시 우선 (정적 리소스)
cacheFirst: [
/\.css$/,
/\.js$/,
/\.png$/,
/\.jpg$/,
/\.jpeg$/,
/\.gif$/,
/\.svg$/,
/\.woff$/,
/\.woff2$/,
/cdn\.tailwindcss\.com/,
/cdnjs\.cloudflare\.com/
],
// 스테일 허용 (HTML 페이지)
staleWhileRevalidate: [
/\.html$/,
/\/$/
]
};
/**
* 서비스 워커 설치
*/
self.addEventListener('install', (event) => {
console.log('🔧 서비스 워커 설치 중...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('📦 정적 리소스 캐싱 중...');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
console.log('✅ 서비스 워커 설치 완료');
return self.skipWaiting();
})
.catch((error) => {
console.error('❌ 서비스 워커 설치 실패:', error);
})
);
});
/**
* 서비스 워커 활성화
*/
self.addEventListener('activate', (event) => {
console.log('🚀 서비스 워커 활성화 중...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// 이전 버전 캐시 삭제
if (cacheName !== STATIC_CACHE &&
cacheName !== DYNAMIC_CACHE &&
cacheName !== CACHE_NAME) {
console.log('🗑️ 이전 캐시 삭제:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
console.log('✅ 서비스 워커 활성화 완료');
return self.clients.claim();
})
);
});
/**
* 네트워크 요청 가로채기
*/
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// CORS 요청이나 외부 도메인은 기본 처리
if (url.origin !== location.origin && !isCDNResource(url)) {
return;
}
// 캐시 전략 결정
const strategy = getCacheStrategy(request.url);
event.respondWith(
handleRequest(request, strategy)
);
});
/**
* 요청 처리 (캐시 전략별)
*/
async function handleRequest(request, strategy) {
try {
switch (strategy) {
case 'networkFirst':
return await networkFirst(request);
case 'cacheFirst':
return await cacheFirst(request);
case 'staleWhileRevalidate':
return await staleWhileRevalidate(request);
default:
return await fetch(request);
}
} catch (error) {
console.error('요청 처리 실패:', request.url, error);
return await handleOffline(request);
}
}
/**
* 네트워크 우선 전략
*/
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
// 성공적인 응답만 캐시
if (networkResponse.ok) {
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// 네트워크 실패 시 캐시에서 반환
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
/**
* 캐시 우선 전략
*/
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// 캐시에 없으면 네트워크에서 가져와서 캐시
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
}
/**
* 스테일 허용 전략
*/
async function staleWhileRevalidate(request) {
const cachedResponse = await caches.match(request);
// 백그라운드에서 업데이트
const networkResponsePromise = fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
const cache = caches.open(DYNAMIC_CACHE);
cache.then(c => c.put(request, networkResponse.clone()));
}
return networkResponse;
})
.catch(() => null);
// 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기
return cachedResponse || await networkResponsePromise;
}
/**
* 캐시 전략 결정
*/
function getCacheStrategy(url) {
for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) {
if (patterns.some(pattern => pattern.test(url))) {
return strategy;
}
}
return 'networkFirst'; // 기본값
}
/**
* CDN 리소스 확인
*/
function isCDNResource(url) {
const cdnDomains = [
'cdn.tailwindcss.com',
'cdnjs.cloudflare.com',
'fonts.googleapis.com',
'fonts.gstatic.com'
];
return cdnDomains.some(domain => url.hostname.includes(domain));
}
/**
* 오프라인 처리
*/
async function handleOffline(request) {
// HTML 요청에 대한 오프라인 페이지
if (request.destination === 'document') {
const offlinePage = await caches.match('/index.html');
if (offlinePage) {
return offlinePage;
}
}
// 이미지 요청에 대한 기본 이미지
if (request.destination === 'image') {
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200"><rect width="200" height="200" fill="#f3f4f6"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#9ca3af">오프라인</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
// 기본 오프라인 응답
return new Response('오프라인 상태입니다.', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
/**
* 메시지 처리 (캐시 관리)
*/
self.addEventListener('message', (event) => {
const { type, payload } = event.data;
switch (type) {
case 'CLEAR_CACHE':
clearAllCaches().then(() => {
event.ports[0].postMessage({ success: true });
});
break;
case 'CACHE_PAGE':
cachePage(payload.url).then(() => {
event.ports[0].postMessage({ success: true });
});
break;
case 'GET_CACHE_STATUS':
getCacheStatus().then((status) => {
event.ports[0].postMessage({ status });
});
break;
}
});
/**
* 모든 캐시 정리
*/
async function clearAllCaches() {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
console.log('🗑️ 모든 캐시 정리 완료');
}
/**
* 특정 페이지 캐시
*/
async function cachePage(url) {
try {
const cache = await caches.open(DYNAMIC_CACHE);
await cache.add(url);
console.log('📦 페이지 캐시 완료:', url);
} catch (error) {
console.error('페이지 캐시 실패:', url, error);
}
}
/**
* 캐시 상태 조회
*/
async function getCacheStatus() {
const cacheNames = await caches.keys();
const status = {};
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
status[cacheName] = keys.length;
}
return status;
}

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 DB → localStorage 동기화</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.status {
padding: 15px;
margin: 10px 0;
border-radius: 5px;
}
.success { background: #d4edda; color: #155724; }
.error { background: #f8d7da; color: #721c24; }
.info { background: #d1ecf1; color: #0c5460; }
pre { background: #f4f4f4; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>프로젝트 DB → localStorage 동기화</h1>
<div id="status"></div>
<pre id="result"></pre>
<button onclick="syncProjects()">동기화 시작</button>
<button onclick="location.href='index.html'">메인으로</button>
<script>
// DB의 프로젝트 데이터 (backend에서 확인한 데이터)
const dbProjects = [
{
id: 1,
jobNo: 'TKR-25009R',
projectName: 'M Project',
isActive: true,
createdAt: '2025-10-24T09:49:42.456272+09:00',
createdByName: '관리자'
},
{
id: 2,
jobNo: 'TKG-24011P',
projectName: 'TKG Project',
isActive: true,
createdAt: '2025-10-24T10:59:49.71909+09:00',
createdByName: '관리자'
}
];
function showStatus(message, type = 'info') {
const statusDiv = document.getElementById('status');
statusDiv.className = 'status ' + type;
statusDiv.textContent = message;
}
function syncProjects() {
try {
// 기존 localStorage 데이터 확인
const existing = localStorage.getItem('work-report-projects');
if (existing) {
showStatus('기존 localStorage 데이터가 있습니다. 덮어쓰시겠습니까?', 'info');
if (!confirm('기존 프로젝트 데이터를 DB 데이터로 덮어쓰시겠습니까?')) {
showStatus('동기화 취소됨', 'error');
return;
}
}
// localStorage에 저장
localStorage.setItem('work-report-projects', JSON.stringify(dbProjects));
// 결과 표시
document.getElementById('result').textContent = JSON.stringify(dbProjects, null, 2);
showStatus('✅ DB 프로젝트 2개를 localStorage로 동기화 완료!', 'success');
// 2초 후 메인으로 이동
setTimeout(() => {
alert('동기화가 완료되었습니다. 메인 페이지로 이동합니다.');
location.href = 'index.html';
}, 2000);
} catch (error) {
showStatus('❌ 동기화 실패: ' + error.message, 'error');
}
}
// 페이지 로드 시 현재 상태 표시
window.onload = () => {
const current = localStorage.getItem('work-report-projects');
if (current) {
showStatus('현재 localStorage에 프로젝트 데이터가 있습니다.', 'info');
document.getElementById('result').textContent = 'Current: ' + current;
} else {
showStatus('localStorage에 프로젝트 데이터가 없습니다.', 'info');
}
};
</script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>API 테스트</title>
</head>
<body>
<h1>API 테스트 페이지</h1>
<button onclick="testLogin()">로그인 테스트</button>
<button onclick="testUsers()">사용자 목록 테스트</button>
<div id="result"></div>
<script>
let token = null;
async function testLogin() {
try {
const formData = new URLSearchParams();
formData.append('username', 'hyungi');
formData.append('password', '123456');
const response = await fetch('http://localhost:16080/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData.toString()
});
const data = await response.json();
token = data.access_token;
document.getElementById('result').innerHTML = `<pre>로그인 성공: ${JSON.stringify(data, null, 2)}</pre>`;
} catch (error) {
document.getElementById('result').innerHTML = `<pre>로그인 실패: ${error.message}</pre>`;
}
}
async function testUsers() {
if (!token) {
alert('먼저 로그인하세요');
return;
}
try {
const response = await fetch('http://localhost:16080/api/auth/users', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
document.getElementById('result').innerHTML = `<pre>사용자 목록: ${JSON.stringify(data, null, 2)}</pre>`;
} catch (error) {
document.getElementById('result').innerHTML = `<pre>사용자 목록 실패: ${error.message}</pre>`;
}
}
</script>
</body>
</html>