Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
\u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
\u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
\u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
\u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
\u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
\u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac
Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
\u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
\u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
\u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
\u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc
Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
2476 lines
100 KiB
Python
2476 lines
100 KiB
Python
"""
|
||
메인 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
|
||
)
|