""" 통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴. 기능: - 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색) - 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴) - 반복 패턴 관리 → RecurringLeaveDialog """ from __future__ import annotations from datetime import datetime, date, timedelta from typing import List from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QCalendarWidget, QListWidget, QListWidgetItem, QMessageBox, QMenu, QGroupBox, QSplitter, QWidget) from PyQt5.QtCore import Qt, QDate from PyQt5.QtGui import QTextCharFormat, QColor, QBrush from core.recurring_leaves import expand_for_range, describe_pattern from ui.styles import apply_dark_titlebar # 색상 팔레트 _C_HOLIDAY = QColor("#e53935") # 빨강 _C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용) _C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용) _C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용) _C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일) _C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차) _C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치) _C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더) class ScheduleView(QDialog): """월간 통합 스케줄 다이얼로그.""" def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db self.setWindowTitle("🗓️ 스케줄") self.setMinimumSize(820, 560) self._build_ui() self._reload() apply_dark_titlebar(self) def _build_ui(self): layout = QVBoxLayout() # 상단 툴바 bar = QHBoxLayout() title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴") title.setStyleSheet("font-weight: bold; font-size: 13px;") bar.addWidget(title) bar.addStretch() rec_btn = QPushButton("🔁 반복 패턴 관리") rec_btn.clicked.connect(self._open_recurring_dialog) bar.addWidget(rec_btn) add_btn = QPushButton("➕ 연차 등록") add_btn.clicked.connect(self._open_add_leave_dialog) bar.addWidget(add_btn) layout.addLayout(bar) # 범례 legend = QHBoxLayout() for label, color in [("공휴일", _C_HOLIDAY), ("연차 사용", _C_LEAVE_FULL_PAST), ("연차 예정", _C_LEAVE_FULL_PLAN), ("반차/반반차", _C_LEAVE_HALF_PAST), ("반복 패턴", _C_RECURRING)]: sw = QLabel(f" {label} ") sw.setStyleSheet( f"background-color: {color.name()}; color: white; " f"padding: 2px 6px; border-radius: 3px;" ) legend.addWidget(sw) legend.addStretch() layout.addLayout(legend) # 캘린더 + 상세 splitter splitter = QSplitter(Qt.Horizontal) # 좌측: 캘린더 self.calendar = QCalendarWidget() self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader) self.calendar.clicked.connect(self._on_date_click) self.calendar.currentPageChanged.connect(self._on_page_change) splitter.addWidget(self.calendar) # 우측: 상세 패널 right = QWidget() right_layout = QVBoxLayout() self.detail_title = QLabel("날짜를 선택하세요") self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;") right_layout.addWidget(self.detail_title) self.detail_list = QListWidget() self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu) self.detail_list.customContextMenuRequested.connect(self._on_list_menu) right_layout.addWidget(self.detail_list, 1) right.setLayout(right_layout) splitter.addWidget(right) splitter.setSizes([520, 280]) layout.addWidget(splitter, 1) close_btn = QPushButton("닫기") close_btn.clicked.connect(self.close) layout.addWidget(close_btn) self.setLayout(layout) # ------------------------------------------------------------- reload def _reload(self): """현재 화면 월에 대해 색상/리스트 갱신.""" # 모든 날짜 포맷 초기화 self.calendar.setDateTextFormat(QDate(), QTextCharFormat()) y = self.calendar.yearShown() m = self.calendar.monthShown() # 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날) first = date(y, m, 1) if m == 12: last = date(y + 1, 1, 1) - timedelta(days=1) else: last = date(y, m + 1, 1) - timedelta(days=1) view_start = first - timedelta(days=7) view_end = last + timedelta(days=7) # 휴일 holidays = self.db.get_holidays_in_range(view_start.isoformat(), view_end.isoformat()) \ if hasattr(self.db, 'get_holidays_in_range') else [] if not holidays: holidays = self._fallback_holidays(view_start, view_end) for h in holidays: d = self._parse_date(h.get('date')) if d is None: continue self._paint(d, _C_HOLIDAY, fg='white') # 연차 (구체) leaves = self.db.get_leave_records_by_range(view_start.isoformat(), view_end.isoformat()) today = date.today() for r in leaves: d = self._parse_date(r.get('date')) if d is None: continue days = float(r.get('days') or 0) is_planned = d > today if is_planned: color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN else: if days >= 1.0: color = _C_LEAVE_FULL_PAST elif days >= 0.5: color = _C_LEAVE_HALF_PAST else: color = _C_LEAVE_QUART_PAST self._paint(d, color, fg='white') # 반복 패턴 인스턴스 recurring = self.db.get_recurring_leaves() for occ in expand_for_range(recurring, view_start, view_end): # 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음) existing = self.calendar.dateTextFormat( QDate(occ.date.year, occ.date.month, occ.date.day)) if existing.background() != QBrush(): continue self._paint(occ.date, _C_RECURRING, fg='white') def _paint(self, d: date, color: QColor, fg: str = 'white'): qd = QDate(d.year, d.month, d.day) fmt = QTextCharFormat() fmt.setBackground(QBrush(color)) fmt.setForeground(QBrush(QColor(fg))) self.calendar.setDateTextFormat(qd, fmt) # ------------------------------------------------------------- events def _on_date_click(self, qd: QDate): d = date(qd.year(), qd.month(), qd.day()) date_str = d.isoformat() weekday_kr = ['월', '화', '수', '목', '금', '토', '일'] self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)") self.detail_list.clear() # 휴일 holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None if holiday: item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}") item.setForeground(QBrush(QColor("#e53935"))) self.detail_list.addItem(item) elif d.weekday() in (5, 6): item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)") self.detail_list.addItem(item) # 연차 (구체) for r in self.db.get_leave_records_by_date(date_str): days = float(r.get('days') or 0) t = r.get('leave_type', '연차') memo = r.get('memo') or '' label = f"📌 {t} {days}일" if memo: label += f" — {memo}" label += f" [id={r['id']}]" item = QListWidgetItem(label) item.setData(Qt.UserRole, ('concrete', r['id'])) self.detail_list.addItem(item) # 반복 패턴 매치 recurring = self.db.get_recurring_leaves(active_on=date_str) from core.recurring_leaves import expand_for_date for occ in expand_for_date(recurring, d): item = QListWidgetItem( f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})" ) item.setData(Qt.UserRole, ('recurring', occ.recurring_id)) self.detail_list.addItem(item) if self.detail_list.count() == 0: self.detail_list.addItem("일정 없음") def _on_page_change(self, year: int, month: int): self._reload() def _on_list_menu(self, pos): item = self.detail_list.currentItem() if not item: return data = item.data(Qt.UserRole) if not data: return kind, _id = data menu = QMenu(self) del_act = menu.addAction("삭제") chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos)) if chosen == del_act: self._delete_record(kind, _id) def _delete_record(self, kind: str, _id: int): if kind == 'concrete': reply = QMessageBox.question( self, "삭제 확인", "이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)", QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: self.db.delete_leave_record(_id) self._reload() # 상세 갱신 d = self.calendar.selectedDate() self._on_date_click(d) elif kind == 'recurring': reply = QMessageBox.question( self, "삭제 확인", "이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)", QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: self.db.delete_recurring_leave(_id) self._reload() d = self.calendar.selectedDate() self._on_date_click(d) def _open_recurring_dialog(self): from ui.recurring_leave_dialog import RecurringLeaveDialog dlg = RecurringLeaveDialog(self, self.db) dlg.exec_() self._reload() def _open_add_leave_dialog(self): from ui.leave_view import AddLeaveDialog dlg = AddLeaveDialog(self, self.db) # 선택된 날짜로 기본값 설정 d = self.calendar.selectedDate() if d.isValid(): dlg.date_edit.setDate(d) if dlg.exec_() == dlg.Accepted: self._reload() self._on_date_click(d) # ------------------------------------------------------------- helpers @staticmethod def _parse_date(s): if not s: return None try: return datetime.strptime(s, '%Y-%m-%d').date() except (ValueError, TypeError): return None def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]: """get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리).""" if not hasattr(self.db, 'get_holiday'): return [] # 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체 # 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회) out = [] cur = view_start while cur <= view_end: h = self.db.get_holiday(cur.isoformat()) if h: out.append(h) cur += timedelta(days=1) return out