Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
\u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
\u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
\u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
\u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
\u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
\u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac
Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
\u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
\u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
\u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
\u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc
Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
316 lines
12 KiB
Python
316 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 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
|