""" 메인 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 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 class MainWindow(QMainWindow): """메인 윈도우 클래스""" def __init__(self, db: 'Database' = None): """ Args: db: 사전 초기화된 Database 인스턴스. None이면 자체 부트스트랩. (main.py가 backup/crash_handler용으로 먼저 만들고 전달) """ super().__init__() # 테마 적용 self.current_theme = 'light' # 설정에서 로드 후 덮어씀 # 데이터베이스 — 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: pass # TimeCalculator 초기화 (설정값 반영) settings = self.db.get_settings() # 시간 형식 설정 캐시 (매 초 DB 조회 방지) self.cached_time_format = str(settings.get(TIME_FORMAT, '24')) # 테마 설정 self.current_theme = str(settings.get(THEME, 'light')) 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 중복 적용 방지 # 컨트롤러는 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: pass # 초기 데이터 로드 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 = "오전" if hour < 12 else "오후" display_hour = hour % 12 if display_hour == 0: display_hour = 12 if include_seconds: return f"{period} {display_hour}:{minute:02d}:{second:02d}" else: return f"{period} {display_hour}:{minute:02d}" 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, 500, 620) self.setMinimumSize(480, 520) # 외부 컨테이너 (스크롤 + 고정 하단) 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) # 메인 레이아웃 main_layout = QVBoxLayout() main_layout.setSpacing(8) main_layout.setContentsMargins(12, 10, 12, 10) # 1. 헤더 - 앱 타이틀 title_label = QLabel("퇴근시간 계산기") 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) # 4. 예상 퇴근시간 self.expected_time_label = QLabel() self.expected_time_label.setObjectName("expected_time") self.expected_time_label.setAlignment(Qt.AlignCenter) main_layout.addWidget(self.expected_time_label) # 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("좌클릭: 토글 / 우클릭: 실제 시간 입력") 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("좌클릭: 토글 / 우클릭: 실제 시간 입력") 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("외출") self.break_out_button.clicked.connect(self.break_out) self.break_in_button = QPushButton("복귀") self.break_in_button.clicked.connect(self.break_in) self.break_in_button.setEnabled(False) self.break_manage_button = QPushButton("외출 관리") 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(8) fixed_bottom_layout.setContentsMargins(12, 8, 12, 10) 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("🏆 도전과제") 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) for btn in [stats_button, calendar_button, report_button, achievements_button, help_button, settings_button]: 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._setup_shortcuts() 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 create_clock_in_group(self) -> QGroupBox: """출근 정보 그룹 생성""" group = QGroupBox("오늘의 근무") layout = QVBoxLayout() layout.setSpacing(4) layout.setContentsMargins(12, 20, 12, 8) # 출근 시간 레이아웃 clock_in_layout = QHBoxLayout() clock_in_label = QLabel("출근:") clock_in_label.setObjectName("field_label") clock_in_label.setFixedWidth(50) self.clock_in_value = QLabel("--:--:--") self.clock_in_value.setObjectName("time_value") self.clock_in_value.setMinimumWidth(90) # 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정) self.clock_in_value.setCursor(Qt.PointingHandCursor) self.clock_in_value.setToolTip("클릭하여 출근 시간 수정") self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in() self.edit_clock_in_button = QPushButton("✏️ 수정") self.edit_clock_in_button.setObjectName("btn_small") self.edit_clock_in_button.setFixedWidth(70) self.edit_clock_in_button.clicked.connect(self.manual_clock_in) clock_in_layout.addWidget(clock_in_label) clock_in_layout.addWidget(self.clock_in_value) clock_in_layout.addStretch() clock_in_layout.addWidget(self.edit_clock_in_button) # 현재 시간 레이아웃 current_layout = QHBoxLayout() current_label = QLabel("현재:") current_label.setObjectName("field_label") current_label.setFixedWidth(50) self.current_time_value = QLabel("--:--:--") self.current_time_value.setObjectName("time_value") self.current_time_value.setMinimumWidth(90) current_layout.addWidget(current_label) current_layout.addWidget(self.current_time_value) current_layout.addStretch() layout.addLayout(clock_in_layout) layout.addLayout(current_layout) group.setLayout(layout) return group def create_remaining_time_group(self) -> QGroupBox: """남은 시간 표시 그룹 생성""" self.remaining_time_group = QGroupBox("남은 시간") layout = QVBoxLayout() layout.setSpacing(6) layout.setContentsMargins(12, 20, 12, 8) # 남은 시간 라벨 self.remaining_time_label = QLabel("--:--:--") self.remaining_time_label.setObjectName("time_display") self.remaining_time_label.setAlignment(Qt.AlignCenter) # 프로그레스 바 self.progress_bar = QProgressBar() self.progress_bar.setTextVisible(False) layout.addWidget(self.remaining_time_label) layout.addWidget(self.progress_bar) self.remaining_time_group.setLayout(layout) return self.remaining_time_group def create_overtime_group(self) -> QGroupBox: """연장근무 및 연차 현황 그룹 생성""" group = QGroupBox("연장근무 및 연차 현황") layout = QVBoxLayout() layout.setSpacing(6) layout.setContentsMargins(12, 20, 12, 8) # 연장근무 섹션 overtime_header = QHBoxLayout() overtime_title = QLabel("연장근무 적립") overtime_title.setObjectName("section_title") overtime_header.addWidget(overtime_title) overtime_header.addStretch() self.overtime_balance_label = QLabel("0분 (×0)") 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("30분") use_1hour_button = QPushButton("1시간") use_2hour_button = QPushButton("2시간") use_custom_overtime_button = QPushButton("직접입력") overtime_detail_button = QPushButton("상세") 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("연차") leave_title.setObjectName("section_title") leave_header.addWidget(leave_title) leave_header.addStretch() self.leave_balance_label = QLabel("잔여: 0일") 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("30분") use_1hour_leave_button = QPushButton("1시간") use_half_leave_button = QPushButton("반차") use_full_leave_button = QPushButton("연차") leave_detail_button = QPushButton("상세") 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("총 보유 시간") total_title.setObjectName("section_title") total_header.addWidget(total_title) total_header.addStretch() self.total_time_label = QLabel("0시간 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("🔄 퇴근 취소") # 퇴근 완료 상태에서도 출퇴근 시간은 표시 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("✅ 퇴근하기") 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, "자동 출근 감지", f"출근 시간이 자동으로 감지되었습니다.\n" f"출근: {clock_in_str}\n\n" f"잘못된 경우 수정할 수 있습니다." ) else: # 자동 감지 실패 - 수동 입력 요청 reply = QMessageBox.question( self, "출근 시간 입력", "출근 시간을 자동으로 감지하지 못했습니다.\n\n" "수동으로 입력하시겠습니까?\n" "(관리자 권한으로 실행하면 자동 감지됩니다)", 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)) # 근무일 경계 시간 확인 workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) # 새 근무일 체크: 퇴근 완료 상태에서 날짜가 바뀌고 경계 시간 이후면 새 출근 유도 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.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 # 근무일 경계 체크: 출근일과 현재 날짜가 다르고, 경계 시간(기본 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 → 출근 직후부터 모든 시간이 연장근무로 흐름. 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( self.clock_in_time, now, include_lunch=self.lunch_break_enabled, include_dinner=self.dinner_break_enabled, break_minutes=break_minutes, ) # 사용한 추가근무 차감만 반영 (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, 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("연차 override (전체 적립)") elif day_type == 'weekend': self.remaining_time_group.setTitle("주말 근무 (전체 적립)") elif day_type == 'holiday': self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)") else: self.remaining_time_group.setTitle("추가 근무 중") # + 기호로 표시 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_overtime')};") else: # 정상 근무 중 self.remaining_time_group.setTitle("남은 시간") remaining_str = self.time_calc.format_time_delta(remaining) if remaining.total_seconds() < 1800: # 30분 이내 self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_warning')};") else: self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};") 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, "휴일 근무 (정해진 퇴근시각 없음)" ) 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"예상 퇴근: {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_kr = ['월', '화', '수', '목', '금', '토', '일'] weekday = weekday_kr[now.weekday()] 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() 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(f"{hours}시간 {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(f"{balance_hours}시간 {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(f"{total_hours}시간 {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, "연장근무 사용 (마이너스 전환)", f"{minutes}분의 연장근무를 사용하시겠습니까?\n\n" f"현재 잔액: {balance}분\n" f"사용 후 잔액: {new_balance}분 (마이너스)\n\n" f"⚠️ 잔액이 마이너스가 됩니다.\n" f"나중에 초과근무로 갚아야 합니다.", QMessageBox.Yes | QMessageBox.No ) else: reply = QMessageBox.question( self, "연장근무 사용", f"{minutes}분의 연장근무를 사용하시겠습니까?\n\n" f"현재 잔액: {balance}분\n" f"사용 후 잔액: {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, "사용 완료", f"{minutes}분이 사용되었습니다." ) self.update_overtime_balance() except Exception as e: QMessageBox.warning( self, "사용 실패", 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, "잔액 부족", f"사용 가능한 연차가 부족합니다.\n" f"현재 잔액: {balance}일\n" f"요청: {days}일" ) return # 사용 날짜 입력 from PyQt5.QtWidgets import QInputDialog, QLineEdit from datetime import date today = date.today().isoformat() date_str, ok = QInputDialog.getText( self, "연차 사용 날짜", "사용 날짜를 입력하세요 (YYYY-MM-DD):", QLineEdit.Normal, today ) if not ok or not date_str: return # 날짜 형식 검증 try: datetime.strptime(date_str, "%Y-%m-%d") except ValueError: QMessageBox.warning( self, "입력 오류", "날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-15)" ) return # 메모 입력 memo, ok = QInputDialog.getText( self, "연차 사유", "사유를 입력하세요 (선택):", QLineEdit.Normal, "" ) if not ok: return # 사용 확인 if days == 1.0: leave_type = "연차" days_str = "1일" elif days == 0.5: leave_type = "반차" days_str = "0.5일 (4시간)" elif days == 0.125: leave_type = "시간연차" days_str = "0.125일 (1시간)" elif days == 0.0625: leave_type = "시간연차" days_str = "0.0625일 (30분)" else: leave_type = "연차" hours = days * 8 days_str = f"{days}일 ({hours}시간)" reply = QMessageBox.question( self, "연차 사용", f"{date_str}에 {leave_type} {days_str}를 사용하시겠습니까?\n\n" f"사용 후 잔액: {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, "사용 완료", f"{leave_type}가 사용되었습니다." ) self.update_leave_balance() except ValueError as e: # 잔액 부족 등 검증 오류 QMessageBox.warning( self, "사용 불가", str(e) ) self.update_leave_balance() # 최신 잔액으로 새로고침 except Exception as e: # 기타 데이터베이스 오류 QMessageBox.critical( self, "오류", f"연차 사용 중 오류가 발생했습니다:\n{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, "시간 입력", "사용할 시간을 입력하세요 (0.5시간 단위):\n예) 0.5, 1, 1.5, 2, 3, 4", 0.5, 0.5, 24.0, 1 ) if not ok: return # 시간을 분으로 변환 minutes = int(hours * 60) # 30분 단위 검증 if minutes % 30 != 0: QMessageBox.warning( self, "입력 오류", "30분 단위로만 사용 가능합니다.\n예) 0.5시간, 1시간, 1.5시간" ) 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, "시간 입력", "사용할 시간을 입력하세요 (0.5시간 단위):\n예) 0.5, 1, 1.5, 2, 4, 8", 0.5, 0.5, 80.0, 1 ) if not ok: return # 시간을 일수로 변환 (8시간 = 1일) days = hours / 8.0 if days > balance: QMessageBox.warning( self, "잔액 부족", f"사용 가능한 연차가 부족합니다.\n" f"현재 잔액: {balance}일 ({balance * 8}시간)\n" f"요청: {days}일 ({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, "퇴근 확인", f"퇴근 처리하시겠습니까?\n\n" f"퇴근 시간: {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, "연장근무 적립 확인", f"연장근무 {actual_str} 발생, {time_str} 적립 대상입니다.\n\n" f"적립하시겠습니까?\n" f"(아니오 선택 시 이번 퇴근분은 적립되지 않습니다)", 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("🔄 퇴근 취소") # 결과 메시지 msg = f"퇴근 처리되었습니다!\n\n" if day_type == 'weekend': msg += f"[주말 근무]\n" elif day_type == 'holiday': holiday_info = self.db.get_holiday(today) holiday_name = holiday_info['name'] if holiday_info else "공휴일" msg += f"[공휴일 근무 - {holiday_name}]\n" msg += f"총 근무시간: {total_hours:.1f}시간\n" if overtime_earned > 0: tokens, time_str = self.time_calc.format_overtime_tokens(overtime_earned) if is_non_working_day: msg += f"전체 적립: {time_str} (🕐×{tokens})" else: msg += f"연장근무 적립: {time_str} (🕐×{tokens})" QMessageBox.information(self, "퇴근 완료", 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, "퇴근 취소", "퇴근을 취소하시겠습니까?\n\n" "퇴근 시간과 연장근무 적립 내역이 삭제됩니다.", 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("✅ 퇴근하기") # 잔액 업데이트 self.update_overtime_balance() QMessageBox.information( self, "퇴근 취소 완료", "퇴근이 취소되었습니다.\n다시 근무 중 상태로 전환되었습니다." ) else: QMessageBox.warning( self, "취소 실패", "퇴근 기록을 찾을 수 없습니다." ) except Exception as e: QMessageBox.critical( self, "오류", f"퇴근 취소 중 오류가 발생했습니다:\n{str(e)}" ) 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, ) # 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, "근무일 경계 경과", f"근무일 경계 시간({workday_boundary_hour}시)이 지나 자동으로 처리되었습니다.\n\n" f"전날 근무: {before_boundary_str} 퇴근 처리\n" f"금일 근무: {boundary_time_str} 출근 처리\n\n" f"자정~{workday_boundary_hour}시 전까지의 야근은 전날 초과근무로 인정됩니다." ) # 화면 업데이트 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, "새 근무일", f"새로운 근무일입니다. ({today_str})\n\n" f"출근 처리를 하시겠습니까?", 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("✅ 퇴근하기") 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() record = self.db.get_work_record(check_date) # 출근은 했지만 퇴근을 안 한 기록 발견 if record and record.get('clock_in') and not record.get('clock_out'): # 해당 날짜의 종료 시간 감지 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) # 외출 시간 가져오기 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT SUM(total_minutes) FROM break_records WHERE date = ? ''', (check_date,)) break_minutes = cursor.fetchone()[0] or 0 conn.close() # 총 근무시간 계산 (원본 시간) 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, ) # 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) # 외출 시간 가져오기 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT SUM(total_minutes) FROM break_records WHERE date = ? ''', (check_date,)) break_minutes = cursor.fetchone()[0] or 0 conn.close() # 총 근무시간 계산 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, ) # 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}분)") 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 # 다이얼로그 표시 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("✅ 퇴근하기") QMessageBox.information( self, "출근 시간 설정", f"출근 시간이 설정되었습니다.\n\n출근: {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: pass 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 self.setStyleSheet(get_theme(theme_name)) apply_dark_titlebar(self, theme_name == 'dark') # 타이틀바 갱신을 위해 크기 미세 조정 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, 'light')) 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: pass 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 = "점심" if meal_type == 'lunch' else "저녁" edit_action = menu.addAction(f"⏱ {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, "출근 필요", "출근 후에만 식사 시간을 기록할 수 있습니다.") 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, "기록 완료", f"{title} {minutes}분 기록되었습니다.\n({start} ~ {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, "설정 업데이트", "변경된 설정이 즉시 반영되었습니다.") # ===== 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 = f"오늘 추정: {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: ("업데이트 확인", f"현재 최신 버전입니다 (v{__version__})."), NETWORK_ERROR: ("연결 실패", "업데이트 서버에 연결할 수 없습니다.\n" "네트워크 상태를 확인해 주세요."), NO_RELEASE: ("릴리스 없음", "업데이트 저장소에서 릴리스를 찾을 수 없습니다.\n" "(저장소 비공개 또는 첫 릴리스 전)"), NO_ASSET: ("자산 누락", "새 버전은 있지만 다운로드 가능한 main.exe 자산이 없습니다.\n" "관리자에게 문의하세요."), } title, body = messages.get(reason, ("업데이트 확인", "알 수 없는 응답입니다.")) QMessageBox.information(self, title, body) return # 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만 if not getattr(sys, 'frozen', False): QMessageBox.information( self, "새 버전 발견", f"새 버전 {info.version}이 있습니다.\n" "(개발 환경에서는 자동 적용 불가 — git pull 또는 빌드 후 사용)" ) return reply = QMessageBox.question( self, "새 버전 발견", f"현재: v{__version__}\n새 버전: {info.version}\n\n" f"릴리스 노트:\n{info.notes[:500]}\n\n지금 다운로드 후 업데이트할까요?", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return # 다운로드 (모달 진행 다이얼로그) from PyQt5.QtWidgets import QProgressDialog progress = QProgressDialog("다운로드 중...", "취소", 0, 100, self) progress.setWindowTitle("업데이트 다운로드") 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, "다운로드 실패", "새 버전 다운로드 중 오류가 발생했습니다.") return if not apply_update(new_exe): QMessageBox.critical( self, "업데이트 실패", "updater.exe를 찾을 수 없거나 실행에 실패했습니다." ) return # updater.exe가 메인 종료를 기다리고 있음 → 즉시 종료 QMessageBox.information(self, "재시작", "업데이트 적용을 위해 프로그램이 종료됩니다.") 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, "외출 불가", "출근하지 않은 상태입니다.") return if self.is_on_break: if not silent: QMessageBox.warning(self, "외출 불가", "이미 외출 중입니다.") 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, "외출 불가", "출근 기록을 찾을 수 없습니다.") return work_record_id = today_record['id'] reason = "화면 잠금" 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, "외출", f"외출 시간: {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, "복귀 불가", "진행 중인 외출 기록을 찾을 수 없습니다.") return if not active_break.get('break_out'): if not silent: QMessageBox.warning(self, "복귀 불가", "외출 시간 기록이 손상되었습니다.") 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'] break_date_obj = datetime.strptime(break_date, "%Y-%m-%d").date() break_out_time = datetime.combine( break_date_obj, datetime.strptime(active_break['break_out'], "%H:%M:%S").time() ) 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, "복귀", f"복귀 시간: {break_in_str}\n외출 시간: {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(f"외출 중 ({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(f"오늘 총 외출: {hours}시간 {minutes}분") else: self.break_status_label.setText(f"오늘 총 외출: {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() # 접근성 재적용 (글꼴 / 고대비) try: from ui.accessibility import apply_from_settings as _apply_a11y _apply_a11y(self.db) except Exception: pass # 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(f"{label}: {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( f" - {self.format_time(start_dt)} ~ {self.format_time(end_dt)} ({dur}분)" ) else: report_lines.append(f" - {self.format_time(start_dt)} ~ 진행중") elif flag: report_lines.append(f"{label}: 포함 ({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: pass today = date.today().isoformat() # 오늘의 근무 기록 조회 work_record = self.db.get_today_record() if not work_record: QMessageBox.warning( self, "기록 없음", "오늘 출근 기록이 없습니다." ) return # 보고서 작성 report_lines = [] report_lines.append("=" * 40) report_lines.append(f"📋 일일 근무 보고서 - {today}") report_lines.append("=" * 40) report_lines.append("") # 출근/퇴근 시간 clock_in_dt = datetime.fromisoformat(f"{today} {work_record['clock_in']}") report_lines.append(f"🕐 출근 시간: {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(f"🕐 퇴근 시간: {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(f"⏱️ 총 근무: {hours}시간 {minutes}분") else: report_lines.append(f"🕐 퇴근 시간: 미퇴근") 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(f"🚶 외출 시간: {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(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}분){reason}") else: reason = f" ({br['reason']})" if br.get('reason') else "" report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{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, '🍱 점심시간', 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, '🍽️ 저녁시간', 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(f"⏰ 추가 근무 발생: {ot_hours}시간 {ot_mins}분") report_lines.append(f" 💰 적립: {earned_hours}시간 {earned_mins}분 (30분 단위 절삭)") 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(f"🕐 추가 근무 사용: {used_hours}시간 {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(f" - {used_h}시간 {used_m}분{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: report_lines.append(f"🌴 연차 사용: {days}일 {hours}시간") else: report_lines.append(f"🌴 연차 사용: {days}일") else: hours = int(total_leave_days * 8) report_lines.append(f"🌴 연차 사용: {hours}시간") for lr in filtered_leave_records: # leave_type을 한글 이름으로 변환 leave_type_name = { 'annual': '연차', 'sick': '병가', 'half_am': '오전 반차', 'half_pm': '오후 반차', 'time_off': '시간 연차', '연차': '연차', '반차': '반차', '반반차': '반반차' }.get(lr.get('leave_type', ''), lr.get('leave_type', '연차')) days_used = lr['days'] # 일수를 시간으로 표시 if days_used >= 1: d = int(days_used) h = int((days_used - d) * 8) if h > 0: time_str = f"{d}일 {h}시간" else: time_str = f"{d}일" else: h = int(days_used * 8) time_str = f"{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(f"📝 메모: {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, "보고서 복사 완료", "일일 근무 보고서가 클립보드에 복사되었습니다.\n\n" + 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( "Clock-out Time Calculator", "프로그램이 트레이에서 실행 중입니다.", QSystemTrayIcon.Information, 2000 )