VOC_Monitor/app/view/components/date_range_selector.py

545 lines
18 KiB
Python

import customtkinter as ctk
from datetime import datetime, timedelta, date
from view.theme import theme_manager
import calendar
from typing import Callable, Optional
try:
import holidays # type: ignore
except ImportError:
holidays = None
class ModernCalendarPopup(ctk.CTkToplevel):
"""
모던하고 심플한 커스텀 캘린더 팝업
- 한국 공휴일 표시
- 토요일(파란색), 일요일(빨간색) 구분
- 오늘 날짜 강조
- 범위 선택 시 색상 변경
- 년/월 선택 드롭다운
"""
def __init__(self, parent, start_date: date, end_date: date, callback: Optional[Callable]):
super().__init__(parent)
self.callback = callback
self.start_date: date = start_date
self.end_date: date = end_date
self.temp_start: Optional[date] = None
self.selecting_start = True
# 현재 표시 중인 년/월
self.current_year = datetime.now().year
self.current_month = datetime.now().month
# 한국 공휴일
self.kr_holidays = holidays.KR(years=range(2020, 2030)) if holidays else set()
# 팝업 설정
self.title("기간 선택")
self.geometry("420x600") # 높이를 늘려서 6주 캘린더 수용
self.resizable(False, False)
self.attributes('-topmost', True)
self.configure(fg_color=self._get_palette()["popup_bg"])
# ESC 키로 닫기
self.bind("<Escape>", lambda e: self.destroy())
# 팝업 위치를 부모 버튼 아래로
self._position_popup(parent)
# UI 구성
self._setup_ui()
self._update_calendar()
def _get_palette(self) -> dict:
"""현재 테마에 맞는 캘린더 색상 팔레트 반환"""
is_dark = ctk.get_appearance_mode() == "Dark"
if is_dark:
return {
"popup_bg": "#1f1f1f",
"main_bg": "#1f1f1f",
"day_bg": "#2b2b2b",
"day_hover": "#3a3a3a",
"normal_text": "#f1f1f1",
"week_text": "#b8b8b8",
"range_text": "#7db4ff",
"today_bg": "#3d3314",
"today_hover": "#52431a",
"today_border": "#f4b400",
"today_border_hover": "#f39c12",
"range_edge_bg": "#1f538d",
"range_mid_bg": "#2c3e50",
"range_mid_hover": "#34495e",
"neutral_btn": "#3a3a3a",
"neutral_btn_hover": "#4a4a4a",
}
return {
"popup_bg": "#f7f7f7",
"main_bg": "#f7f7f7",
"day_bg": "#ffffff",
"day_hover": "#e9ecef",
"normal_text": "#222222",
"week_text": "#5f6368",
"range_text": "#1f538d",
"today_bg": "#fff3cd",
"today_hover": "#ffe69c",
"today_border": "#ffc107",
"today_border_hover": "#ff9800",
"range_edge_bg": "#1f538d",
"range_mid_bg": "#d6eaf8",
"range_mid_hover": "#aed6f1",
"neutral_btn": "#e5e7eb",
"neutral_btn_hover": "#d1d5db",
}
def _position_popup(self, parent):
"""팝업을 부모 위젯 아래에 위치"""
self.update_idletasks()
# 부모 위젯의 위치 가져오기
x = parent.winfo_rootx()
y = parent.winfo_rooty() + parent.winfo_height() + 5
# 화면 경계 체크
screen_width = self.winfo_screenwidth()
screen_height = self.winfo_screenheight()
if x + 420 > screen_width:
x = screen_width - 420 - 10
if y + 600 > screen_height:
y = parent.winfo_rooty() - 600 - 5
self.geometry(f"+{x}+{y}")
def _setup_ui(self):
"""UI 구성"""
palette = self._get_palette()
main_frame = ctk.CTkFrame(self, fg_color=palette["main_bg"])
main_frame.pack(fill="both", expand=True, padx=20, pady=20)
# === 상단: 년/월 선택 ===
header_frame = ctk.CTkFrame(main_frame, fg_color="transparent")
header_frame.pack(fill="x", pady=(0, 15))
# 이전 달 버튼
self.prev_btn = ctk.CTkButton(
header_frame,
text="",
width=40,
height=35,
command=self._prev_month,
font=theme_manager.get_font(14),
fg_color="transparent",
hover_color=("gray85", "gray30")
)
self.prev_btn.pack(side="left", padx=5)
# 년도 선택
year_frame = ctk.CTkFrame(header_frame, fg_color="transparent")
year_frame.pack(side="left", expand=True, fill="x", padx=5)
years = [str(y) for y in range(2020, 2031)]
self.year_combo = ctk.CTkComboBox(
year_frame,
values=years,
width=90,
height=35,
command=self._on_year_changed,
font=theme_manager.get_font(13, "bold"),
dropdown_font=theme_manager.get_font(12),
button_color=("gray70", "gray40"),
border_color=("gray70", "gray40")
)
self.year_combo.set(str(self.current_year))
self.year_combo.pack(side="left", padx=2)
# 월 선택
months = [f"{m}" for m in range(1, 13)]
self.month_combo = ctk.CTkComboBox(
year_frame,
values=months,
width=80,
height=35,
command=self._on_month_changed,
font=theme_manager.get_font(13, "bold"),
dropdown_font=theme_manager.get_font(12),
button_color=("gray70", "gray40"),
border_color=("gray70", "gray40")
)
self.month_combo.set(f"{self.current_month}")
self.month_combo.pack(side="left", padx=2)
# 다음 달 버튼
self.next_btn = ctk.CTkButton(
header_frame,
text="",
width=40,
height=35,
command=self._next_month,
font=theme_manager.get_font(14),
fg_color="transparent",
hover_color=("gray85", "gray30")
)
self.next_btn.pack(side="left", padx=5)
# === 선택된 범위 표시 ===
self.range_label = ctk.CTkLabel(
main_frame,
text=f"{self.start_date.strftime('%Y-%m-%d')} ~ {self.end_date.strftime('%Y-%m-%d')}",
font=theme_manager.get_font(12),
text_color=palette["range_text"],
height=30
)
self.range_label.pack(pady=(0, 10))
# === 캘린더 그리드 (고정 높이) ===
self.cal_frame = ctk.CTkFrame(main_frame, fg_color="transparent", height=330) # 6주 + 헤더 고정
self.cal_frame.pack(pady=10)
self.cal_frame.pack_propagate(False) # 크기 고정
# 요일 헤더 (일요일부터 시작)
weekdays = ["", "", "", "", "", "", ""]
colors = ["#e74c3c", palette["week_text"], palette["week_text"], palette["week_text"], palette["week_text"], palette["week_text"], "#3498db"]
for i, (day, color) in enumerate(zip(weekdays, colors)):
lbl = ctk.CTkLabel(
self.cal_frame,
text=day,
width=50,
height=30,
font=theme_manager.get_font(12, "bold"),
text_color=color
)
lbl.grid(row=0, column=i, padx=2, pady=2)
# 날짜 버튼들을 저장할 리스트
self.day_buttons = []
# === 빠른 선택 버튼 ===
quick_frame = ctk.CTkFrame(main_frame, fg_color="transparent")
quick_frame.pack(pady=15, fill="x")
quick_buttons = [
("오늘", 0),
("3일", 3),
("일주일", 7),
("한달", 30)
]
for text, days in quick_buttons:
btn = ctk.CTkButton(
quick_frame,
text=text,
width=85,
height=32,
command=lambda d=days: self._quick_select(d),
font=theme_manager.get_font(12),
fg_color=palette["neutral_btn"],
hover_color=palette["neutral_btn_hover"],
corner_radius=6
)
btn.pack(side="left", padx=3, expand=True)
# === 하단 버튼 ===
btn_frame = ctk.CTkFrame(main_frame, fg_color="transparent")
btn_frame.pack(pady=(10, 0), fill="x")
confirm_btn = ctk.CTkButton(
btn_frame,
text="✓ 적용",
height=38,
command=self._apply,
font=theme_manager.get_font(13, "bold"),
fg_color=("#1f538d", "#2980b9"),
hover_color=("#174a7a", "#1f6fa8"),
corner_radius=8
)
confirm_btn.pack(side="left", padx=5, expand=True, fill="x")
cancel_btn = ctk.CTkButton(
btn_frame,
text="✕ 취소",
height=38,
command=self.destroy,
font=theme_manager.get_font(13),
fg_color=palette["neutral_btn"],
hover_color=palette["neutral_btn_hover"],
corner_radius=8
)
cancel_btn.pack(side="left", padx=5, expand=True, fill="x")
def _update_calendar(self):
"""캘린더 날짜 업데이트"""
palette = self._get_palette()
# 기존 버튼 제거
for btn in self.day_buttons:
btn.destroy()
self.day_buttons.clear()
# 해당 월의 첫날과 마지막날
first_day = datetime(self.current_year, self.current_month, 1)
last_day = datetime(self.current_year, self.current_month,
calendar.monthrange(self.current_year, self.current_month)[1])
# 첫 주의 시작 (일요일부터)
start_weekday = (first_day.weekday() + 1) % 7 # 일요일=0
# 오늘 날짜
today = datetime.now().date()
# 날짜 버튼 생성
row = 1
col = start_weekday
for day in range(1, last_day.day + 1):
current_date = datetime(self.current_year, self.current_month, day).date()
# 요일 (0=일요일, 6=토요일)
weekday = (first_day.replace(day=day).weekday() + 1) % 7
# 기본 색상 설정
if weekday == 0: # 일요일
text_color = "#e74c3c"
elif weekday == 6: # 토요일
text_color = "#3498db"
else:
text_color = palette["normal_text"]
# 공휴일 체크
is_holiday = current_date in self.kr_holidays
if is_holiday and weekday != 0: # 일요일이 아닌 공휴일
text_color = "#e74c3c"
# 배경색 설정
fg_color = palette["day_bg"]
hover_color = palette["day_hover"]
# 오늘 날짜 강조
if current_date == today:
fg_color = palette["today_bg"]
hover_color = palette["today_hover"]
# 범위 내 날짜 강조
if self.start_date <= current_date <= self.end_date:
if current_date == self.start_date or current_date == self.end_date:
fg_color = ("#1f538d", "#2980b9")
text_color = "white"
hover_color = ("#174a7a", "#1f6fa8")
else:
fg_color = palette["range_mid_bg"]
hover_color = palette["range_mid_hover"]
# 날짜 버튼 생성
btn = ctk.CTkButton(
self.cal_frame,
text=str(day),
width=50,
height=45,
command=lambda d=day: self._on_day_click(d),
font=theme_manager.get_font(13),
fg_color=fg_color,
text_color=text_color,
hover_color=hover_color,
corner_radius=6,
border_width=1 if current_date == today else 0,
border_color=(palette["today_border"], palette["today_border_hover"])
)
btn.grid(row=row, column=col, padx=2, pady=2)
self.day_buttons.append(btn)
col += 1
if col > 6:
col = 0
row += 1
def _on_day_click(self, day):
"""날짜 클릭 이벤트"""
selected_date = datetime(self.current_year, self.current_month, day).date()
if self.selecting_start:
self.temp_start = selected_date
self.start_date = selected_date
self.end_date = selected_date
self.selecting_start = False
else:
if self.temp_start is None:
self.temp_start = selected_date
if selected_date < self.temp_start:
self.start_date = selected_date
self.end_date = self.temp_start
else:
self.start_date = self.temp_start
self.end_date = selected_date
self.selecting_start = True
self._update_range_label()
self._update_calendar()
def _quick_select(self, days):
"""빠른 선택"""
self.end_date = datetime.now().date()
self.start_date = self.end_date - timedelta(days=days)
self.selecting_start = True
self.temp_start = None
# 현재 월을 종료일 기준으로 변경
self.current_year = self.end_date.year
self.current_month = self.end_date.month
self.year_combo.set(str(self.current_year))
self.month_combo.set(f"{self.current_month}")
self._update_range_label()
self._update_calendar()
def _update_range_label(self):
"""범위 라벨 업데이트"""
self.range_label.configure(
text=f"{self.start_date.strftime('%Y-%m-%d')} ~ {self.end_date.strftime('%Y-%m-%d')}"
)
def _prev_month(self):
"""이전 달"""
if self.current_month == 1:
self.current_month = 12
self.current_year -= 1
else:
self.current_month -= 1
self.year_combo.set(str(self.current_year))
self.month_combo.set(f"{self.current_month}")
self._update_calendar()
def _next_month(self):
"""다음 달"""
if self.current_month == 12:
self.current_month = 1
self.current_year += 1
else:
self.current_month += 1
self.year_combo.set(str(self.current_year))
self.month_combo.set(f"{self.current_month}")
self._update_calendar()
def _on_year_changed(self, value):
"""년도 변경"""
self.current_year = int(value)
self._update_calendar()
def _on_month_changed(self, value):
"""월 변경"""
self.current_month = int(value.replace("", ""))
self._update_calendar()
def _apply(self):
"""적용"""
if self.callback:
self.callback(self.start_date, self.end_date)
self.destroy()
class DateRangeSelector(ctk.CTkFrame):
"""
모던하고 심플한 날짜 범위 선택 컴포넌트
"""
def __init__(self, parent, on_change_callback=None, **kwargs):
super().__init__(parent, **kwargs)
self.on_change_callback = on_change_callback
self.popup: Optional[ModernCalendarPopup] = None
# 기본값: 오늘부터 일주일 전
self.end_date: date = datetime.now().date()
self.start_date: date = self.end_date - timedelta(days=7)
# UI 구성
self._setup_ui()
self._update_display()
def _setup_ui(self):
"""UI 구성"""
# 날짜 표시 버튼
self.date_button = ctk.CTkButton(
self,
text="",
width=250,
height=40,
command=self._show_popup,
font=theme_manager.get_font(13),
fg_color=("white", "gray20"),
text_color=("gray20", "white"),
hover_color=("gray90", "gray30"),
border_width=2,
border_color=("gray70", "gray40"),
corner_radius=8
)
self.date_button.pack(side="left", padx=5)
# 초기화 버튼
self.reset_button = ctk.CTkButton(
self,
text="",
width=40,
height=40,
command=self._reset_to_default,
font=theme_manager.get_font(18),
fg_color="transparent",
text_color=("gray40", "gray60"),
hover_color=("gray85", "gray30"),
corner_radius=8
)
self.reset_button.pack(side="left", padx=2)
def _update_display(self):
"""날짜 표시 업데이트"""
if self.start_date == self.end_date:
display_text = f"📅 {self.start_date.strftime('%Y-%m-%d')}"
else:
display_text = f"📅 {self.start_date.strftime('%m/%d')} ~ {self.end_date.strftime('%m/%d')}"
self.date_button.configure(text=display_text)
def _show_popup(self):
"""팝업 표시"""
if self.popup and self.popup.winfo_exists():
self.popup.focus_force()
return
self.popup = ModernCalendarPopup(
self.date_button,
self.start_date,
self.end_date,
self._on_date_selected
)
def _on_date_selected(self, start_date, end_date):
"""날짜 선택 콜백"""
self.start_date = start_date
self.end_date = end_date
self._update_display()
if self.on_change_callback:
self.on_change_callback(self.start_date, self.end_date)
def _reset_to_default(self):
"""기본값으로 초기화"""
self.end_date = datetime.now().date()
self.start_date = self.end_date - timedelta(days=7)
self._update_display()
if self.on_change_callback:
self.on_change_callback(self.start_date, self.end_date)
def get_date_range(self):
"""현재 선택된 날짜 범위 반환"""
return self.start_date, self.end_date