545 lines
18 KiB
Python
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
|