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:
+17
-10
@@ -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")))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user