Clock_out_Time_Calculator/ui/schedule_view.py

319 lines
12 KiB
Python

"""
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
기능:
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
- 반복 패턴 관리 → 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 core.i18n import tr
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(tr('schedule.title'))
self.setMinimumSize(820, 560)
self._build_ui()
self._reload()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 상단 툴바
bar = QHBoxLayout()
title = QLabel(tr('schedule.header'))
title.setStyleSheet("font-weight: bold; font-size: 13px;")
bar.addWidget(title)
bar.addStretch()
rec_btn = QPushButton(tr('schedule.btn_recurring'))
rec_btn.clicked.connect(self._open_recurring_dialog)
bar.addWidget(rec_btn)
add_btn = QPushButton(tr('schedule.btn_add_leave'))
add_btn.clicked.connect(self._open_add_leave_dialog)
bar.addWidget(add_btn)
layout.addLayout(bar)
# 범례
legend = QHBoxLayout()
for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
(tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
(tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
(tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
(tr('schedule.legend_recurring'), _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(tr('schedule.detail_placeholder'))
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(tr('btn.close'))
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 = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), tr('label.weekday_sun')]
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
self.detail_list.clear()
# 휴일
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
if holiday:
item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
item.setForeground(QBrush(QColor("#e53935")))
self.detail_list.addItem(item)
elif d.weekday() in (5, 6):
item = QListWidgetItem(tr('schedule.weekend', weekday=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 = tr('schedule.leave_label', type=t, days=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(
tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
days=occ.days, type=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(tr('schedule.no_events'))
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(tr('schedule.delete'))
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, tr('schedule.delete_leave_confirm_title'),
tr('schedule.delete_leave_confirm_body'),
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, tr('schedule.delete_recurring_confirm_title'),
tr('schedule.delete_recurring_confirm_body'),
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