- 권한 기반 공통 헤더 컴포넌트 구현 - 모바일 친화적 캘린더 날짜 필터 추가 - 페이지 프리로더 및 키보드 단축키 시스템 구현 - Service Worker 기반 캐싱 시스템 추가 Frontend Changes: - components/common-header.js: 권한 기반 동적 메뉴 생성 - components/mobile-calendar.js: 터치/스와이프 지원 캘린더 - core/permissions.js: 페이지 접근 권한 관리 - core/page-manager.js: 페이지 라이프사이클 관리 - core/page-preloader.js: 페이지 프리로딩 최적화 - core/keyboard-shortcuts.js: 키보드 네비게이션 - css/mobile-calendar.css: 모바일 최적화 캘린더 스타일 - sw.js: 3단계 캐싱 전략 서비스 워커 Removed: - auth-common.js, common-header.js (구버전 파일들)
360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
/**
|
|
* 모바일 친화적 캘린더 컴포넌트
|
|
* 터치 및 스와이프 지원, 날짜 범위 선택 기능
|
|
*/
|
|
|
|
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;
|