Clock_out_Time_Calculator/ui/schedule_view.py
KINDNICK 5fb8655a47 v2.11.0: UI 전면 다크 리디자인 + 라인 아이콘 + 적립 가드/삭제
- 모던 다크 미니멀 테마(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>
2026-06-04 18:21:54 +09:00

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