feat(leave): \uc5f0\ucc28 \ubbf8\ub9ac \ub4f1\ub85d + \uc218\uc544\ud55c \uc790\ub3d9 \uc801\uc6a9 + \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28

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)
This commit is contained in:
KINDNICK
2026-05-01 13:07:52 +09:00
parent d41e5cb921
commit c98ca361cd
12 changed files with 1250 additions and 22 deletions
+17 -10
View File
@@ -43,11 +43,10 @@ class LeaveCalendarView(QDialog):
header.addStretch()
layout.addLayout(header)
# 범례
# 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout()
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
"🔵 예정", "🔘 종일+예정"]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
@@ -76,7 +75,9 @@ class LeaveCalendarView(QDialog):
self.setLayout(layout)
def _mark_dates(self):
"""연차 사용 일자에 색상 표시."""
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
from datetime import date as _date
today = _date.today()
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
@@ -85,12 +86,18 @@ class LeaveCalendarView(QDialog):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
is_planned = d > today
if is_planned:
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
else:
color = QColor("#9c27b0")
# 과거/오늘 = 사용 완료 색상
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))
+46
View File
@@ -90,6 +90,11 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
schedule_button = QPushButton("🗓️ 스케줄")
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_button)
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
@@ -102,6 +107,13 @@ class LeaveView(QDialog):
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def _show_schedule(self):
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
# 닫고 돌아오면 잔액/리스트 갱신
self.load_data()
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
@@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
@@ -345,6 +359,38 @@ class AddLeaveDialog(QDialog):
)
return
# 휴일/주말 검증 — 차감 의미 없으므로 차단
from datetime import datetime as _dt
date_dt = _dt.strptime(date, "%Y-%m-%d")
if date_dt.weekday() in (5, 6): # 토/일
QMessageBox.warning(
self,
"주말 등록 불가",
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
)
return
if self.db.is_holiday(date):
holiday = self.db.get_holiday(date)
name = (holiday or {}).get('name', '공휴일')
QMessageBox.warning(
self,
"공휴일 등록 불가",
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
)
return
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
existing_min = self.db.get_leave_minutes_for(date)
existing_days = existing_min / max(1, self.db.get_work_minutes())
if existing_days + days > 1.0001: # 부동소수점 여유
QMessageBox.warning(
self,
"중복 등록 초과",
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
)
return
# 확인 메시지
hours = days * 8
reply = QMessageBox.question(
+84 -8
View File
@@ -631,6 +631,21 @@ class MainWindow(QMainWindow):
self.clock_out_button.setText("✅ 퇴근하기")
else:
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
today_str = datetime.now().date().isoformat()
if self.db.has_full_day_leave(today_str):
self.is_clocked_in = False
self.clock_out_button.setEnabled(False)
# 점심/저녁/외출/잔액 갱신만 수행
self.lunch_button.setChecked(False)
self.update_lunch_status()
self.dinner_button.setChecked(False)
self.update_dinner_status()
self.update_break_status()
self.update_overtime_balance()
self.update_leave_balance()
return
# 출근 기록 없음 - 자동 감지 시도
auto_clock_in = self.event_monitor.get_work_start_time()
@@ -701,6 +716,14 @@ class MainWindow(QMainWindow):
self.start_new_workday(now)
return
# 종일 연차일 — 출근 안 한 상태에서 전용 카드만 표시 후 종료.
# (수동 출근 override는 handle_clock_in 경로에서 별도 처리)
if not self.is_clocked_in:
today_str = now.date().isoformat()
if self.db.has_full_day_leave(today_str):
self._render_full_day_leave_state(today_str)
return
# 출근하지 않았으면 여기서 종료
if not self.is_clocked_in or not self.clock_in_time:
return
@@ -728,9 +751,10 @@ class MainWindow(QMainWindow):
# 총 차감 시간 (추가근무 + 연차/반차)
total_time_off = overtime_used_today + leave_used_today
# 휴일/주말이면 출근 직후부터 모든 시간이 연장근무로 흐름.
# 정해진 퇴근 시각이 없으므로 calculate_remaining_time 분기를 건너뛴다.
is_holiday = self.time_calc.is_non_working_day(now, self.db)
# 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름.
is_non_working = self.time_calc.is_non_working_day(now, self.db)
is_full_day_leave = self.db.has_full_day_leave(now.date().isoformat())
is_holiday = is_non_working or is_full_day_leave
if is_holiday:
actual_ot, _ = self.time_calc.calculate_holiday_overtime(
@@ -739,11 +763,12 @@ class MainWindow(QMainWindow):
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
)
# 추가근무/반차 사용분만큼 시작점을 늦춤 (즉 그만큼 적게 적립)
actual_ot = max(0, actual_ot - total_time_off)
# 사용한 추가근무 차감만 반영 (leave_used는 holiday/override 케이스에서 의미 없음)
actual_ot = max(0, actual_ot - overtime_used_today)
remaining = -timedelta(minutes=actual_ot)
else:
# 평일: 정상 남은 시간 계산
# 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 leave_used_today에
# 그대로 반영되어 카운트다운이 단축됨.
remaining = self.time_calc.calculate_remaining_time(
self.clock_in_time,
include_lunch=self.lunch_break_enabled,
@@ -756,9 +781,11 @@ class MainWindow(QMainWindow):
# 남은 시간 표시 및 추가 근무 처리
if remaining.total_seconds() < 0:
# 추가 근무 중 (휴일이면 출근 직후부터 항상 이 분기)
# 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기)
day_type = self.time_calc.get_day_type(now, self.db)
if day_type == 'weekend':
if is_full_day_leave and not is_non_working:
self.remaining_time_group.setTitle("연차 override (전체 적립)")
elif day_type == 'weekend':
self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
elif day_type == 'holiday':
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
@@ -844,6 +871,35 @@ class MainWindow(QMainWindow):
date_str = f"{now.year}년 {now.month}월 {now.day}일 {weekday}요일"
self.date_label.setText(date_str)
def _render_full_day_leave_state(self, today_str: str) -> None:
"""오늘이 종일 연차이고 출근 안 한 상태 → 카운트다운 대신 휴가 카드 표시."""
records = self.db.get_leave_records_by_date(today_str)
# 가장 큰 일수의 leave_type을 대표로 표시 (보통 1.0짜리 1건)
if records:
primary = max(records, key=lambda r: r.get('days') or 0)
label = primary.get('leave_type') or '연차'
memo = primary.get('memo') or ''
else:
label = '연차'
memo = ''
self.remaining_time_group.setTitle("🌴 오늘은 휴가")
self.remaining_time_label.setText("연차 사용 중")
self.remaining_time_label.setStyleSheet(
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
)
self.progress_bar.setValue(100)
if memo:
self._set_text_if_changed(self.expected_time_label,
f"🌴 {label} — {memo}")
else:
self._set_text_if_changed(self.expected_time_label,
f"🌴 {label}")
# 트레이/미니 위젯
self.tray_icon.update_time_display("🌴 휴가")
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
self._mini_widget.update_remaining("🌴 휴가")
def toggle_lunch_break(self):
"""점심시간 토글 — MealController 위임."""
self._meal.toggle_lunch()
@@ -1637,6 +1693,20 @@ class MainWindow(QMainWindow):
def manual_clock_in(self):
"""수동 출근 시간 입력"""
# 종일 연차 등록일이면 override 의도 확인
today_str = datetime.now().date().isoformat()
if self.db.has_full_day_leave(today_str):
reply = QMessageBox.question(
self,
"종일 연차 등록됨",
"오늘은 종일 연차로 등록되어 있습니다.\n"
"그래도 출근하시겠어요?\n\n"
"(출근 시 모든 시간이 연장근무로 적립됩니다.)",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
default_time = self.clock_in_time if self.clock_in_time else None
@@ -1708,6 +1778,12 @@ class MainWindow(QMainWindow):
dialog = CalendarView(self, self.db)
dialog.exec_()
def show_schedule(self):
"""통합 스케줄(휴일+연차+반복) 창 표시."""
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
def show_leave_management(self):
"""휴가 관리 창 표시"""
dialog = LeaveView(self, self.db)
+199
View File
@@ -0,0 +1,199 @@
"""
반복 연차 등록/관리 다이얼로그.
지원: 매주/격주 요일, 매월 N일.
"""
from __future__ import annotations
from datetime import date
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QComboBox, QDateEdit, QSpinBox,
QDoubleSpinBox, QLineEdit, QGroupBox,
QListWidget, QListWidgetItem, QMessageBox,
QCheckBox, QButtonGroup, QRadioButton)
from PyQt5.QtCore import QDate, Qt
from core.recurring_leaves import describe_pattern
from ui.styles import apply_dark_titlebar
_KO_WEEKDAYS = [('월', 'mon'), ('화', 'tue'), ('수', 'wed'),
('목', 'thu'), ('금', 'fri'), ('토', 'sat'), ('일', 'sun')]
class RecurringLeaveDialog(QDialog):
"""반복 연차 패턴 추가/삭제."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("🔁 반복 연차 관리")
self.setMinimumSize(540, 480)
self._build_ui()
self._reload_list()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 기존 패턴 목록
list_group = QGroupBox("등록된 반복 패턴")
lg = QVBoxLayout()
self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(160)
lg.addWidget(self.list_widget)
del_btn = QPushButton("선택 삭제")
del_btn.clicked.connect(self._delete_selected)
lg.addWidget(del_btn)
list_group.setLayout(lg)
layout.addWidget(list_group)
# 신규 등록
add_group = QGroupBox("신규 패턴 추가")
ag = QVBoxLayout()
# 패턴 종류
kind_row = QHBoxLayout()
kind_row.addWidget(QLabel("주기:"))
self.kind_group = QButtonGroup(self)
self.rb_weekly = QRadioButton("매주")
self.rb_weekly.setChecked(True)
self.rb_biweekly = QRadioButton("격주")
self.rb_monthly = QRadioButton("매월 N일")
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
self.kind_group.addButton(rb)
kind_row.addWidget(rb)
kind_row.addStretch()
ag.addLayout(kind_row)
# 요일 체크박스 (weekly/biweekly)
wd_row = QHBoxLayout()
wd_row.addWidget(QLabel("요일:"))
self.weekday_checks = []
for ko, en in _KO_WEEKDAYS:
cb = QCheckBox(ko)
self.weekday_checks.append((cb, en))
wd_row.addWidget(cb)
wd_row.addStretch()
ag.addLayout(wd_row)
# 매월 N일
month_row = QHBoxLayout()
month_row.addWidget(QLabel("매월:"))
self.day_of_month = QSpinBox()
self.day_of_month.setRange(1, 31)
self.day_of_month.setValue(15)
self.day_of_month.setSuffix("일")
month_row.addWidget(self.day_of_month)
month_row.addStretch()
ag.addLayout(month_row)
# 차감 일수
days_row = QHBoxLayout()
days_row.addWidget(QLabel("차감:"))
self.days_combo = QComboBox()
self.days_combo.addItem("1.0일 (종일)", 1.0)
self.days_combo.addItem("0.5일 (반차)", 0.5)
self.days_combo.addItem("0.25일 (반반차)", 0.25)
days_row.addWidget(self.days_combo)
days_row.addStretch()
ag.addLayout(days_row)
# 시작/종료 날짜
date_row = QHBoxLayout()
date_row.addWidget(QLabel("시작:"))
self.start_edit = QDateEdit()
self.start_edit.setDate(QDate.currentDate())
self.start_edit.setCalendarPopup(True)
date_row.addWidget(self.start_edit)
date_row.addWidget(QLabel("종료:"))
self.end_edit = QDateEdit()
self.end_edit.setDate(QDate.currentDate().addMonths(6))
self.end_edit.setCalendarPopup(True)
date_row.addWidget(self.end_edit)
self.no_end_check = QCheckBox("종료 없음 (무기한)")
self.no_end_check.toggled.connect(
lambda v: self.end_edit.setEnabled(not v)
)
date_row.addWidget(self.no_end_check)
date_row.addStretch()
ag.addLayout(date_row)
# 메모
memo_row = QHBoxLayout()
memo_row.addWidget(QLabel("메모:"))
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
memo_row.addWidget(self.memo_edit)
ag.addLayout(memo_row)
# 추가 버튼
add_btn = QPushButton("➕ 추가")
add_btn.setObjectName("btn_primary")
add_btn.clicked.connect(self._save)
ag.addWidget(add_btn)
add_group.setLayout(ag)
layout.addWidget(add_group)
# 닫기
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
def _reload_list(self):
self.list_widget.clear()
for r in self.db.get_recurring_leaves():
desc = describe_pattern(r['pattern'])
end = r.get('end_date') or '무기한'
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
f"· {r['start_date']} ~ {end}")
if r.get('memo'):
text += f" — {r['memo']}"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, r['id'])
self.list_widget.addItem(item)
def _delete_selected(self):
item = self.list_widget.currentItem()
if not item:
return
rec_id = item.data(Qt.UserRole)
reply = QMessageBox.question(
self, "삭제 확인",
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(rec_id)
self._reload_list()
def _build_pattern(self) -> str | None:
if self.rb_monthly.isChecked():
return f"monthly:{self.day_of_month.value()}"
# weekly/biweekly
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
if not chosen:
return None
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
return f"{prefix}:" + ",".join(chosen)
def _save(self):
pattern = self._build_pattern()
if not pattern:
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
return
days = self.days_combo.currentData()
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
start = self.start_edit.date().toString('yyyy-MM-dd')
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
memo = self.memo_edit.text().strip()
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
QMessageBox.information(self, "추가 완료",
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
self.memo_edit.clear()
self._reload_list()
+315
View File
@@ -0,0 +1,315 @@
"""
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
기능:
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
- 반복 패턴 관리 → 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