# -*- coding: utf-8 -*- """ 커스텀 캘린더 모듈 날짜 및 기간 선택을 위한 커스텀 캘린더 위젯입니다. 기능: - 단일 날짜 선택 - 기간(시작~종료) 선택 토글 - 시간 선택 (시/분, 30분 단위) - 날짜 강조 표시 - 현대적인 UI 스타일 """ from datetime import date, datetime, time from typing import Optional, Tuple from PySide6.QtWidgets import ( QCalendarWidget, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSpinBox, QComboBox ) from PySide6.QtCore import Qt, Signal, QDate, QTime from PySide6.QtGui import QFont, QColor, QTextCharFormat, QPainter, QPen, QBrush from core.config import ConfigManager from core.logger import get_logger logger = get_logger(__name__) class RangeCalendarWidget(QCalendarWidget): """ 기간 선택을 지원하는 캘린더 위젯 paintCell을 오버라이드하여 기간 범위를 시각적으로 표시합니다. """ def __init__(self, parent=None): super().__init__(parent) self.config = ConfigManager() self._start_date: Optional[QDate] = None self._end_date: Optional[QDate] = None self._range_mode = False # 기본 설정 self.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader) self.setHorizontalHeaderFormat(QCalendarWidget.ShortDayNames) self.setGridVisible(False) self.setNavigationBarVisible(True) self._apply_style() def set_range_mode(self, enabled: bool): """기간 선택 모드 설정""" self._range_mode = enabled if not enabled: self._start_date = None self._end_date = None self.updateCells() def set_range(self, start: Optional[QDate], end: Optional[QDate]): """기간 설정""" self._start_date = start self._end_date = end self.updateCells() def get_range(self) -> Tuple[Optional[date], Optional[date]]: """선택된 기간 반환""" start = None end = None if self._start_date: start = date(self._start_date.year(), self._start_date.month(), self._start_date.day()) if self._end_date: end = date(self._end_date.year(), self._end_date.month(), self._end_date.day()) return start, end def paintCell(self, painter: QPainter, rect, qdate: QDate): """셀 그리기 (기간 범위 표시)""" painter.save() theme = self.config.theme is_dark = theme == 'dark' # 색상 정의 if is_dark: text_color = QColor("#f8fafc") range_bg = QColor("#3b82f6") range_bg.setAlpha(60) start_end_bg = QColor("#3b82f6") today_border = QColor("#22c55e") other_month_text = QColor("#64748b") else: text_color = QColor("#1e293b") range_bg = QColor("#3b82f6") range_bg.setAlpha(40) start_end_bg = QColor("#3b82f6") today_border = QColor("#22c55e") other_month_text = QColor("#94a3b8") # 현재 보이는 달인지 확인 is_current_month = qdate.month() == self.monthShown() and qdate.year() == self.yearShown() # 기간 모드에서 범위 내 날짜인지 확인 in_range = False is_start = False is_end = False if self._range_mode and self._start_date and self._end_date: start = self._start_date end = self._end_date if start > end: start, end = end, start in_range = start <= qdate <= end is_start = qdate == start is_end = qdate == end elif self._range_mode and self._start_date: is_start = qdate == self._start_date # 배경 그리기 if is_start or is_end: # 시작/종료 날짜: 원형 배경 painter.setBrush(QBrush(start_end_bg)) painter.setPen(Qt.NoPen) center = rect.center() radius = min(rect.width(), rect.height()) // 2 - 4 painter.drawEllipse(center, radius, radius) text_color = QColor("#ffffff") elif in_range: # 범위 내 날짜: 연한 배경 painter.fillRect(rect, range_bg) # 오늘 날짜 테두리 if qdate == QDate.currentDate(): painter.setPen(QPen(today_border, 2)) painter.setBrush(Qt.NoBrush) center = rect.center() radius = min(rect.width(), rect.height()) // 2 - 4 painter.drawEllipse(center, radius, radius) # 텍스트 그리기 if not is_current_month: painter.setPen(other_month_text) elif is_start or is_end: painter.setPen(QColor("#ffffff")) else: painter.setPen(text_color) painter.setFont(QFont("GmarketSans", 11)) painter.drawText(rect, Qt.AlignCenter, str(qdate.day())) painter.restore() def _apply_style(self): """스타일 적용""" theme = self.config.theme if theme == 'dark': bg = "#1e293b" header_bg = "#334155" text = "#f8fafc" hover_bg = "#475569" nav_bg = "#0f172a" border = "#334155" else: bg = "#ffffff" header_bg = "#f1f5f9" text = "#1e293b" hover_bg = "#e2e8f0" nav_bg = "#f8fafc" border = "#e2e8f0" self.setStyleSheet(f""" QCalendarWidget {{ background-color: {bg}; font-family: 'GmarketSans'; border: 1px solid {border}; border-radius: 8px; }} QCalendarWidget QToolButton {{ background-color: {nav_bg}; color: {text}; font-size: 13px; font-weight: bold; padding: 6px 12px; border-radius: 6px; margin: 2px; }} QCalendarWidget QToolButton:hover {{ background-color: {hover_bg}; }} QCalendarWidget QToolButton::menu-indicator {{ image: none; }} QCalendarWidget QWidget#qt_calendar_navigationbar {{ background-color: {nav_bg}; padding: 4px; border-top-left-radius: 8px; border-top-right-radius: 8px; }} QCalendarWidget QTableView {{ background-color: {bg}; outline: none; selection-background-color: transparent; selection-color: {text}; }} QCalendarWidget QHeaderView::section {{ background-color: {header_bg}; color: {text}; padding: 6px; border: none; font-weight: bold; font-size: 11px; }} """) class TimeSelector(QWidget): """ 시간 선택 위젯 시간과 분을 선택할 수 있습니다. 분은 30분 단위 (00, 30)로 기본 설정됩니다. Signals: time_changed: 시간이 변경되었을 때 (QTime) """ time_changed = Signal(object) # QTime def __init__(self, parent=None, minute_step: int = 30): """ Args: parent: 부모 위젯 minute_step: 분 단위 (기본 30분) """ super().__init__(parent) self.config = ConfigManager() self._minute_step = minute_step self._setup_ui() def _setup_ui(self): """UI 설정""" layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) theme = self.config.theme is_dark = theme == 'dark' # 시간 선택 self.hour_spin = QSpinBox() self.hour_spin.setRange(0, 23) self.hour_spin.setSuffix("시") self.hour_spin.setFont(QFont("GmarketSans", 12)) self.hour_spin.valueChanged.connect(self._on_time_changed) layout.addWidget(self.hour_spin) # 구분자 colon = QLabel(":") colon.setFont(QFont("GmarketSans", 14, QFont.Bold)) colon.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'};") layout.addWidget(colon) # 분 선택 (스텝이 1이면 스핀박스, 아니면 드롭다운) if self._minute_step == 1: self.minute_spin = QSpinBox() self.minute_spin.setRange(0, 59) self.minute_spin.setSuffix("분") self.minute_spin.setFont(QFont("GmarketSans", 12)) self.minute_spin.valueChanged.connect(self._on_time_changed) self.minute_combo = None layout.addWidget(self.minute_spin) else: self.minute_spin = None self.minute_combo = QComboBox() self.minute_combo.setFont(QFont("GmarketSans", 12)) # 분 옵션 생성 (30분 단위) minutes = [] for m in range(0, 60, self._minute_step): minutes.append(f"{m:02d}분") self.minute_combo.addItems(minutes) self.minute_combo.currentIndexChanged.connect(self._on_time_changed) layout.addWidget(self.minute_combo) self._apply_style() def _apply_style(self): """스타일 적용""" theme = self.config.theme if theme == 'dark': bg = "#1e293b" text = "#f8fafc" border = "#475569" hover = "#334155" else: bg = "#ffffff" text = "#1e293b" border = "#e2e8f0" hover = "#f1f5f9" style = f""" QSpinBox, QComboBox {{ background-color: {bg}; color: {text}; border: 1px solid {border}; border-radius: 6px; padding: 6px 10px; min-width: 70px; }} QSpinBox:hover, QComboBox:hover {{ border-color: #3b82f6; }} QSpinBox::up-button, QSpinBox::down-button {{ width: 20px; background-color: {hover}; border: none; }} QSpinBox::up-button:hover, QSpinBox::down-button:hover {{ background-color: #3b82f6; }} QComboBox::drop-down {{ border: none; width: 25px; }} QComboBox::down-arrow {{ image: none; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid {text}; margin-right: 5px; }} QComboBox QAbstractItemView {{ background-color: {bg}; color: {text}; border: 1px solid {border}; selection-background-color: #3b82f6; }} """ self.setStyleSheet(style) def _on_time_changed(self): """시간 변경 시""" current_time = self.get_time() self.time_changed.emit(current_time) def get_time(self) -> time: """현재 선택된 시간 반환""" hour = self.hour_spin.value() if self._minute_step == 1 and self.minute_spin: minute = self.minute_spin.value() else: minute_idx = self.minute_combo.currentIndex() minute = minute_idx * self._minute_step return time(hour, minute) def set_time(self, t: time): """시간 설정""" self.hour_spin.setValue(t.hour) if self._minute_step == 1 and self.minute_spin: self.minute_spin.setValue(t.minute) else: # 가장 가까운 분 단위로 반올림 minute_idx = round(t.minute / self._minute_step) if minute_idx >= self.minute_combo.count(): minute_idx = 0 self.minute_combo.setCurrentIndex(minute_idx) def get_qtime(self) -> QTime: """QTime으로 반환""" t = self.get_time() return QTime(t.hour, t.minute) class CustomCalendar(QWidget): """ 커스텀 캘린더 위젯 날짜 또는 기간 선택 기능을 제공합니다. '기간' 토글을 활성화하면 시작일과 종료일을 선택할 수 있습니다. 시간 선택 옵션을 활성화하면 시/분도 선택할 수 있습니다. Signals: date_selected: 단일 날짜 선택 시그널 (date) range_selected: 기간 선택 시그널 (start: date, end: date) datetime_selected: 날짜+시간 선택 시그널 (datetime) range_datetime_selected: 기간+시간 선택 시그널 (start: datetime, end: datetime) Examples: >>> calendar = CustomCalendar(show_time=True) >>> calendar.date_selected.connect(self.on_date_selected) >>> calendar.datetime_selected.connect(self.on_datetime_selected) """ date_selected = Signal(object) # date range_selected = Signal(object, object) # start_date, end_date datetime_selected = Signal(object) # datetime range_datetime_selected = Signal(object, object) # start_datetime, end_datetime def __init__( self, parent=None, show_range_toggle: bool = True, show_time: bool = False, minute_step: int = 30 ): """ Args: parent: 부모 위젯 show_range_toggle: 기간 토글 버튼 표시 여부 show_time: 시간 선택 표시 여부 minute_step: 분 단위 (기본 30분) """ super().__init__(parent) self.config = ConfigManager() self._show_range_toggle = show_range_toggle self._show_time = show_time self._minute_step = minute_step self._range_mode = False self._first_click = True # 기간 모드에서 첫 번째 클릭인지 self._start_date: Optional[QDate] = None self._end_date: Optional[QDate] = None self._setup_ui() self._connect_signals() def _setup_ui(self): """UI 설정""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) theme = self.config.theme is_dark = theme == 'dark' # 상단 툴바 (기간 토글, 날짜 표시) toolbar = QWidget() toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(8, 4, 8, 4) toolbar_layout.setSpacing(8) # 선택된 날짜/기간 표시 self.date_label = QLabel("날짜를 선택하세요") self.date_label.setFont(QFont("GmarketSans", 11)) self.date_label.setStyleSheet(f"color: {'#94a3b8' if is_dark else '#64748b'};") toolbar_layout.addWidget(self.date_label) toolbar_layout.addStretch() # 기간 토글 버튼 if self._show_range_toggle: self.range_btn = QPushButton("📅 기간") self.range_btn.setCheckable(True) self.range_btn.setFont(QFont("GmarketSans", 11)) self.range_btn.setCursor(Qt.PointingHandCursor) self.range_btn.clicked.connect(self._toggle_range_mode) btn_style = f""" QPushButton {{ background-color: {'#334155' if is_dark else '#e2e8f0'}; color: {'#f8fafc' if is_dark else '#1e293b'}; border: none; border-radius: 6px; padding: 6px 12px; }} QPushButton:hover {{ background-color: {'#475569' if is_dark else '#cbd5e1'}; }} QPushButton:checked {{ background-color: #3b82f6; color: white; }} """ self.range_btn.setStyleSheet(btn_style) toolbar_layout.addWidget(self.range_btn) else: self.range_btn = None layout.addWidget(toolbar) # 캘린더 self.calendar = RangeCalendarWidget() layout.addWidget(self.calendar) # 시간 선택 영역 (옵션) if self._show_time: time_container = QWidget() time_layout = QHBoxLayout(time_container) time_layout.setContentsMargins(8, 8, 8, 8) time_layout.setSpacing(16) # 시작 시간 (기간 모드) self.start_time_label = QLabel("시작 시간:") self.start_time_label.setFont(QFont("GmarketSans", 11)) self.start_time_label.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'};") self.start_time_label.setVisible(False) time_layout.addWidget(self.start_time_label) self.start_time_selector = TimeSelector(minute_step=self._minute_step) self.start_time_selector.time_changed.connect(self._on_time_changed) time_layout.addWidget(self.start_time_selector) # 종료 시간 (기간 모드) self.end_time_label = QLabel("→ 종료 시간:") self.end_time_label.setFont(QFont("GmarketSans", 11)) self.end_time_label.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'};") self.end_time_label.setVisible(False) time_layout.addWidget(self.end_time_label) self.end_time_selector = TimeSelector(minute_step=self._minute_step) self.end_time_selector.time_changed.connect(self._on_time_changed) self.end_time_selector.setVisible(False) time_layout.addWidget(self.end_time_selector) time_layout.addStretch() layout.addWidget(time_container) else: self.start_time_selector = None self.end_time_selector = None # 하단 버튼 btn_container = QWidget() btn_layout = QHBoxLayout(btn_container) btn_layout.setContentsMargins(8, 4, 8, 8) btn_layout.setSpacing(8) # 오늘 버튼 self.today_btn = QPushButton("오늘") self.today_btn.setFont(QFont("GmarketSans", 11)) self.today_btn.setCursor(Qt.PointingHandCursor) self.today_btn.clicked.connect(self._go_to_today) self.today_btn.setStyleSheet(f""" QPushButton {{ background-color: {'#334155' if is_dark else '#e2e8f0'}; color: {'#f8fafc' if is_dark else '#1e293b'}; border: none; border-radius: 6px; padding: 6px 16px; }} QPushButton:hover {{ background-color: {'#475569' if is_dark else '#cbd5e1'}; }} """) btn_layout.addWidget(self.today_btn) btn_layout.addStretch() # 초기화 버튼 self.clear_btn = QPushButton("초기화") self.clear_btn.setFont(QFont("GmarketSans", 11)) self.clear_btn.setCursor(Qt.PointingHandCursor) self.clear_btn.clicked.connect(self._clear_selection) self.clear_btn.setStyleSheet(f""" QPushButton {{ background-color: {'#334155' if is_dark else '#e2e8f0'}; color: {'#f8fafc' if is_dark else '#1e293b'}; border: none; border-radius: 6px; padding: 6px 16px; }} QPushButton:hover {{ background-color: {'#475569' if is_dark else '#cbd5e1'}; }} """) btn_layout.addWidget(self.clear_btn) layout.addWidget(btn_container) def _connect_signals(self): """시그널 연결""" self.calendar.clicked.connect(self._on_date_clicked) def _toggle_range_mode(self): """기간 선택 모드 토글""" if self.range_btn: self._range_mode = self.range_btn.isChecked() else: self._range_mode = not self._range_mode self._first_click = True self._start_date = None self._end_date = None self.calendar.set_range_mode(self._range_mode) # 시간 선택기 표시/숨김 if self._show_time: self.start_time_label.setVisible(self._range_mode) self.end_time_label.setVisible(self._range_mode) self.end_time_selector.setVisible(self._range_mode) self._update_date_label() def set_range_mode(self, enabled: bool): """외부에서 기간 모드 설정""" if self.range_btn: self.range_btn.setChecked(enabled) self._range_mode = enabled self.calendar.set_range_mode(enabled) if self._show_time: self.start_time_label.setVisible(enabled) self.end_time_label.setVisible(enabled) self.end_time_selector.setVisible(enabled) self._update_date_label() def _on_date_clicked(self, qdate: QDate): """날짜 클릭 이벤트""" if self._range_mode: # 기간 선택 모드 if self._first_click: # 첫 번째 클릭: 시작일 설정 self._start_date = qdate self._end_date = None self._first_click = False self.calendar.set_range(self._start_date, None) else: # 두 번째 클릭: 종료일 설정 self._end_date = qdate self._first_click = True # 시작일이 종료일보다 크면 스왑 if self._start_date > self._end_date: self._start_date, self._end_date = self._end_date, self._start_date self.calendar.set_range(self._start_date, self._end_date) # 시그널 발생 start = date(self._start_date.year(), self._start_date.month(), self._start_date.day()) end = date(self._end_date.year(), self._end_date.month(), self._end_date.day()) if self._show_time: start_time = self.start_time_selector.get_time() end_time = self.end_time_selector.get_time() start_dt = datetime.combine(start, start_time) end_dt = datetime.combine(end, end_time) self.range_datetime_selected.emit(start_dt, end_dt) else: self.range_selected.emit(start, end) else: # 단일 날짜 선택 모드 selected_date = date(qdate.year(), qdate.month(), qdate.day()) if self._show_time: selected_time = self.start_time_selector.get_time() selected_dt = datetime.combine(selected_date, selected_time) self.datetime_selected.emit(selected_dt) else: self.date_selected.emit(selected_date) self._update_date_label() def _on_time_changed(self, _time): """시간 변경 시""" # 날짜가 선택된 상태에서 시간만 변경되면 시그널 발생 if self._range_mode: if self._start_date and self._end_date: start = date(self._start_date.year(), self._start_date.month(), self._start_date.day()) end = date(self._end_date.year(), self._end_date.month(), self._end_date.day()) start_time = self.start_time_selector.get_time() end_time = self.end_time_selector.get_time() start_dt = datetime.combine(start, start_time) end_dt = datetime.combine(end, end_time) self.range_datetime_selected.emit(start_dt, end_dt) else: qdate = self.calendar.selectedDate() selected_date = date(qdate.year(), qdate.month(), qdate.day()) selected_time = self.start_time_selector.get_time() selected_dt = datetime.combine(selected_date, selected_time) self.datetime_selected.emit(selected_dt) self._update_date_label() def _update_date_label(self): """날짜 라벨 업데이트""" theme = self.config.theme is_dark = theme == 'dark' if self._range_mode: if self._start_date and self._end_date: start = self._start_date end = self._end_date text = f"{start.year()}.{start.month():02d}.{start.day():02d}" if self._show_time: st = self.start_time_selector.get_time() text += f" {st.hour:02d}:{st.minute:02d}" text += f" ~ {end.year()}.{end.month():02d}.{end.day():02d}" if self._show_time: et = self.end_time_selector.get_time() text += f" {et.hour:02d}:{et.minute:02d}" self.date_label.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'}; font-weight: bold;") elif self._start_date: text = f"{self._start_date.year()}.{self._start_date.month():02d}.{self._start_date.day():02d} ~ ?" self.date_label.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'};") else: text = "시작일을 선택하세요" self.date_label.setStyleSheet(f"color: {'#94a3b8' if is_dark else '#64748b'};") else: qdate = self.calendar.selectedDate() text = f"{qdate.year()}.{qdate.month():02d}.{qdate.day():02d}" if self._show_time: t = self.start_time_selector.get_time() text += f" {t.hour:02d}:{t.minute:02d}" self.date_label.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'}; font-weight: bold;") self.date_label.setText(text) def _go_to_today(self): """오늘 날짜로 이동""" self.calendar.setSelectedDate(QDate.currentDate()) if not self._range_mode: today = date.today() if self._show_time: now = datetime.now() self.start_time_selector.set_time(now.time()) self.datetime_selected.emit(now) else: self.date_selected.emit(today) self._update_date_label() def _clear_selection(self): """선택 초기화""" self._first_click = True self._start_date = None self._end_date = None self.calendar.set_range(None, None) if self._show_time: self.start_time_selector.set_time(time(0, 0)) if self.end_time_selector: self.end_time_selector.set_time(time(0, 0)) self._update_date_label() def set_date(self, d: date): """날짜 설정""" self.calendar.setSelectedDate(QDate(d.year, d.month, d.day)) self._update_date_label() def get_date(self) -> date: """선택된 날짜 반환""" qdate = self.calendar.selectedDate() return date(qdate.year(), qdate.month(), qdate.day()) def set_datetime(self, dt: datetime): """날짜와 시간 설정""" self.calendar.setSelectedDate(QDate(dt.year, dt.month, dt.day)) if self._show_time: self.start_time_selector.set_time(dt.time()) self._update_date_label() def get_datetime(self) -> datetime: """선택된 날짜+시간 반환""" qdate = self.calendar.selectedDate() d = date(qdate.year(), qdate.month(), qdate.day()) if self._show_time: t = self.start_time_selector.get_time() else: t = time(0, 0) return datetime.combine(d, t) def set_range(self, start: date, end: date): """기간 설정""" self._range_mode = True if self.range_btn: self.range_btn.setChecked(True) self._start_date = QDate(start.year, start.month, start.day) self._end_date = QDate(end.year, end.month, end.day) self._first_click = True self.calendar.set_range_mode(True) self.calendar.set_range(self._start_date, self._end_date) if self._show_time: self.start_time_label.setVisible(True) self.end_time_label.setVisible(True) self.end_time_selector.setVisible(True) self._update_date_label() def set_range_datetime(self, start: datetime, end: datetime): """기간 + 시간 설정""" self.set_range(start.date(), end.date()) if self._show_time: self.start_time_selector.set_time(start.time()) self.end_time_selector.set_time(end.time()) self._update_date_label() def get_range(self) -> Tuple[Optional[date], Optional[date]]: """선택된 기간 반환""" return self.calendar.get_range() def get_range_datetime(self) -> Tuple[Optional[datetime], Optional[datetime]]: """선택된 기간 + 시간 반환""" start_date, end_date = self.calendar.get_range() if start_date and end_date: if self._show_time: start_time = self.start_time_selector.get_time() end_time = self.end_time_selector.get_time() else: start_time = time(0, 0) end_time = time(23, 59) return ( datetime.combine(start_date, start_time), datetime.combine(end_date, end_time) ) return None, None def highlight_dates(self, dates: list, color: str = "#3b82f6"): """ 여러 날짜 강조 표시 Args: dates: date 리스트 color: 강조 색상 """ highlight_format = QTextCharFormat() highlight_format.setBackground(QColor(color)) highlight_format.setForeground(QColor("#ffffff")) for d in dates: self.calendar.setDateTextFormat(QDate(d.year, d.month, d.day), highlight_format)