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

195 lines
6.1 KiB
Python

"""
시스템 트레이 아이콘
"""
from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
from PyQt5.QtGui import QIcon, QPixmap, QPainter, QFont, QColor
from PyQt5.QtCore import Qt, QSize
from core.i18n import tr
class SystemTrayIcon(QSystemTrayIcon):
"""시스템 트레이 아이콘 클래스"""
def __init__(self, parent=None):
# 기본 아이콘 생성
icon = self.create_icon("")
super().__init__(icon, parent)
self.parent_window = parent
self.setup_menu()
# 클릭 이벤트
self.activated.connect(self.on_tray_activated)
def create_icon(self, text: str, color: QColor = None) -> QIcon:
"""
텍스트로 아이콘 생성
Args:
text: 아이콘에 표시할 텍스트 (이모지 또는 시간)
color: 배경색
Returns:
QIcon: 생성된 아이콘
"""
pixmap = QPixmap(64, 64)
if color:
pixmap.fill(color)
else:
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
# 텍스트 그리기
if len(text) <= 2:
# 이모지
font = QFont("Segoe UI Emoji", 40)
else:
# 시간 표시
font = QFont("Consolas", 12, QFont.Bold)
painter.fillRect(pixmap.rect(), QColor(255, 255, 255))
painter.setFont(font)
painter.setPen(QColor(0, 0, 0))
painter.drawText(pixmap.rect(), Qt.AlignCenter, text)
painter.end()
return QIcon(pixmap)
def setup_menu(self):
"""트레이 메뉴 설정"""
menu = QMenu()
show_action = QAction(tr('tray.open'), self)
show_action.triggered.connect(self.show_window)
menu.addAction(show_action)
mini_action = QAction(tr('tray.mini_widget'), self)
mini_action.triggered.connect(self._open_mini_widget)
menu.addAction(mini_action)
menu.addSeparator()
lunch_action = QAction(tr('tray.toggle_lunch'), self)
lunch_action.triggered.connect(self._toggle_lunch)
menu.addAction(lunch_action)
break_out_action = QAction(tr('btn.break_out'), self)
break_out_action.triggered.connect(self._break_out)
menu.addAction(break_out_action)
break_in_action = QAction(tr('btn.break_in'), self)
break_in_action.triggered.connect(self._break_in)
menu.addAction(break_in_action)
menu.addSeparator()
clock_out_action = QAction("" + tr('btn.clock_out'), self)
clock_out_action.triggered.connect(self.quick_clock_out)
menu.addAction(clock_out_action)
menu.addSeparator()
stats_action = QAction("📊 " + tr('menu.stats'), self)
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
menu.addAction(stats_action)
cal_action = QAction("📅 " + tr('menu.calendar'), self)
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
schedule_action = QAction("🗓️ 스케줄", self)
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
menu.addAction(schedule_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)
menu.addSeparator()
quit_action = QAction(tr('tray.quit'), self)
quit_action.triggered.connect(self.quit_app)
menu.addAction(quit_action)
self.setContextMenu(menu)
def _call_parent(self, method_name: str):
if self.parent_window and hasattr(self.parent_window, method_name):
getattr(self.parent_window, method_name)()
def _toggle_lunch(self):
if self.parent_window and hasattr(self.parent_window, 'lunch_button'):
self.parent_window.lunch_button.click()
def _break_out(self):
if self.parent_window and hasattr(self.parent_window, 'break_out'):
self.parent_window.break_out()
def _break_in(self):
if self.parent_window and hasattr(self.parent_window, 'break_in'):
self.parent_window.break_in()
def _open_mini_widget(self):
self._call_parent('show_mini_widget')
def on_tray_activated(self, reason):
"""트레이 아이콘 클릭 시"""
if reason == QSystemTrayIcon.DoubleClick:
self.show_window()
def show_window(self):
"""메인 윈도우 표시"""
if self.parent_window:
self.parent_window.show()
self.parent_window.activateWindow()
def quick_clock_out(self):
"""빠른 퇴근"""
if self.parent_window and hasattr(self.parent_window, 'clock_out'):
self.parent_window.clock_out()
def quit_app(self):
"""앱 종료"""
from PyQt5.QtWidgets import QApplication
QApplication.quit()
def update_time_display(self, remaining_str: str):
"""
남은 시간 표시 업데이트
Args:
remaining_str: "HH:MM" 형식의 시간
"""
# 간단한 시간 표시로 아이콘 업데이트
if remaining_str.startswith('+'):
icon = self.create_icon("🔥")
self.setToolTip(tr('tray.tooltip_overtime', time=remaining_str))
elif remaining_str.startswith('-'):
icon = self.create_icon("")
self.setToolTip(tr('tray.tooltip_remaining', time='--'))
else:
parts = remaining_str.split(':')
display_time = f"{parts[0]}:{parts[1]}" if len(parts) >= 2 else remaining_str
icon = self.create_icon("")
self.setToolTip(tr('tray.tooltip_remaining', time=display_time))
self.setIcon(icon)
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication, QMainWindow
import sys
app = QApplication(sys.argv)
window = QMainWindow()
window.setWindowTitle("Main Window")
tray = SystemTrayIcon(window)
tray.show()
window.show()
sys.exit(app.exec_())