Clock_out_Time_Calculator/ui/leave_calendar_view.py
KINDNICK c98ca361cd 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)
2026-05-01 13:07:52 +09:00

120 lines
4.3 KiB
Python

"""
연차 사용 캘린더 시각화.
QCalendarWidget에 사용 연차일을 색칠로 표시.
- 1.0일: 진한 색
- 0.5일(반차): 중간 색
- 0.25일(반반차): 옅은 색
"""
from __future__ import annotations
from datetime import datetime
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from ui.styles import apply_dark_titlebar
class LeaveCalendarView(QDialog):
"""연차 캘린더 시각화."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("📅 연차 캘린더")
self.setModal(True)
self.setMinimumSize(540, 480)
self._build_ui()
self._mark_dates()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 헤더: 잔여 + 범례
header = QHBoxLayout()
balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
layout.addLayout(header)
# 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout()
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
"🔵 예정", "🔘 종일+예정"]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
# 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
layout.addWidget(self.calendar, 1)
# 선택 일자 정보
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
layout.addWidget(self.detail_label)
# 닫기 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
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:
d = datetime.strptime(r['date'], '%Y-%m-%d').date()
except (ValueError, TypeError):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
is_planned = d > today
if is_planned:
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
else:
# 과거/오늘 = 사용 완료 색상
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")))
self.calendar.setDateTextFormat(qd, fmt)
def _on_date_click(self, qdate):
date_str = qdate.toString('yyyy-MM-dd')
records = self.db.get_all_leave_records(limit=365)
match = [r for r in records if r['date'] == date_str]
if not match:
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
return
parts = []
for r in match:
t = r.get('leave_type', 'annual')
d = float(r.get('days') or 0)
memo = r.get('memo') or ''
parts.append(f"{t} {d}" + (f" ({memo})" if memo else ""))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))