""" 메인 GUI 윈도우 PyQt5를 사용한 메인 애플리케이션 인터페이스 """ import sys from datetime import datetime, timedelta from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QProgressBar, QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon, QShortcut, QDialog) from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir, QSize from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence from core.settings_keys import ( DB_PATH_OVERRIDE, LANGUAGE, TIME_FORMAT, THEME, WORKDAY_BOUNDARY_HOUR, WORK_MINUTES, WORK_HOURS, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, ) from core.i18n import tr import os import sys # core 모듈을 import하기 위한 경로 추가 sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.database import Database from core.event_monitor import EventMonitor from core.time_calculator import TimeCalculator from ui.clock_in_dialog import ClockInDialog from ui.calendar_view import CalendarView from ui.stats_view import StatsView from ui.leave_view import LeaveView from ui.settings_view import SettingsView from ui.break_view import BreakView from core.notifier import Notifier from utils.system_tray import SystemTrayIcon from utils.time_format import format_hours_minutes from ui.styles import get_theme, ThemeColors, apply_dark_titlebar from utils.debug_log import dlog class MainWindow(QMainWindow): """메인 윈도우 클래스""" def __init__(self, db: 'Database' = None): """ Args: db: 사전 초기화된 Database 인스턴스. None이면 자체 부트스트랩. (main.py가 backup/crash_handler용으로 먼저 만들고 전달) """ super().__init__() # 테마 적용 self.current_theme = 'dark' # 설정에서 로드 후 덮어씀 # 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩 if db is not None: self.db = db else: bootstrap = Database() override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or '' if override_path and os.path.exists(os.path.dirname(override_path) or '.'): self.db = Database(override_path) else: self.db = bootstrap self.event_monitor = EventMonitor() # 언어 초기화 (설정값 반영) from core.i18n import set_language set_language(self.db.get_setting(LANGUAGE, 'ko') or 'ko') # 접근성 — 글꼴 크기 + 고대비 try: from ui.accessibility import apply_from_settings as _apply_a11y _apply_a11y(self.db) except Exception as e: dlog(f"accessibility apply failed: {e}") # TimeCalculator 초기화 (설정값 반영) settings = self.db.get_settings() # 시간 형식 설정 캐시 (매 초 DB 조회 방지) self.cached_time_format = str(settings.get(TIME_FORMAT, '24')) # 테마 설정 self.current_theme = str(settings.get(THEME, 'dark')) self.apply_theme(self.current_theme) self.time_calc = self._build_time_calc(settings) # 알림 시스템 (db 전달 — 설정 키로 알림 가드) self.notifier = Notifier(self, db=self.db) self.notifier.notification_signal.connect(self.show_notification) # 도전과제 정의 동기화 (실패는 silent — 핵심 기능 아님) try: from core.achievements import sync_definitions_to_db sync_definitions_to_db(self.db) except Exception as e: from utils.debug_log import dlog dlog(f"achievements sync failed: {e}") # 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션) from ui.controllers.lock_monitor import LockMonitor from ui.controllers.auto_lunch import AutoLunchManager from ui.controllers.notification_orchestrator import NotificationOrchestrator from ui.controllers.meal_controller import MealController self._lock_monitor = LockMonitor(self) self._auto_lunch = AutoLunchManager(self) self._notif_orch = NotificationOrchestrator(self) self._meal = MealController(self) # 시스템 트레이 self.tray_icon = SystemTrayIcon(self) self.tray_icon.show() # 윈도우 아이콘 설정 (시계 아이콘) from PyQt5.QtGui import QIcon # PyInstaller로 패키징된 경우 _MEIPASS 경로 사용 if getattr(sys, 'frozen', False): # PyInstaller로 실행 중 base_path = sys._MEIPASS else: # 일반 Python 실행 base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) icon_path = os.path.join(base_path, "3d-alarm.png") if os.path.exists(icon_path): window_icon = QIcon(icon_path) else: window_icon = self.tray_icon.create_icon("⏰") self.setWindowIcon(window_icon) # 상태 변수 self.clock_in_time = None self.lunch_break_enabled = False self.dinner_break_enabled = False self.is_clocked_in = False self.is_on_break = False # 외출 중 여부 self.midnight_rollover_handled = False # 자정 넘김 처리 여부 self.auto_lunch_applied_today = False # auto_lunch 중복 적용 방지 # update_display 1Hz 핫패스 캐시 (설정/날짜 변경 시에만 재계산) self._workday_boundary_hour_cache = None self._non_working_cache_date = None self._non_working_cache_value = None self._full_day_leave_cache_date = None self._full_day_leave_cache_value = None # 컨트롤러는 init_ui() 이후 알림 시스템 생성 시점에 함께 초기화 # UI 초기화 self.init_ui() # 타이머 시작 (1초마다 업데이트) self.timer = QTimer() self.timer.timeout.connect(self.update_display) self.timer.start(1000) # 화면 잠금 감지 (5초 간격, auto_break_on_lock 설정 시 활성) self._last_lock_state = False self._lock_timer = QTimer() self._lock_timer.timeout.connect(self._check_screen_lock) self._lock_timer.start(5000) # 종료 시 타이머 정리 — aboutToQuit은 QApplication 종료 직전에만 1회 fire. # 모든 종료 경로(트레이 메뉴/Discord/언어변경 등)를 한 곳에서 커버. try: qapp = QApplication.instance() if qapp is not None: qapp.aboutToQuit.connect(self._on_app_quit) except Exception as e: dlog(f"aboutToQuit connect failed: {e}") # 초기 데이터 로드 self.load_today_data() # 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시) QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True)) def _on_app_quit(self) -> None: """QApplication.aboutToQuit 핸들러 — 타이머 정지 + 트레이 숨김. in-flight 1Hz/5s tick이 부분 파괴된 객체에 대해 fire하는 것 방지. """ try: if hasattr(self, 'timer') and self.timer is not None: self.timer.stop() if hasattr(self, '_lock_timer') and self._lock_timer is not None: self._lock_timer.stop() if hasattr(self, 'notifier') and self.notifier is not None: # Notifier 내부 1분 timer if hasattr(self.notifier, 'timer'): self.notifier.timer.stop() if hasattr(self, 'tray_icon') and self.tray_icon is not None: self.tray_icon.hide() except Exception: # 정리 실패해도 종료는 진행 — Qt가 결국 다 cleanup pass def _check_screen_lock(self): """LockMonitor 컨트롤러로 위임 (5초 polling).""" self._lock_monitor.tick() def _set_text_if_changed(self, widget, text: str) -> None: """직전 값과 다를 때만 setText (1Hz hot path 무의미한 repaint 방지).""" if widget.text() != text: widget.setText(text) def format_time(self, dt: datetime, include_seconds: bool = False) -> str: """ 시간을 설정에 따라 형식화 Args: dt: datetime 객체 include_seconds: 초 포함 여부 Returns: 형식화된 시간 문자열 """ # 캐시된 시간 형식 사용 (매 초 DB 조회 방지) time_format = getattr(self, 'cached_time_format', '24') if time_format == '12': # 12시간 형식 (오전/오후) hour = dt.hour minute = dt.minute second = dt.second period = tr('label.am') if hour < 12 else tr('label.pm') display_hour = hour % 12 if display_hour == 0: display_hour = 12 minute_str = f"{minute:02d}:{second:02d}" if include_seconds else f"{minute:02d}" return tr('time_format.12h', period=period, hour=display_hour, minute=minute_str) else: # 24시간 형식 if include_seconds: return dt.strftime('%H:%M:%S') else: return dt.strftime('%H:%M') def init_ui(self): """UI 초기화""" from core.version import __version__ from ui.i18n_runtime import register self._app_version = __version__ self.setWindowTitle(f"{tr('window.main_title')} v{__version__}") register(self, 'window.main_title', setter='setWindowTitle', post=lambda t: f"{t} v{__version__}") self.setGeometry(100, 100, 540, 720) self.setMinimumSize(500, 600) # 외부 컨테이너 (스크롤 + 고정 하단) from PyQt5.QtWidgets import QScrollArea outer_widget = QWidget() outer_layout = QVBoxLayout() outer_layout.setSpacing(0) outer_layout.setContentsMargins(0, 0, 0, 0) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # 중앙 위젯 (스크롤 내부) central_widget = QWidget() central_widget.setObjectName("central_widget") scroll_area.setWidget(central_widget) outer_layout.addWidget(scroll_area, 1) outer_widget.setLayout(outer_layout) self.setCentralWidget(outer_widget) # 메인 레이아웃 — 외곽 24px, 위젯 간 12px (통일된 여백 시스템) main_layout = QVBoxLayout() main_layout.setSpacing(12) main_layout.setContentsMargins(24, 20, 24, 16) # 1. 헤더 - 앱 타이틀 title_label = QLabel(tr('app.title')) title_label.setObjectName("app_title") title_label.setAlignment(Qt.AlignCenter) main_layout.addWidget(title_label) # 2. 날짜 표시 self.date_label = QLabel() self.date_label.setObjectName("date_label") self.date_label.setAlignment(Qt.AlignCenter) main_layout.addWidget(self.date_label) # 1.5 오늘 요약 카드 (퇴근 후 표시, 평소엔 숨김) from ui.today_summary import TodaySummaryCard self.today_summary_card = TodaySummaryCard() main_layout.addWidget(self.today_summary_card) # 2. 출근 정보 그룹 clock_in_group = self.create_clock_in_group() main_layout.addWidget(clock_in_group) # 3. 남은 시간 표시 그룹 (히어로 — 남은시간 + 진행률 + 예상 퇴근시각 통합) remaining_group = self.create_remaining_time_group() main_layout.addWidget(remaining_group) # 5. 점심/저녁 토글 (가로 배치) meal_button_layout = QHBoxLayout() meal_button_layout.setSpacing(8) self.lunch_button = QPushButton(tr('btn.lunch_add')) self.lunch_button.setCheckable(True) self.lunch_button.clicked.connect(self.toggle_lunch_break) self.lunch_button.setContextMenuPolicy(Qt.CustomContextMenu) self.lunch_button.customContextMenuRequested.connect( lambda pos: self._show_meal_context('lunch', self.lunch_button, pos) ) self.lunch_button.setToolTip(tr('tooltip.meal_click')) self.dinner_button = QPushButton(tr('btn.dinner_add')) self.dinner_button.setCheckable(True) self.dinner_button.clicked.connect(self.toggle_dinner_break) self.dinner_button.setContextMenuPolicy(Qt.CustomContextMenu) self.dinner_button.customContextMenuRequested.connect( lambda pos: self._show_meal_context('dinner', self.dinner_button, pos) ) self.dinner_button.setToolTip(tr('tooltip.meal_click')) meal_button_layout.addWidget(self.lunch_button) meal_button_layout.addWidget(self.dinner_button) main_layout.addLayout(meal_button_layout) # 5-1. 외출 버튼 break_button_layout = QHBoxLayout() break_button_layout.setSpacing(8) self.break_out_button = QPushButton(tr('btn.break_out')) self.break_out_button.clicked.connect(self.break_out) self.break_in_button = QPushButton(tr('btn.break_in')) self.break_in_button.clicked.connect(self.break_in) self.break_in_button.setEnabled(False) self.break_manage_button = QPushButton(tr('btn.break_manage')) self.break_manage_button.clicked.connect(self.show_break_management) break_button_layout.addWidget(self.break_out_button) break_button_layout.addWidget(self.break_in_button) break_button_layout.addWidget(self.break_manage_button) main_layout.addLayout(break_button_layout) # 외출 상태 라벨 self.break_status_label = QLabel("") self.break_status_label.setObjectName("field_label") self.break_status_label.setAlignment(Qt.AlignCenter) main_layout.addWidget(self.break_status_label) # 6. 연장근무 적립 현황 overtime_group = self.create_overtime_group() main_layout.addWidget(overtime_group) central_widget.setLayout(main_layout) # 7. 퇴근 버튼 - 강조 스타일 (고정 하단) fixed_bottom = QWidget() fixed_bottom.setObjectName("fixed_bottom") fixed_bottom_layout = QVBoxLayout() fixed_bottom_layout.setSpacing(10) fixed_bottom_layout.setContentsMargins(24, 12, 24, 16) self.clock_out_button = QPushButton(tr('btn.clock_out')) self.clock_out_button.setObjectName("clock_out_button") self.clock_out_button.setCursor(Qt.PointingHandCursor) self.clock_out_button.clicked.connect(self.handle_clock_out_button) fixed_bottom_layout.addWidget(self.clock_out_button) # 8. 하단 버튼 bottom_layout = QHBoxLayout() bottom_layout.setSpacing(8) stats_button = QPushButton(tr('menu.stats')) calendar_button = QPushButton(tr('menu.calendar')) report_button = QPushButton(tr('menu.daily_report')) achievements_button = QPushButton(tr('btn.achievements')) help_button = QPushButton(tr('menu.help')) settings_button = QPushButton(tr('menu.settings')) # 런타임 i18n 등록 for btn, key in [(stats_button, 'menu.stats'), (calendar_button, 'menu.calendar'), (report_button, 'menu.daily_report'), (help_button, 'menu.help'), (settings_button, 'menu.settings')]: register(btn, key) # 하단 네비게이션 — 라인 아이콘 + 라벨 (이모지 대체) self._nav_icon_specs = [ (stats_button, 'chart'), (calendar_button, 'calendar'), (report_button, 'report'), (achievements_button, 'award'), (help_button, 'help'), (settings_button, 'settings'), ] for btn, _name in self._nav_icon_specs: btn.setObjectName("nav_btn") bottom_layout.addWidget(btn) # 버튼 연결 stats_button.clicked.connect(self.show_stats) calendar_button.clicked.connect(self.show_calendar) report_button.clicked.connect(self.generate_daily_report) achievements_button.clicked.connect(self.show_achievements) help_button.clicked.connect(self.show_help) settings_button.clicked.connect(self.show_settings) fixed_bottom_layout.addLayout(bottom_layout) fixed_bottom.setLayout(fixed_bottom_layout) outer_layout.addWidget(fixed_bottom, 0) # 초기 날짜 업데이트 self.update_date_label() # 라인 아이콘 적용 (테마 색 틴팅) self._apply_button_icons() # 앱 내 단축키 self._setup_shortcuts() def _apply_button_icons(self): """버튼 아이콘을 현재 테마 색으로 (재)적용. 테마 전환 시에도 호출돼 재틴팅.""" from ui.icons import get_icon sec = ThemeColors.get('text_secondary') inv = ThemeColors.get('text_inverse') for btn, name in getattr(self, '_nav_icon_specs', []): btn.setIcon(get_icon(name, sec, 16)) btn.setIconSize(QSize(16, 16)) if getattr(self, 'edit_clock_in_button', None) is not None: self.edit_clock_in_button.setIcon(get_icon('edit', sec, 15)) self.edit_clock_in_button.setIconSize(QSize(15, 15)) if getattr(self, 'clock_out_button', None) is not None: self.clock_out_button.setIcon(get_icon('logout', inv, 18)) self.clock_out_button.setIconSize(QSize(18, 18)) def _setup_shortcuts(self): """앱 내 단축키 — 메인 창 포커스 시만 동작""" bindings = [ ("Ctrl+O", self.handle_clock_out_button), # 출/퇴근 토글 ("Ctrl+L", lambda: self.lunch_button.click()), # 점심 ("Ctrl+D", lambda: self.dinner_button.click()), # 저녁 ("Ctrl+B", self.show_break_management), # 외출 관리 ("Ctrl+,", self.show_settings), # 설정 ("F1", self.show_help), # 도움말 ("F5", lambda: self.check_for_updates(silent=False)), # 업데이트 확인 ("Ctrl+R", self.generate_daily_report), # 일일보고 ] for keyseq, handler in bindings: sc = QShortcut(QKeySequence(keyseq), self) sc.activated.connect(handler) def _build_time_column(self, label_text: str, value_widget: QLabel) -> QVBoxLayout: """라벨(작게) 위 + 시각(크게) 아래 형태의 세로 컬럼. 한 줄 나란히 배치용.""" col = QVBoxLayout() col.setSpacing(2) lbl = QLabel(label_text) lbl.setObjectName("field_label") col.addWidget(lbl) col.addWidget(value_widget) return col def create_clock_in_group(self) -> QGroupBox: """출근 정보 그룹 생성 — 출근/현재 시각을 한 줄에 나란히""" group = QGroupBox(tr('group.today_work')) layout = QVBoxLayout() layout.setSpacing(8) layout.setContentsMargins(16, 24, 16, 16) # 출근 / 현재 시각을 한 줄에 나란히 (2-컬럼) row = QHBoxLayout() row.setSpacing(12) # 출근 컬럼 (라벨 + 편집 버튼 헤더 / 값) self.clock_in_value = QLabel("--:--:--") self.clock_in_value.setObjectName("time_value") # 라벨 자체도 클릭 가능 (인라인 편집 — 출근 시간 빠른 수정) self.clock_in_value.setCursor(Qt.PointingHandCursor) self.clock_in_value.setToolTip(tr('tooltip.clock_in_edit')) self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in() clock_in_col = QVBoxLayout() clock_in_col.setSpacing(2) clock_in_label = QLabel(tr('label.clock_in_time')) clock_in_label.setObjectName("field_label") clock_in_col.addWidget(clock_in_label) # 시각 + 편집 버튼을 한 줄에 (편집 아이콘이 출근 시각 바로 옆에 붙도록) clock_in_value_row = QHBoxLayout() clock_in_value_row.setSpacing(6) self.edit_clock_in_button = QPushButton("") self.edit_clock_in_button.setObjectName("btn_small") self.edit_clock_in_button.setFixedWidth(30) self.edit_clock_in_button.setToolTip(tr('tooltip.clock_in_edit')) self.edit_clock_in_button.clicked.connect(self.manual_clock_in) clock_in_value_row.addWidget(self.clock_in_value) clock_in_value_row.addWidget(self.edit_clock_in_button) clock_in_value_row.addStretch() clock_in_col.addLayout(clock_in_value_row) # 현재 컬럼 self.current_time_value = QLabel("--:--:--") self.current_time_value.setObjectName("time_value") current_col = self._build_time_column(tr('label.current_time'), self.current_time_value) row.addLayout(clock_in_col, 1) row.addLayout(current_col, 1) layout.addLayout(row) group.setLayout(layout) return group def create_remaining_time_group(self) -> QGroupBox: """남은 시간 히어로 그룹 — 남은시간(가장 큼) + 진행률 + 예상 퇴근시각""" self.remaining_time_group = QGroupBox(tr('group.remaining_time')) layout = QVBoxLayout() layout.setSpacing(12) layout.setContentsMargins(16, 24, 16, 16) # 남은 시간 라벨 (히어로 — 화면에서 가장 큰 결과) self.remaining_time_label = QLabel("--:--:--") self.remaining_time_label.setObjectName("time_display") self.remaining_time_label.setAlignment(Qt.AlignCenter) # 프로그레스 바 (얇게 6px) self.progress_bar = QProgressBar() self.progress_bar.setTextVisible(False) self.progress_bar.setFixedHeight(6) # 예상 퇴근시각 (히어로 카드 내부에 통합) self.expected_time_label = QLabel() self.expected_time_label.setObjectName("expected_time") self.expected_time_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.remaining_time_label) layout.addWidget(self.progress_bar) layout.addWidget(self.expected_time_label) self.remaining_time_group.setLayout(layout) return self.remaining_time_group def create_overtime_group(self) -> QGroupBox: """연장근무 및 연차 현황 그룹 생성""" group = QGroupBox(tr('group.overtime_leave')) layout = QVBoxLayout() layout.setSpacing(10) layout.setContentsMargins(16, 24, 16, 16) # 연장근무 섹션 overtime_header = QHBoxLayout() overtime_title = QLabel(tr('section.overtime_earned')) overtime_title.setObjectName("section_title") overtime_header.addWidget(overtime_title) overtime_header.addStretch() self.overtime_balance_label = QLabel(tr('label.overtime_balance_zero')) self.overtime_balance_label.setObjectName("badge_overtime") overtime_header.addWidget(self.overtime_balance_label) layout.addLayout(overtime_header) # 연장근무 사용 버튼 (1줄) overtime_button_layout = QHBoxLayout() overtime_button_layout.setSpacing(4) use_30min_button = QPushButton(tr('btn.use_30min')) use_1hour_button = QPushButton(tr('btn.use_1hour')) use_2hour_button = QPushButton(tr('btn.use_2hour')) use_custom_overtime_button = QPushButton(tr('btn.custom_input')) overtime_detail_button = QPushButton(tr('btn.detail')) for btn in [use_30min_button, use_1hour_button, use_2hour_button, use_custom_overtime_button, overtime_detail_button]: btn.setObjectName("btn_small") overtime_button_layout.addWidget(btn) use_30min_button.clicked.connect(lambda: self.use_overtime(30)) use_1hour_button.clicked.connect(lambda: self.use_overtime(60)) use_2hour_button.clicked.connect(lambda: self.use_overtime(120)) use_custom_overtime_button.clicked.connect(self.use_custom_overtime) overtime_detail_button.clicked.connect(self.show_overtime_detail) layout.addLayout(overtime_button_layout) # 구분선 separator = QLabel() separator.setObjectName("separator") layout.addWidget(separator) # 연차 섹션 leave_header = QHBoxLayout() leave_title = QLabel(tr('section.leave')) leave_title.setObjectName("section_title") leave_header.addWidget(leave_title) leave_header.addStretch() self.leave_balance_label = QLabel(tr('label.leave_balance_zero')) self.leave_balance_label.setObjectName("badge_leave") leave_header.addWidget(self.leave_balance_label) layout.addLayout(leave_header) # 연차 사용 버튼 (1줄) leave_button_layout = QHBoxLayout() leave_button_layout.setSpacing(4) use_30min_leave_button = QPushButton(tr('btn.use_30min')) use_1hour_leave_button = QPushButton(tr('btn.use_1hour')) use_half_leave_button = QPushButton(tr('btn.half_leave')) use_full_leave_button = QPushButton(tr('btn.full_leave')) leave_detail_button = QPushButton(tr('btn.detail')) for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button, use_full_leave_button, leave_detail_button]: btn.setObjectName("btn_small") leave_button_layout.addWidget(btn) use_30min_leave_button.clicked.connect(lambda: self.use_leave(0.5/8)) # 0.0625일 use_1hour_leave_button.clicked.connect(lambda: self.use_leave(1.0/8)) # 0.125일 use_half_leave_button.clicked.connect(lambda: self.use_leave(0.5)) use_full_leave_button.clicked.connect(lambda: self.use_leave(1.0)) leave_detail_button.clicked.connect(self.show_leave_detail) layout.addLayout(leave_button_layout) # 구분선 separator2 = QLabel() separator2.setObjectName("separator") layout.addWidget(separator2) # 총합 시간 표시 total_header = QHBoxLayout() total_title = QLabel(tr('section.total_time')) total_title.setObjectName("section_title") total_header.addWidget(total_title) total_header.addStretch() self.total_time_label = QLabel(tr('label.time_hours_minutes', hours=0, minutes=0)) self.total_time_label.setObjectName("badge_total") total_header.addWidget(self.total_time_label) layout.addLayout(total_header) group.setLayout(layout) return group def load_today_data(self): """오늘 데이터 로드""" # 먼저 이전 퇴근 기록들 자동 처리 self.auto_clock_out_previous_days() today_record = self.db.get_today_record() if today_record and today_record.get('clock_in'): # 이미 출근 기록이 있음 clock_in_str = today_record['clock_in'] self.clock_in_time = datetime.strptime( f"{datetime.now().date()} {clock_in_str}", "%Y-%m-%d %H:%M:%S" ) self.lunch_break_enabled = bool(today_record.get('lunch_break', False)) self.dinner_break_enabled = bool(today_record.get('dinner_break', False)) self.is_clocked_in = True self.midnight_rollover_handled = False # 새로운 근무일 시작 시 플래그 리셋 # 점심이 이미 적용되어 있으면 auto_lunch가 다시 트리거되지 않도록 self.auto_lunch_applied_today = self.lunch_break_enabled # 퇴근했는지 확인 if today_record.get('clock_out'): self.is_clocked_in = False self.clock_out_button.setEnabled(True) self.clock_out_button.setText(tr('btn.clock_out_cancel')) # 퇴근 완료 상태에서도 출퇴근 시간은 표시 self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True)) else: # 출근 중이면 퇴근하기 버튼 self.clock_out_button.setEnabled(True) self.clock_out_button.setText(tr('btn.clock_out')) 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() if auto_clock_in: # 자동 감지 성공 self.clock_in_time = auto_clock_in self.is_clocked_in = True self.midnight_rollover_handled = False # 새로운 근무일 시작 시 플래그 리셋 # DB에 저장 today = datetime.now().date().isoformat() clock_in_str = auto_clock_in.strftime("%H:%M:%S") self.db.add_work_record(today, clock_in_str, is_manual=False) QMessageBox.information( self, tr('msg.auto_clock_in.title'), tr('msg.auto_clock_in.body', time=clock_in_str) ) else: # 자동 감지 실패 - 수동 입력 요청 reply = QMessageBox.question( self, tr('msg.manual_clock_in.title'), tr('msg.manual_clock_in.body'), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self.manual_clock_in() else: self.is_clocked_in = False self.clock_out_button.setEnabled(False) # 점심 버튼 상태 업데이트 self.lunch_button.setChecked(self.lunch_break_enabled) self.update_lunch_status() # 저녁 버튼 상태 업데이트 self.dinner_button.setChecked(self.dinner_break_enabled) self.update_dinner_status() # 외출 상태 업데이트 self.update_break_status() # 연장근무 및 연차 잔액 업데이트 self.update_overtime_balance() self.update_leave_balance() def update_display(self): """디스플레이 업데이트 (1초마다)""" now = datetime.now() # 현재 시간은 항상 업데이트 (출근 전에도 표시) self._set_text_if_changed(self.current_time_value, self.format_time(now, include_seconds=True)) # 근무일 경계 시간 확인 (캐시, 설정 변경 시 reload_settings에서 무효화) if self._workday_boundary_hour_cache is None: self._workday_boundary_hour_cache = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) workday_boundary_hour = self._workday_boundary_hour_cache # 새 근무일 체크: 퇴근 완료 상태에서 날짜가 바뀌고 경계 시간 이후면 새 출근 유도 if not self.is_clocked_in and self.clock_in_time: # 이전 출근 기록이 있고, 날짜가 바뀌었고, 경계 시간 이후면 if self.clock_in_time.date() != now.date() and now.hour >= workday_boundary_hour: self.start_new_workday(now) return # 종일 연차일 — 출근 안 한 상태에서 전용 카드만 표시 후 종료. # (수동 출근 override는 handle_clock_in 경로에서 별도 처리) if not self.is_clocked_in: today_str = now.date().isoformat() if self._full_day_leave_cache_date != now.date(): self._full_day_leave_cache_value = self.db.has_full_day_leave(today_str) self._full_day_leave_cache_date = now.date() if self._full_day_leave_cache_value: self._render_full_day_leave_state(today_str) return # 출근하지 않았으면 여기서 종료 if not self.is_clocked_in or not self.clock_in_time: return # 근무일 경계 체크: 출근일과 현재 날짜가 다르고, 경계 시간(기본 6시) 이후면 롤오버 # 예: 야근으로 새벽 2시까지 일해도 6시 전까지는 전날 근무로 인정 if self.clock_in_time.date() != now.date() and now.hour >= workday_boundary_hour: self.handle_workday_rollover(now) # 출근 시간 업데이트 (설정 변경 시에도 갱신됨) self._set_text_if_changed(self.clock_in_value, self.format_time(self.clock_in_time, include_seconds=True)) # 자동 점심시간 적용 (설정 + 출근 후 4시간 경과 + 미적용 + 1회만) self._auto_lunch.maybe_apply(now) # 외출 시간 계산 break_minutes = self.db.get_total_break_minutes_today() # 오늘 사용한 추가근무 시간 계산 overtime_used_today = self.db.get_today_overtime_usage() # 오늘 사용한 연차/반차 시간 계산 leave_used_today = self.db.get_today_leave_minutes() # 총 차감 시간 (추가근무 + 연차/반차) total_time_off = overtime_used_today + leave_used_today # 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름. if self._non_working_cache_date != now.date(): self._non_working_cache_value = self.time_calc.is_non_working_day(now, self.db) self._non_working_cache_date = now.date() is_non_working = self._non_working_cache_value if self._full_day_leave_cache_date != now.date(): self._full_day_leave_cache_value = self.db.has_full_day_leave(now.date().isoformat()) self._full_day_leave_cache_date = now.date() is_full_day_leave = self._full_day_leave_cache_value is_holiday = is_non_working or is_full_day_leave if is_holiday: # 표시는 초 단위로 부드럽게 — 적립(분 절삭)은 퇴근 시 별도 계산. # calculate_holiday_overtime와 동일한 차감 항목을 timedelta로 적용. deduction_min = break_minutes + overtime_used_today if self.lunch_break_enabled: deduction_min += self.time_calc.lunch_duration_minutes if self.dinner_break_enabled: deduction_min += self.time_calc.dinner_duration_minutes worked = (now - self.clock_in_time) - timedelta(minutes=deduction_min) if worked.total_seconds() < 0: worked = timedelta(0) remaining = -worked else: # 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 leave_used_today에 # 그대로 반영되어 카운트다운이 단축됨. remaining = self.time_calc.calculate_remaining_time( self.clock_in_time, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, current_time=now, break_minutes=break_minutes ) # 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능) remaining -= timedelta(minutes=total_time_off) # 남은 시간 표시 및 추가 근무 처리 if remaining.total_seconds() < 0: # 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기) day_type = self.time_calc.get_day_type(now, self.db) if is_full_day_leave and not is_non_working: self.remaining_time_group.setTitle(tr('label.full_day_leave_override')) elif day_type == 'weekend': self.remaining_time_group.setTitle(tr('label.weekend_work')) elif day_type == 'holiday': self.remaining_time_group.setTitle(tr('label.holiday_work')) else: self.remaining_time_group.setTitle(tr('label.overtime_progress')) # + 기호로 표시 total_seconds = int(abs(remaining.total_seconds())) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 seconds = total_seconds % 60 remaining_str = f"+{hours:02d}:{minutes:02d}:{seconds:02d}" # 퇴근 가능(연장근무 진입) → 그린 피드백 self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};") else: # 정상 근무 중 — 아직 퇴근 전이므로 기본 텍스트 색 self.remaining_time_group.setTitle(tr('group.remaining_time')) remaining_str = self.time_calc.format_time_delta(remaining) self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('text_primary')};") self._set_text_if_changed(self.remaining_time_label, remaining_str) # 진행률 업데이트 # 휴일은 정해진 근무시간이 없으므로 게이지 의미 없음 → 100%로 채워둠. if is_holiday: self.progress_bar.setValue(100) else: progress = self.time_calc.calculate_work_progress( self.clock_in_time, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, current_time=now, break_minutes=break_minutes, overtime_used_minutes=total_time_off ) self.progress_bar.setValue(int(progress * 100)) # 예상 퇴근 시간 (외출 시간 포함) # 휴일은 정해진 퇴근 시각이 없음 → 출근 시각을 그대로 표시 (= 즉시 적립 시작 의미) if is_holiday: expected_clock_out = self.clock_in_time self._set_text_if_changed( self.expected_time_label, tr('label.holiday_work_no_clock_out') ) else: expected_clock_out = self.time_calc.calculate_clock_out_time( self.clock_in_time, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, break_minutes=break_minutes ) # 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김 expected_clock_out -= timedelta(minutes=total_time_off) self._set_text_if_changed( self.expected_time_label, f"{tr('label.expected_clock_out_prefix')}{self.format_time(expected_clock_out)}" ) # 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함) # 휴일이면 "퇴근 30분 전" 알림은 의미 없으므로 플래그로 게이팅. self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds(), is_holiday=is_holiday) # 트레이 / 미니 위젯 갱신 if remaining.total_seconds() < 0: display_str = f"+{abs(int(remaining.total_seconds() // 3600)):02d}:{abs(int((remaining.total_seconds() % 3600) // 60)):02d}" else: display_str = self.time_calc.format_time_delta(remaining) self.tray_icon.update_time_display(display_str) if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible(): self._mini_widget.update_remaining(remaining_str) def update_date_label(self): """날짜 라벨 업데이트""" now = datetime.now() weekday_keys = ['label.weekday_mon', 'label.weekday_tue', 'label.weekday_wed', 'label.weekday_thu', 'label.weekday_fri', 'label.weekday_sat', 'label.weekday_sun'] weekday = tr(weekday_keys[now.weekday()]) self.date_label.setText(tr('date_format.full', year=now.year, month=now.month, day=now.day, weekday=weekday)) 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 tr('leave.type.annual') memo = primary.get('memo') or '' else: label = tr('leave.type.annual') memo = '' self.remaining_time_group.setTitle(tr('label.full_day_leave_today')) self.remaining_time_label.setText(tr('label.full_day_leave_in_use')) 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, tr('label.full_day_leave_format', type=label, memo=memo)) else: self._set_text_if_changed(self.expected_time_label, label) # 트레이/미니 위젯 self.tray_icon.update_time_display(tr('label.vacation')) if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible(): self._mini_widget.update_remaining(tr('label.vacation')) def toggle_lunch_break(self): """점심시간 토글 — MealController 위임.""" self._meal.toggle_lunch() def toggle_dinner_break(self): """저녁시간 토글 — MealController 위임.""" self._meal.toggle_dinner() def update_lunch_status(self): """점심시간 상태 업데이트 — MealController 위임.""" self._meal.refresh_lunch_label() def update_dinner_status(self): """저녁시간 상태 업데이트 — MealController 위임.""" self._meal.refresh_dinner_label() def update_overtime_balance(self): """연장근무 잔액 업데이트""" balance_minutes = self.db.get_total_overtime_balance() tokens, time_str = self.time_calc.format_overtime_tokens(balance_minutes) hours = balance_minutes // 60 mins = balance_minutes % 60 self.overtime_balance_label.setText(tr('label.time_hours_minutes', hours=hours, minutes=mins)) self.update_total_time() def update_leave_balance(self): """연차 잔액 업데이트""" balance = self.db.get_leave_balance() balance_hours = int(balance * 8) balance_mins = int((balance * 8 % 1) * 60) self.leave_balance_label.setText(tr('label.time_hours_minutes', hours=balance_hours, minutes=balance_mins)) self.update_total_time() def update_total_time(self): """연차 + 연장근무 총합 시간 업데이트""" # 연장근무 시간 (분) overtime_minutes = self.db.get_total_overtime_balance() # 연차 시간 (일 -> 분으로 변환, 1일 = 8시간 = 480분) leave_balance = self.db.get_leave_balance() leave_minutes = int(leave_balance * 480) # 총합 (분) total_minutes = overtime_minutes + leave_minutes total_hours = total_minutes // 60 total_mins = total_minutes % 60 self.total_time_label.setText(tr('label.time_hours_minutes', hours=total_hours, minutes=total_mins)) def use_overtime(self, minutes: int): """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" balance = self.db.get_total_overtime_balance() new_balance = balance - minutes # 음수가 되는 경우 추가 경고 if new_balance < 0: reply = QMessageBox.warning( self, tr('msg.overtime_use_minus.title'), tr('msg.overtime_use_minus.body', minutes=minutes, balance=balance, new_balance=new_balance), QMessageBox.Yes | QMessageBox.No ) else: reply = QMessageBox.question( self, tr('msg.overtime_use.title'), tr('msg.overtime_use.body', minutes=minutes, balance=balance, new_balance=new_balance), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: try: # 오늘 날짜 from datetime import date today = date.today().isoformat() # 추가근무 사용 기록 추가 (work_record_id는 NULL로 - 직접 사용) self.db.add_overtime_usage( work_record_id=None, used_minutes=minutes, date=today, reason="직접 사용" ) QMessageBox.information( self, tr('msg.overtime_use_done.title'), tr('msg.overtime_use_done.body', minutes=minutes) ) self.update_overtime_balance() except Exception as e: QMessageBox.warning( self, tr('msg.overtime_use_fail.title'), str(e) ) self.update_overtime_balance() def show_overtime_detail(self): """연장근무 상세 내역 보기""" from ui.overtime_view import OvertimeView dialog = OvertimeView(self, self.db) dialog.exec_() # 다이얼로그 종료 후 잔액 업데이트 self.update_overtime_balance() def use_leave(self, days: float): """연차 사용""" balance = self.db.get_leave_balance() if balance < days: QMessageBox.warning( self, tr('msg.leave_short.title'), tr('msg.leave_short.body', balance=balance, days=days) ) return # 사용 날짜 입력 from PyQt5.QtWidgets import QInputDialog, QLineEdit from datetime import date today = date.today().isoformat() date_str, ok = QInputDialog.getText( self, tr('msg.leave_use_date.title'), tr('msg.leave_use_date.body'), QLineEdit.Normal, today ) if not ok or not date_str: return # 날짜 형식 검증 try: datetime.strptime(date_str, "%Y-%m-%d") except ValueError: QMessageBox.warning( self, tr('msg.input_error.title'), tr('msg.input_error.date_format') ) return # 메모 입력 memo, ok = QInputDialog.getText( self, tr('msg.leave_use_reason.title'), tr('msg.leave_use_reason.body'), QLineEdit.Normal, "" ) if not ok: return # 사용 확인 if days == 1.0: leave_type = tr('leave.type.annual') days_str = tr('leave.use.full_day') elif days == 0.5: leave_type = tr('view.leave.type_half') days_str = tr('leave.use.half_day') elif days == 0.125: leave_type = tr('leave.type.hourly_leave') days_str = tr('leave.use.hour_1') elif days == 0.0625: leave_type = tr('leave.type.hourly_leave') days_str = tr('leave.use.min_30') else: leave_type = tr('leave.type.annual') hours = days * 8 days_str = tr('leave.use.custom', days=days, hours=hours) reply = QMessageBox.question( self, tr('msg.leave_use.title'), tr('msg.leave_use_confirm.body', date=date_str, type=leave_type, days=days_str, balance_after=balance - days), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: try: self.db.use_leave(days, date_str, leave_type, memo or None) QMessageBox.information( self, tr('msg.leave_use_done.title'), tr('msg.leave_use_done.body', type=leave_type) ) self.update_leave_balance() except ValueError as e: # 잔액 부족 등 검증 오류 QMessageBox.warning( self, tr('msg.leave_use_impossible.title'), str(e) ) self.update_leave_balance() # 최신 잔액으로 새로고침 except Exception as e: # 기타 데이터베이스 오류 QMessageBox.critical( self, tr('msg.error.title'), tr('msg.error.body', action=tr('msg.leave_use.title'), error=str(e)) ) def use_custom_overtime(self): """사용자 정의 추가근무 사용""" from PyQt5.QtWidgets import QInputDialog balance = self.db.get_total_overtime_balance() # 사용할 시간 입력 (30분 단위) hours, ok = QInputDialog.getDouble( self, tr('msg.input.title'), tr('msg.overtime_input.body'), 0.5, 0.5, 24.0, 1 ) if not ok: return # 시간을 분으로 변환 minutes = int(hours * 60) # 30분 단위 검증 if minutes % 30 != 0: QMessageBox.warning( self, tr('msg.input_error.title'), tr('msg.input_error.overtime_unit') ) return # use_overtime 메서드 호출 (내부에서 잔액 검증 수행) self.use_overtime(minutes) def use_custom_leave(self): """사용자 정의 연차 사용""" from PyQt5.QtWidgets import QInputDialog balance = self.db.get_leave_balance() # 사용할 시간 입력 (시간 단위) hours, ok = QInputDialog.getDouble( self, tr('msg.input.title'), tr('msg.leave_input.body'), 0.5, 0.5, 80.0, 1 ) if not ok: return # 시간을 일수로 변환 (8시간 = 1일) days = hours / 8.0 if days > balance: QMessageBox.warning( self, tr('msg.leave_short.title'), tr('msg.leave_short_hours.body', balance=balance, balance_hours=int(balance * 8), days=days, hours=hours) ) return # use_leave 메서드 호출 self.use_leave(days) def show_leave_detail(self): """연차 상세 내역 보기""" from ui.leave_view import LeaveView dialog = LeaveView(self, self.db) dialog.exec_() # 다이얼로그 종료 후 잔액 업데이트 self.update_leave_balance() def handle_clock_out_button(self): """퇴근 버튼 클릭 핸들러 - 상태에 따라 퇴근 또는 취소""" if self.is_clocked_in: # 출근 중 -> 퇴근 처리 self.clock_out() else: # 퇴근 완료 -> 퇴근 취소 self.cancel_clock_out() def clock_out(self): """퇴근 처리""" if not self.is_clocked_in: return now = datetime.now() # 확인 메시지 reply = QMessageBox.question( self, tr('msg.clock_out_confirm.title'), tr('msg.clock_out_confirm.body', time=now.strftime('%H:%M:%S')), QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: # 취소 시 버튼 다시 활성화 self.clock_out_button.setEnabled(True) return # 총 근무시간 계산 total_hours = self.time_calc.calculate_total_work_time( self.clock_in_time, now ) # 주말/공휴일 체크 is_non_working_day = self.time_calc.is_non_working_day(now, self.db) day_type = self.time_calc.get_day_type(now, self.db) # 오늘의 외출 시간 가져오기 break_minutes = self.db.get_total_break_minutes_today() # 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60. unit_minutes = self.db.get_setting_int('overtime_unit', 30) if unit_minutes not in (15, 30, 60): unit_minutes = 30 if is_non_working_day: # 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 시간 제외) overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime( self.clock_in_time, now, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) else: # 평일: 정상 연장근무 계산 (외출 시간 포함) overtime_actual, overtime_earned = self.time_calc.calculate_overtime( self.clock_in_time, now, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) # AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인 auto_overtime = self.db.get_setting_bool('auto_overtime', True) if not auto_overtime and overtime_earned > 0: time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True) actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True) ask = QMessageBox.question( self, tr('msg.auto_overtime_confirm.title'), tr('msg.auto_overtime_confirm.body', actual=actual_str, earned=time_str), QMessageBox.Yes | QMessageBox.No, ) if ask != QMessageBox.Yes: overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지) # DB 업데이트 today = datetime.now().date().isoformat() clock_out_str = now.strftime("%H:%M:%S") self.db.update_clock_out( today, clock_out_str, total_hours, overtime_actual, overtime_earned ) # 연장근무 적립 기록 if overtime_earned > 0: today_record = self.db.get_today_record() if today_record: self.db.add_overtime_earned( today_record['id'], overtime_earned, today ) # 상태 업데이트 self.is_clocked_in = False self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋 self.clock_out_button.setEnabled(True) self.clock_out_button.setText(tr('btn.clock_out_cancel')) # 결과 메시지 if day_type == 'weekend': type_info = tr('label.weekend_work_tag') + '\n' elif day_type == 'holiday': holiday_info = self.db.get_holiday(today) holiday_name = holiday_info['name'] if holiday_info else tr('label.holiday_default') type_info = tr('label.holiday_work_tag', name=holiday_name) + '\n' else: type_info = '' overtime_info = '' if overtime_earned > 0: tokens, time_str = self.time_calc.format_overtime_tokens(overtime_earned) if is_non_working_day: overtime_info = tr('label.full_earned_msg', time=time_str, tokens=tokens) else: overtime_info = tr('label.overtime_earned_msg', time=time_str, tokens=tokens) msg = tr('msg.clock_out_done.body', type_info=type_info, total_work=tr('label.total_work_hours', hours=total_hours), overtime_info=overtime_info) QMessageBox.information(self, tr('msg.clock_out_done.title'), msg) # 잔액 업데이트 self.update_overtime_balance() # Discord 웹훅 push (옵션) self._discord_push_clock_out(now, total_hours, overtime_actual, overtime_earned) # 오늘 요약 카드 표시 self._show_today_summary(total_hours, overtime_actual, overtime_earned, break_minutes) def cancel_clock_out(self): """퇴근 취소""" # 확인 대화상자 reply = QMessageBox.question( self, tr('btn.clock_out_cancel'), tr('msg.cancel_clock_out_confirm.body'), QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return try: # DB에서 퇴근 취소 today = datetime.now().date().isoformat() success = self.db.cancel_clock_out(today) if success: # 상태 복원 self.is_clocked_in = True self.clock_out_button.setEnabled(True) self.clock_out_button.setText(tr('btn.clock_out')) # 잔액 업데이트 self.update_overtime_balance() QMessageBox.information( self, tr('msg.cancel_clock_out_done.title'), tr('msg.cancel_clock_out_done.body') ) else: QMessageBox.warning( self, tr('msg.cancel_clock_out_fail.title'), tr('msg.cancel_clock_out_fail.body') ) except Exception as e: QMessageBox.critical( self, tr('msg.error.title'), tr('msg.error.body', action=tr('btn.clock_out_cancel'), error=str(e)) ) def _apply_auto_overtime_gate(self, overtime_earned: int) -> int: """자동 적립(auto_overtime) 설정을 존중해 적립분을 게이팅. OFF면 0을 반환해 은행 적립(add_overtime_earned)을 건너뛰게 한다. clock_out()은 대화상자로 직접 확인하지만, 자동 퇴근 경로(롤오버 / 이전일 자동 처리)는 사용자 상호작용 시점이 없으므로 설정만으로 동일하게 게이팅한다. 실제 연장(work_records.overtime_minutes)은 그대로 기록되고 적립만 스킵된다. """ if overtime_earned > 0 and not self.db.get_setting_bool('auto_overtime', True): return 0 return overtime_earned def handle_workday_rollover(self, now: datetime): """근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근 예: 경계시간이 6시인 경우 - 전날 근무 → 05:59:59 퇴근 처리 (자정~6시 전까지 초과근무로 인정) - 당일 근무 → 06:00:00 출근 처리 """ if not self.is_clocked_in or not self.clock_in_time: return # 이미 처리되었으면 중복 실행 방지 if self.midnight_rollover_handled: return # 근무일 경계 시간 가져오기 workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) boundary_time_str = f"{workday_boundary_hour:02d}:00:00" before_boundary_str = f"{workday_boundary_hour - 1:02d}:59:59" if workday_boundary_hour > 0 else "23:59:59" # 전날 기록은 출근일 날짜로 저장 workday_str = self.clock_in_time.date().isoformat() # 퇴근 시간: 오늘 경계시간 직전 (예: 05:59:59) workday_end = datetime.combine( now.date(), datetime.strptime(before_boundary_str, "%H:%M:%S").time() ) # 외출 중이라면 자동으로 복귀 처리 (출근일 날짜로 조회) active_break = self.db.get_active_break_record(target_date=workday_str) if active_break: self.db.update_break_return(active_break['id'], before_boundary_str) # 총 근무시간 계산 (출근 ~ 경계시간 직전) total_hours = self.time_calc.calculate_total_work_time( self.clock_in_time, workday_end ) # 주말/공휴일 체크 is_non_working_day = self.time_calc.is_non_working_day(self.clock_in_time, self.db) # 외출 시간 가져오기 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT SUM(total_minutes) FROM break_records WHERE date = ? ''', (workday_str,)) break_minutes = cursor.fetchone()[0] or 0 conn.close() # 추가근무 계산 (사용자 설정 적립 단위 적용) unit_minutes = self.db.get_setting_int('overtime_unit', 30) if unit_minutes not in (15, 30, 60): unit_minutes = 30 if is_non_working_day: overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime( self.clock_in_time, workday_end, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) else: overtime_actual, overtime_earned = self.time_calc.calculate_overtime( self.clock_in_time, workday_end, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미) overtime_earned = self._apply_auto_overtime_gate(overtime_earned) # DB 업데이트 (출근일 날짜에 퇴근 시간 기록) self.db.update_clock_out( workday_str, before_boundary_str, total_hours, overtime_actual, overtime_earned ) # 연장근무 적립 if overtime_earned > 0: workday_record = self.db.get_work_record(workday_str) if workday_record: self.db.add_overtime_earned( workday_record['id'], overtime_earned, workday_str ) # 오늘 경계시간에 출근 처리 (예: 06:00:00) today_str = now.date().isoformat() self.db.add_work_record(today_str, boundary_time_str, lunch_break=False, is_manual=False) # 상태 업데이트 self.clock_in_time = datetime.combine( now.date(), datetime.strptime(boundary_time_str, "%H:%M:%S").time() ) # 외출 중이었다면 오늘도 외출 시작 if self.is_on_break: today_record = self.db.get_today_record() if today_record: self.db.add_break_record( today_record['id'], today_str, boundary_time_str, None ) # 근무일 경계 처리 완료 플래그 설정 self.midnight_rollover_handled = True QMessageBox.information( self, tr('msg.workday_boundary.title'), tr('msg.workday_boundary.body', hour=workday_boundary_hour, before=before_boundary_str, boundary=boundary_time_str) ) # 화면 업데이트 self.load_today_data() self.update_overtime_balance() def start_new_workday(self, now: datetime): """새 근무일 시작 (퇴근 완료 상태에서 날짜가 바뀌고 경계 시간 이후)""" workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) # 오늘 이미 출근 기록이 있는지 확인 today_str = now.date().isoformat() today_record = self.db.get_work_record(today_str) if today_record: # 이미 오늘 기록이 있으면 그것을 로드 self.load_today_data() return # 새 근무일 알림 및 출근 처리 reply = QMessageBox.question( self, tr('msg.new_workday.title'), tr('msg.new_workday.body', date=today_str), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # 상태 초기화 self.clock_in_time = None self.is_clocked_in = False self.lunch_break_enabled = False self.dinner_break_enabled = False self.midnight_rollover_handled = False self.auto_lunch_applied_today = False # 새 출근 처리 (load_today_data가 자동 감지 또는 수동 입력 처리) self.load_today_data() else: # 거부하면 상태만 초기화하고 대기 self.clock_in_time = None self.is_clocked_in = False self.clock_out_button.setEnabled(False) self.clock_out_button.setText(tr('btn.clock_out')) def _get_break_minutes_for_date(self, date_str: str) -> int: """특정 날짜의 총 외출 시간(분)을 안전하게 조회.""" try: with self.db._conn() as conn: cursor = conn.cursor() cursor.execute(''' SELECT COALESCE(SUM(total_minutes), 0) FROM break_records WHERE date = ? ''', (date_str,)) return int(cursor.fetchone()[0] or 0) except Exception as e: dlog(f"_get_break_minutes_for_date failed for {date_str}: {e}") return 0 def auto_clock_out_previous_days(self): """이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록""" from datetime import timedelta # 최근 30일간의 기록 중 퇴근하지 않은 모든 기록 처리 today = datetime.now().date() for days_ago in range(1, 31): # 1일 전부터 30일 전까지 확인 check_date = (today - timedelta(days=days_ago)).isoformat() try: record = self.db.get_work_record(check_date) except Exception as e: dlog(f"auto_clock_out: get_work_record failed for {check_date}: {e}") continue # 출근은 했지만 퇴근을 안 한 기록 발견 if not (record and record.get('clock_in') and not record.get('clock_out')): continue try: # 해당 날짜의 종료 시간 감지 check_date_obj = today - timedelta(days=days_ago) shutdown_time = self.event_monitor.get_shutdown_time_by_date(check_date_obj) if shutdown_time: # 출근 시간 파싱 clock_in_str = record['clock_in'] clock_in_time = datetime.strptime( f"{check_date} {clock_in_str}", "%Y-%m-%d %H:%M:%S" ) # 주말/공휴일 체크 is_non_working_day = self.time_calc.is_non_working_day(clock_in_time, self.db) day_type = self.time_calc.get_day_type(clock_in_time, self.db) # 외출 시간 가져오기 break_minutes = self._get_break_minutes_for_date(check_date) # 총 근무시간 계산 (원본 시간) work_duration = shutdown_time - clock_in_time total_hours = work_duration.total_seconds() / 3600 # 점심시간/저녁시간 차감 여부 lunch_enabled = bool(record.get('lunch_break', False)) dinner_enabled = bool(record.get('dinner_break', False)) unit_minutes = self.db.get_setting_int('overtime_unit', 30) if unit_minutes not in (15, 30, 60): unit_minutes = 30 if is_non_working_day: # 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 제외) overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime( clock_in_time, shutdown_time, include_lunch=lunch_enabled, include_dinner=dinner_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) else: # 평일: 정상 연장근무 계산 (외출 시간 포함) overtime_actual, overtime_earned = self.time_calc.calculate_overtime( clock_in_time, shutdown_time, include_lunch=lunch_enabled, include_dinner=dinner_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미) overtime_earned = self._apply_auto_overtime_gate(overtime_earned) # DB 업데이트 (total_hours는 원본 시간 그대로 저장) clock_out_str = shutdown_time.strftime("%H:%M:%S") self.db.update_clock_out( check_date, clock_out_str, total_hours, overtime_actual, overtime_earned ) # 연장근무 적립 if overtime_earned > 0: self.db.add_overtime_earned( record['id'], overtime_earned, check_date ) day_tag = " (주말)" if day_type == 'weekend' else (" (공휴일)" if day_type == 'holiday' else "") print(f"{check_date}{day_tag} 퇴근 자동 등록: {clock_out_str} (총 {total_hours:.1f}시간, 적립 {overtime_earned}분)") else: # 종료 시간을 찾을 수 없는 경우: 해당 날짜 23:59:59로 처리 clock_in_str = record['clock_in'] clock_in_time = datetime.strptime( f"{check_date} {clock_in_str}", "%Y-%m-%d %H:%M:%S" ) fallback_time = datetime.strptime( f"{check_date} 23:59:59", "%Y-%m-%d %H:%M:%S" ) # 주말/공휴일 체크 is_non_working_day = self.time_calc.is_non_working_day(clock_in_time, self.db) day_type = self.time_calc.get_day_type(clock_in_time, self.db) # 외출 시간 가져오기 break_minutes = self._get_break_minutes_for_date(check_date) # 총 근무시간 계산 work_duration = fallback_time - clock_in_time total_hours = work_duration.total_seconds() / 3600 lunch_enabled = bool(record.get('lunch_break', False)) dinner_enabled = bool(record.get('dinner_break', False)) unit_minutes = self.db.get_setting_int('overtime_unit', 30) if unit_minutes not in (15, 30, 60): unit_minutes = 30 if is_non_working_day: overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime( clock_in_time, fallback_time, include_lunch=lunch_enabled, include_dinner=dinner_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) else: overtime_actual, overtime_earned = self.time_calc.calculate_overtime( clock_in_time, fallback_time, include_lunch=lunch_enabled, include_dinner=dinner_enabled, break_minutes=break_minutes, unit_minutes=unit_minutes, ) # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미) overtime_earned = self._apply_auto_overtime_gate(overtime_earned) # DB 업데이트 self.db.update_clock_out( check_date, "23:59:59", total_hours, overtime_actual, overtime_earned ) # 연장근무 적립 if overtime_earned > 0: self.db.add_overtime_earned( record['id'], overtime_earned, check_date ) day_tag = " (주말)" if day_type == 'weekend' else (" (공휴일)" if day_type == 'holiday' else "") print(f"{check_date}{day_tag} 퇴근 자동 등록 (fallback): 23:59:59 (총 {total_hours:.1f}시간, 적립 {overtime_earned}분)") except Exception as e: dlog(f"auto_clock_out: processing failed for {check_date}: {e}") continue 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, tr('msg.full_day_leave.title'), tr('msg.full_day_leave.body'), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return # 기본값: 기존 출근시간이 있으면 그것을, 없으면 None default_time = self.clock_in_time if self.clock_in_time else None # 다이얼로그 표시 dialog = ClockInDialog(self, default_time) if dialog.exec_() == dialog.Accepted: selected_time = dialog.get_time() if selected_time: # 출근 시간 설정 self.clock_in_time = selected_time self.is_clocked_in = True self.midnight_rollover_handled = False # 새로운 근무일 시작 시 플래그 리셋 # DB 저장 today = datetime.now().date().isoformat() clock_in_str = selected_time.strftime("%H:%M:%S") # 기존 기록이 있는지 확인 existing_record = self.db.get_today_record() if existing_record: # 기존 기록 업데이트 (출근시간만) conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' UPDATE work_records SET clock_in = ?, is_manual = 1 WHERE date = ? ''', (clock_in_str, today)) conn.commit() conn.close() else: # 새 기록 추가 self.db.add_work_record(today, clock_in_str, is_manual=True) # UI 업데이트 self.clock_in_value.setText(clock_in_str) self.clock_out_button.setEnabled(True) self.clock_out_button.setText(tr('btn.clock_out')) QMessageBox.information( self, tr('msg.clock_in_set.title'), tr('msg.clock_in_set.body', time=clock_in_str) ) # Discord 웹훅 (옵션) self._discord_push_clock_in(selected_time) # 오늘 요약 카드 숨김 (새 출근 시작) if hasattr(self, 'today_summary_card'): self.today_summary_card.hide() def show_stats(self): """통계 창 표시""" dialog = StatsView(self, self.db) dialog.exec_() def show_calendar(self): """캘린더 창 표시""" # 도전과제 카운터 try: cur = self.db.get_setting_int('calendar_view_count', 0) self.db.set_setting('calendar_view_count', str(cur + 1)) except Exception as e: dlog(f"calendar view counter failed: {e}") 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) dialog.exec_() def apply_theme(self, theme_name: str): """테마 적용""" self.current_theme = theme_name ThemeColors.set_theme(theme_name) self.setStyleSheet(get_theme(theme_name)) apply_dark_titlebar(self, theme_name == 'dark') # 버튼 아이콘을 새 테마 색으로 재틴팅 (init_ui 이후에만) if hasattr(self, '_nav_icon_specs'): self._apply_button_icons() # 트레이 메뉴도 새 테마 QSS/아이콘으로 갱신 if getattr(self, 'tray_icon', None) is not None: self.tray_icon.refresh_theme() # 타이틀바 갱신을 위해 크기 미세 조정 size = self.size() self.resize(size.width() + 1, size.height()) self.resize(size) def show_settings(self): """설정 창 표시""" dialog = SettingsView(self, self.db) dialog.exec_() # 설정 변경 후 테마 재적용 new_theme = str(self.db.get_setting(THEME, 'dark')) if new_theme != self.current_theme: self.apply_theme(new_theme) def show_help(self): """사용 설명 가이드 창 표시""" from ui.help_view import HelpView dialog = HelpView(self) dialog.exec_() def show_achievements(self): """도전과제 다이얼로그 표시.""" from ui.achievements_view import AchievementsView # 진입 시 즉시 한 번 평가 — UI에 최신 진행도 반영 try: from core.achievements import evaluate_all evaluate_all(self.db) except Exception as e: dlog(f"achievements evaluate failed: {e}") dialog = AchievementsView(self.db, self) dialog.exec_() def _show_meal_context(self, meal_type: str, button, pos): """점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴.""" from PyQt5.QtWidgets import QMenu from ui.meal_time_dialog import MealTimeDialog menu = QMenu(self) title = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short') edit_action = menu.addAction(tr('msg.meal_actual_input', meal=title)) global_pos = button.mapToGlobal(pos) action = menu.exec_(global_pos) if action != edit_action: return if not self.is_clocked_in: QMessageBox.warning(self, tr('msg.meal_need_clock_in.title'), tr('msg.meal_need_clock_in.body')) return default_min = (self.time_calc.lunch_duration_minutes if meal_type == 'lunch' else self.time_calc.dinner_duration_minutes) # 식사 시각은 출~퇴근 범위 내여야 함. 호출 시점은 항상 출근 후·미퇴근 상태. dialog = MealTimeDialog( self, meal_type=meal_type, default_minutes=default_min, clock_in_time=self.clock_in_time, ) if dialog.exec_() != QDialog.Accepted: return start, end, minutes = dialog.get_times() today = datetime.now().date().isoformat() self.db.add_meal_record(today, start, end, meal_type=meal_type) # 자동 토글 ON if meal_type == 'lunch': self.lunch_break_enabled = True self.lunch_button.setChecked(True) self.update_lunch_status() self.db.update_lunch_break(today, True) self.auto_lunch_applied_today = True else: self.dinner_break_enabled = True self.dinner_button.setChecked(True) self.update_dinner_status() self.db.update_dinner_break(today, True) QMessageBox.information(self, tr('msg.meal_recorded.title'), tr('msg.meal_recorded.body', meal=title, minutes=minutes, start=start, end=end)) def show_onboarding(self): """온보딩 위저드 다시 보기.""" from ui.onboarding_view import OnboardingWizard wizard = OnboardingWizard(self.db, self) if wizard.exec_(): self.reload_settings() QMessageBox.information(self, tr('msg.settings_updated.title'), tr('msg.settings_updated.body')) # ===== Discord 웹훅 push (옵션, 실패 silent) ===== def _show_today_summary(self, total_hours, overtime_actual, overtime_earned, break_minutes): """퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함.""" if not hasattr(self, 'today_summary_card'): return # 점심/저녁 시간 (플래그 ON이면 설정값, 아니면 0) lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0 dinner_min = (self.time_calc.dinner_duration_minutes if getattr(self, 'dinner_break_enabled', False) else 0) # 추정 급여 (옵션) salary_text = "" if self.db.get_setting('salary_enabled', 'false').lower() == 'true': try: wage = float(self.db.get_setting('hourly_wage', '0') or 0) rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5) if wage > 0: from core.salary import estimate_pay, format_won fake_record = {'total_hours': total_hours, 'overtime_minutes': overtime_actual} result = estimate_pay([fake_record], wage, rate) salary_text = tr('label.today_estimate', amount=format_won(result['total'])) except (ValueError, TypeError): pass self.today_summary_card.show_summary( total_hours=total_hours, lunch_minutes=lunch_min, dinner_minutes=dinner_min, break_minutes=break_minutes, overtime_actual=overtime_actual, overtime_earned=overtime_earned, salary_text=salary_text, ) def _discord_url(self) -> str: return self.db.get_setting('discord_webhook_url', '') or '' def _discord_push_clock_in(self, when): if self.db.get_setting('discord_notif_clock_in', 'true').lower() != 'true': return url = self._discord_url() if not url: return try: from utils import discord_webhook ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S')) self.db.log_notification('discord', 'clock_in', success=ok) except Exception as e: from utils.debug_log import dlog dlog(f"discord clock_in push failed: {e}") def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned): if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true': return url = self._discord_url() if not url: return try: from utils import discord_webhook ok = discord_webhook.send_clock_out( url, when.strftime('%H:%M:%S'), total_hours, overtime_actual, overtime_earned, ) self.db.log_notification('discord', 'clock_out', success=ok) except Exception as e: from utils.debug_log import dlog dlog(f"discord clock_out push failed: {e}") def check_for_updates(self, silent: bool = False): """업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용).""" from core.version import __version__ from utils.updater_client import ( check_for_update, download_update, apply_update, UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET, ) info, reason = check_for_update(__version__) if info is None: if silent: return # 사용자가 명시적으로 트리거한 경우만 메시지 표시 messages = { UP_TO_DATE: (tr('update.check_title'), tr('update.up_to_date', version=__version__)), NETWORK_ERROR: (tr('msg.error.title'), tr('update.network_error')), NO_RELEASE: (tr('msg.no_data.title'), tr('update.no_release')), NO_ASSET: (tr('msg.confirm_delete.title'), tr('update.no_asset')), } title, body = messages.get(reason, (tr('update.check_title'), tr('update.unknown'))) QMessageBox.information(self, title, body) return # 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만 if not getattr(sys, 'frozen', False): QMessageBox.information( self, tr('update.new_version_title'), tr('update.new_found_dev', version=info.version) ) return reply = QMessageBox.question( self, tr('update.new_version_title'), tr('update.new_found', current=__version__, new=info.version, notes=info.notes[:500]), QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return # 다운로드 (모달 진행 다이얼로그) from PyQt5.QtWidgets import QProgressDialog progress = QProgressDialog(tr('update.downloading'), tr('btn.cancel'), 0, 100, self) progress.setWindowTitle(tr('update.download_title')) progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) def cb(downloaded, total): if total > 0: progress.setValue(int(downloaded * 100 / total)) QApplication.processEvents() new_exe = download_update(info.asset_url, progress_cb=cb) progress.close() if new_exe is None: QMessageBox.critical(self, tr('msg.error.title'), tr('update.download_failed')) return if not apply_update(new_exe): QMessageBox.critical( self, tr('update.apply_failed_title'), tr('update.updater_failed') ) return # updater.exe가 메인 종료를 기다리고 있음 → 즉시 종료 QMessageBox.information(self, tr('msg.error.title'), tr('update.restart')) QApplication.quit() def show_mini_widget(self): """미니 위젯 표시 (Always-on-top)""" if not hasattr(self, '_mini_widget') or self._mini_widget is None: from ui.mini_widget import MiniWidget self._mini_widget = MiniWidget(self) self._mini_widget.show() self._mini_widget.raise_() def show_break_management(self): """외출 관리 창 표시""" dialog = BreakView(self, self.db) dialog.exec_() def break_out(self, silent: bool = False): """외출 처리. silent=True면 다이얼로그 없이 (잠금 자동 외출용).""" if not self.is_clocked_in: if not silent: QMessageBox.warning(self, tr('break.cannot_no_clock_in.title'), tr('break.cannot_no_clock_in.body')) return if self.is_on_break: if not silent: QMessageBox.warning(self, tr('break.cannot_no_clock_in.title'), tr('break.cannot_already_on_break.body')) return now = datetime.now() today = now.date().isoformat() break_out_str = now.strftime("%H:%M:%S") today_record = self.db.get_today_record() if not today_record: if not silent: QMessageBox.warning(self, tr('break.cannot_no_clock_in.title'), tr('break.cannot_no_record.body')) return work_record_id = today_record['id'] reason = tr('break.reason.lock') if silent else None self.db.add_break_record(work_record_id, today, break_out_str, reason) self.is_on_break = True self.break_out_button.setEnabled(False) self.break_in_button.setEnabled(True) self.update_break_status() if not silent: QMessageBox.information(self, tr('break.started.title'), tr('break.started.body', time=break_out_str)) def break_in(self, silent: bool = False): """복귀 처리. silent=True면 다이얼로그 없이.""" if not self.is_on_break: return now = datetime.now() active_break = self.db.get_active_break_record() if not active_break: if not silent: QMessageBox.warning(self, tr('msg.error.title'), tr('break.cannot_return_no_active.body')) return if not active_break.get('break_out'): if not silent: QMessageBox.warning(self, tr('msg.error.title'), tr('break.cannot_return_corrupt.body')) return # 복귀 시간 업데이트 break_in_str = now.strftime("%H:%M:%S") self.db.update_break_return(active_break['id'], break_in_str) self.is_on_break = False self.break_out_button.setEnabled(True) self.break_in_button.setEnabled(False) self.update_break_status() # 외출 시간 계산 (자정 경계 처리) # break_record에 저장된 날짜를 사용하여 자정 경계 문제 해결 break_date = active_break['date'] try: break_date_obj = datetime.strptime(break_date, "%Y-%m-%d").date() break_out_parsed = datetime.strptime(active_break['break_out'], "%H:%M:%S").time() except ValueError: if not silent: QMessageBox.warning(self, tr('msg.error.title'), tr('break.cannot_return_unusable.body')) dlog(f"break_in parse failed: date={break_date}, break_out={active_break.get('break_out')}") return break_out_time = datetime.combine(break_date_obj, break_out_parsed) break_in_time = datetime.combine( break_date_obj, datetime.strptime(break_in_str, "%H:%M:%S").time() ) # 복귀 시간이 외출 시간보다 이전이면 자정을 넘긴 것으로 판단 if break_in_time < break_out_time: from datetime import timedelta break_in_time += timedelta(days=1) # 복귀는 다음 날로 처리 duration_minutes = int((break_in_time - break_out_time).total_seconds() / 60) if not silent: QMessageBox.information( self, tr('break.return.title'), tr('break.return.body', time=break_in_str, minutes=duration_minutes) ) def update_break_status(self): """외출 상태 업데이트""" active_break = self.db.get_active_break_record() if active_break: break_out = active_break['break_out'] self.break_status_label.setText(tr('break.status_in_progress', time=break_out)) self.break_status_label.setStyleSheet(f"color: {ThemeColors.get('status_break_active')}; font-weight: bold;") self.is_on_break = True self.break_out_button.setEnabled(False) self.break_in_button.setEnabled(True) else: total_minutes = self.db.get_total_break_minutes_today() if total_minutes > 0: hours = total_minutes // 60 minutes = total_minutes % 60 if hours > 0: self.break_status_label.setText(tr('break.status_total_hours_minutes', hours=hours, minutes=minutes)) else: self.break_status_label.setText(tr('break.status_total_minutes', minutes=minutes)) self.break_status_label.setStyleSheet(f"color: {ThemeColors.get('status_break_idle')};") else: self.break_status_label.setText("") self.is_on_break = False self.break_out_button.setEnabled(True) self.break_in_button.setEnabled(False) def _build_time_calc(self, settings: dict): """settings dict로부터 TimeCalculator 생성. Database.get_settings()가 이미 숫자 문자열을 int로 자동 변환하므로 추가 캐스팅은 불필요. work_minutes 우선, 없으면 work_hours*60 폴백. """ work_minutes = settings.get(WORK_MINUTES) if work_minutes is None: try: work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60)) except (ValueError, TypeError): work_minutes = 480 return TimeCalculator( work_minutes=int(work_minutes), lunch_duration_minutes=int(settings.get(LUNCH_DURATION_MINUTES, 60)), dinner_duration_minutes=int(settings.get(DINNER_DURATION_MINUTES, 60)), ) def reload_settings(self): """설정 다시 불러오기 (설정 변경 후 호출)""" settings = self.db.get_settings() self.time_calc = self._build_time_calc(settings) # 시간 형식 캐시 갱신 self.cached_time_format = str(settings.get(TIME_FORMAT, '24')) # auto_lunch 캐시 무효화 (설정에서 토글 가능하므로) self._auto_lunch.invalidate() # update_display 1Hz 핫패스 캐시 무효화 self._workday_boundary_hour_cache = None self._non_working_cache_date = None self._non_working_cache_value = None self._full_day_leave_cache_date = None self._full_day_leave_cache_value = None # 접근성 재적용 (글꼴 / 고대비) try: from ui.accessibility import apply_from_settings as _apply_a11y _apply_a11y(self.db) except Exception as e: dlog(f"accessibility reapply failed: {e}") # UI 업데이트 self.update_overtime_balance() self.update_leave_balance() # 시간 표시 형식이 변경되었을 경우 디스플레이 즉시 업데이트 if self.is_clocked_in and self.clock_in_time: self.update_display() def _append_meal_section(self, report_lines, today: str, label: str, flag: bool, records: list, default_min: int) -> None: """일일 보고서의 점심/저녁 섹션 출력. 실측 기록(`break_records.break_type='lunch'/'dinner'`)이 있으면 시작/종료 시각과 실측 분을 표시하고, 플래그만 켜진 경우엔 설정값(`lunch_duration_minutes`/`dinner_duration_minutes`)을 표시. """ actual_min = sum((r.get('total_minutes') or 0) for r in records) if records and actual_min > 0: report_lines.append(tr('report.meal_actual', label=label, time=format_hours_minutes(actual_min))) for r in records: try: start_dt = datetime.fromisoformat(f"{today} {r['break_out']}") except (KeyError, ValueError, TypeError): continue if r.get('break_in'): try: end_dt = datetime.fromisoformat(f"{today} {r['break_in']}") except (ValueError, TypeError): continue if end_dt < start_dt: end_dt += timedelta(days=1) dur = int((end_dt - start_dt).total_seconds() / 60) report_lines.append( tr('report.break_detail', start=self.format_time(start_dt), end=self.format_time(end_dt), duration=dur, reason='') ) else: report_lines.append(tr('report.meal_in_progress', start=self.format_time(start_dt))) elif flag: report_lines.append(tr('report.meal_default', label=label, time=format_hours_minutes(default_min))) else: return report_lines.append("") def generate_daily_report(self): """오늘 하루 근무 내역 보고서 생성 및 클립보드 복사""" from datetime import date # 도전과제 카운터 (보고서 생성 횟수) try: cur = self.db.get_setting_int('daily_report_count', 0) self.db.set_setting('daily_report_count', str(cur + 1)) except Exception as e: dlog(f"daily report counter failed: {e}") today = date.today().isoformat() # 오늘의 근무 기록 조회 work_record = self.db.get_today_record() if not work_record: QMessageBox.warning( self, tr('msg.no_record.title'), tr('msg.no_record.body') ) return # 보고서 작성 report_lines = [] report_lines.append("=" * 40) report_lines.append(tr('report.title', date=today)) report_lines.append("=" * 40) report_lines.append("") # 출근/퇴근 시간 clock_in_dt = datetime.fromisoformat(f"{today} {work_record['clock_in']}") report_lines.append(tr('report.clock_in', time=self.format_time(clock_in_dt, include_seconds=True))) if work_record['clock_out']: clock_out_dt = datetime.fromisoformat(f"{today} {work_record['clock_out']}") report_lines.append(tr('report.clock_out', time=self.format_time(clock_out_dt, include_seconds=True))) # 총 근무 시간 total_work_hours = work_record.get('total_hours') or work_record.get('work_hours', 0) if total_work_hours: hours = int(total_work_hours) minutes = int((total_work_hours - hours) * 60) report_lines.append(tr('report.total_work', time=tr('label.time_hours_minutes', hours=hours, minutes=minutes))) else: report_lines.append(tr('report.not_clocked_out')) report_lines.append("") # 외출 / 점심 / 저녁 분리 — break_type 으로 구분 (v2.7.0+) # 'break'(또는 NULL) = 일반 외출, 'lunch'/'dinner' = 실측 식사 기록 all_break_records = self.db.get_today_break_records() real_break_records = [b for b in all_break_records if (b.get('break_type') or 'break') == 'break'] lunch_records = [b for b in all_break_records if b.get('break_type') == 'lunch'] dinner_records = [b for b in all_break_records if b.get('break_type') == 'dinner'] # 외출 시간 (식사 제외) real_break_minutes = sum((b.get('total_minutes') or 0) for b in real_break_records) has_active_break = any(not b.get('break_in') for b in real_break_records) if real_break_minutes > 0 or has_active_break: report_lines.append(tr('report.break_time', time=format_hours_minutes(real_break_minutes))) for br in real_break_records: break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}") if br['break_in']: break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}") # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 if break_in_time < break_out_time: break_in_time += timedelta(days=1) duration = int((break_in_time - break_out_time).total_seconds() / 60) reason = f" ({br['reason']})" if br.get('reason') else "" report_lines.append(tr('report.break_detail', start=self.format_time(break_out_time), end=self.format_time(break_in_time), duration=duration, reason=reason)) else: reason = f" ({br['reason']})" if br.get('reason') else "" report_lines.append(tr('report.break_in_progress', start=self.format_time(break_out_time), reason=reason)) report_lines.append("") # 점심시간 lunch_flag = bool(work_record.get('lunch_break', False)) if lunch_flag or lunch_records: self._append_meal_section( report_lines, today, tr('label.lunch'), lunch_flag, lunch_records, self.time_calc.lunch_duration_minutes, ) # 저녁시간 dinner_flag = bool(work_record.get('dinner_break', False)) if dinner_flag or dinner_records: self._append_meal_section( report_lines, today, tr('label.dinner'), dinner_flag, dinner_records, self.time_calc.dinner_duration_minutes, ) # 추가 근무 적립 if work_record['overtime_minutes'] and work_record['overtime_minutes'] > 0: ot_hours = work_record['overtime_minutes'] // 60 ot_mins = work_record['overtime_minutes'] % 60 # 적립된 추가근무 (30분 단위 절삭) overtime_earned = work_record.get('overtime_earned', 0) earned_hours = overtime_earned // 60 earned_mins = overtime_earned % 60 report_lines.append(tr('report.overtime_occurred', time=tr('label.time_hours_minutes', hours=ot_hours, minutes=ot_mins))) report_lines.append(tr('report.overtime_banked', time=tr('label.time_hours_minutes', hours=earned_hours, minutes=earned_mins))) report_lines.append("") # 오늘 사용한 추가근무 overtime_used_today = self.db.get_today_overtime_usage() if overtime_used_today > 0: used_hours = overtime_used_today // 60 used_mins = overtime_used_today % 60 report_lines.append(tr('report.overtime_used', time=tr('label.time_hours_minutes', hours=used_hours, minutes=used_mins))) # 사용 상세 내역 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT used_minutes, reason, created_at FROM overtime_usage WHERE date = ? ORDER BY created_at ASC ''', (today,)) usage_records = cursor.fetchall() conn.close() for record in usage_records: used_min = record[0] reason = record[1] used_h = used_min // 60 used_m = used_min % 60 reason_text = f" - {reason}" if reason else "" report_lines.append(tr('report.overtime_used_detail', time=tr('label.time_hours_minutes', hours=used_h, minutes=used_m), reason=reason_text)) report_lines.append("") # 오늘 사용한 연차 (일괄 추가 및 수동 조정 제외) leave_records = self.db.get_leave_records(start_date=today, end_date=today, exclude_bulk=False) # manual 타입이거나 메모에 "일괄 추가"가 포함된 것은 제외 filtered_leave_records = [ r for r in leave_records if r.get('leave_type') != 'manual' and not (r.get('memo') and '일괄 추가' in r['memo']) ] if filtered_leave_records: total_leave_days = sum(r['days'] for r in filtered_leave_records) if total_leave_days >= 1: days = int(total_leave_days) hours = int((total_leave_days - days) * 8) if hours > 0: value = tr('report.leave_used_days_hours', days=days, hours=hours) else: value = tr('report.leave_used_days', days=days) else: hours = int(total_leave_days * 8) value = tr('report.leave_used_hours', hours=hours) report_lines.append(tr('report.leave_used', value=value)) for lr in filtered_leave_records: # leave_type을 현재 언어 이름으로 변환 leave_type_name = { 'annual': tr('leave.type.annual'), 'sick': tr('leave.type.sick'), 'half_am': tr('leave.type.half_am'), 'half_pm': tr('leave.type.half_pm'), 'time_off': tr('leave.type.time_off'), '연차': tr('leave.type.annual'), '반차': tr('leave.type.half'), '반반차': tr('leave.type.quarter') }.get(lr.get('leave_type', ''), lr.get('leave_type', tr('leave.type.annual'))) days_used = lr['days'] # 일수를 시간으로 표시 if days_used >= 1: d = int(days_used) h = int((days_used - d) * 8) if h > 0: time_str = tr('report.leave_used_days_hours', days=d, hours=h) else: time_str = tr('report.leave_used_days', days=d) else: h = int(days_used * 8) time_str = tr('report.leave_used_hours', hours=h) memo = f" - {lr['memo']}" if lr.get('memo') else "" report_lines.append(f" - {leave_type_name}: {time_str}{memo}") report_lines.append("") # 메모 if work_record['memo']: report_lines.append(tr('report.memo', memo=work_record['memo'])) report_lines.append("") report_lines.append("=" * 40) # 클립보드에 복사 report_text = "\n".join(report_lines) clipboard = QApplication.clipboard() clipboard.setText(report_text) # 미리보기 메시지 박스 QMessageBox.information( self, tr('report.copied.title'), tr('report.copied.body', report=report_text) ) def show_notification(self, title: str, message: str): """알림 표시""" # 시스템 트레이 알림 if self.tray_icon.supportsMessages(): self.tray_icon.showMessage( title, message, QSystemTrayIcon.Information, 5000 # 5초간 표시 ) else: # 대체: 메시지 박스 QMessageBox.information(self, title, message) def closeEvent(self, event): """창 닫기 이벤트""" # 최소화로 변경 (트레이로) event.ignore() self.hide() if self.tray_icon.supportsMessages(): self.tray_icon.showMessage( tr('app.title'), tr('tray.background'), QSystemTrayIcon.Information, 2000 )