- 모던 다크 미니멀 테마(NanumSquare 번들, 단일 accent #4DABF7, 8px radius, flat 버튼, 다크 기본값) - 라인 아이콘 시스템(ui/icons.py, QtSvg) — 앱 전반 이모지 교체 - 다크 깨짐 수정: 테이블 헤더/코너 흰색, 도움말 탭 흰 라인, 트레이/미니위젯 메뉴 - fix: 자동 적립 OFF가 자동 퇴근 경로에서 무시되던 버그(게이팅) - feat: 연장근무 적립 기록 삭제(우클릭) - 테스트 3건 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
|