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,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;