Some checks failed
CI / test (push) Has been cancelled
핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의) - Windows 이벤트 뷰어 자동 출퇴근 감지 - 30분 단위 연장근무 적립/사용 시스템 - 1.0/0.5/0.25일 연차·반차·반반차 - 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출 - 한국 공휴일 자동 등록 (음력 포함, holidays 패키지) - matplotlib 차트 기반 주간/월간/패턴 통계 - 미니 위젯 + 시스템 트레이 통합 - 한국어/English i18n - 자가 업데이트 (updater.exe + Gitea Releases) 아키텍처: - core/ (db, time_calculator, notifier, i18n, version, settings_keys) - ui/ (main_window + 9 dialogs + 3 controllers) - utils/ (backup, lock_detector, debug_log, updater_client, time_format) - tests/ (66 pytest 단위) + 통합/i18n GUI 검증 CI/CD: - .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트 - .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2050 lines
80 KiB
Python
2050 lines
80 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)
|
||
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 ui.styles import get_theme, ThemeColors, apply_dark_titlebar
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""메인 윈도우 클래스"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
|
||
# 테마 적용
|
||
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
|
||
|
||
# 데이터베이스 — db_path_override 설정 시 그 경로 사용 (클라우드 동기화 폴더 등)
|
||
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')
|
||
|
||
# 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)
|
||
|
||
# 책임 분리된 컨트롤러들 (1Hz hot path)
|
||
from ui.controllers.lock_monitor import LockMonitor
|
||
from ui.controllers.auto_lunch import AutoLunchManager
|
||
from ui.controllers.notification_orchestrator import NotificationOrchestrator
|
||
self._lock_monitor = LockMonitor(self)
|
||
self._auto_lunch = AutoLunchManager(self)
|
||
self._notif_orch = NotificationOrchestrator(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)
|
||
|
||
# 초기 데이터 로드
|
||
self.load_today_data()
|
||
|
||
# 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시)
|
||
QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True))
|
||
|
||
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 초기화"""
|
||
self.setWindowTitle("⏰ " + tr('window.main_title'))
|
||
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)
|
||
|
||
# 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.dinner_button = QPushButton(tr('btn.dinner_add'))
|
||
self.dinner_button.setCheckable(True)
|
||
self.dinner_button.clicked.connect(self.toggle_dinner_break)
|
||
|
||
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'))
|
||
help_button = QPushButton(tr('menu.help'))
|
||
settings_button = QPushButton(tr('menu.settings'))
|
||
|
||
for btn in [stats_button, calendar_button, report_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)
|
||
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.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:
|
||
# 출근 기록 없음 - 자동 감지 시도
|
||
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
|
||
|
||
# 출근하지 않았으면 여기서 종료
|
||
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
|
||
|
||
# 남은 시간 계산 (외출 시간 반영, 추가근무/반차 사용 시간 차감)
|
||
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:
|
||
# 추가 근무 중
|
||
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)
|
||
|
||
# 진행률 업데이트
|
||
# - 외출 시간: 필요 근무시간 증가 (일을 안 한 시간이므로 더 일해야 함)
|
||
# - 추가근무 사용: 필요 근무시간 감소 (미리 일한 것을 사용하므로 덜 일해도 됨)
|
||
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))
|
||
|
||
# 예상 퇴근 시간 (외출 시간 포함)
|
||
# 추가근무 사용 시간만큼 일찍 퇴근 가능하므로 실제 퇴근 시간에서 차감
|
||
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 포함)
|
||
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds())
|
||
|
||
# 트레이 / 미니 위젯 갱신
|
||
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 toggle_lunch_break(self):
|
||
"""점심시간 토글"""
|
||
self.lunch_break_enabled = self.lunch_button.isChecked()
|
||
self.update_lunch_status()
|
||
|
||
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
|
||
if self.lunch_break_enabled:
|
||
self.auto_lunch_applied_today = True
|
||
|
||
# DB 업데이트
|
||
if self.is_clocked_in:
|
||
today = datetime.now().date().isoformat()
|
||
self.db.update_lunch_break(today, self.lunch_break_enabled)
|
||
|
||
def toggle_dinner_break(self):
|
||
"""저녁시간 토글"""
|
||
self.dinner_break_enabled = self.dinner_button.isChecked()
|
||
self.update_dinner_status()
|
||
|
||
# DB 업데이트
|
||
if self.is_clocked_in:
|
||
today = datetime.now().date().isoformat()
|
||
self.db.update_dinner_break(today, self.dinner_break_enabled)
|
||
|
||
def update_lunch_status(self):
|
||
"""점심시간 상태 업데이트"""
|
||
self.lunch_button.setText(
|
||
tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add')
|
||
)
|
||
|
||
def update_dinner_status(self):
|
||
"""저녁시간 상태 업데이트"""
|
||
self.dinner_button.setText(
|
||
tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add')
|
||
)
|
||
|
||
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()
|
||
|
||
if is_non_working_day:
|
||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
||
work_minutes = int(total_hours * 60)
|
||
if self.lunch_break_enabled:
|
||
work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외
|
||
if self.dinner_break_enabled:
|
||
work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외
|
||
work_minutes -= break_minutes # 외출시간 제외
|
||
# 음수 방지
|
||
work_minutes = max(0, work_minutes)
|
||
# 30분 단위로 절삭
|
||
overtime_earned = (work_minutes // 30) * 30
|
||
overtime_actual = work_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
|
||
)
|
||
|
||
# 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()
|
||
|
||
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()
|
||
|
||
# 추가근무 계산
|
||
if is_non_working_day:
|
||
work_minutes = int(total_hours * 60)
|
||
if self.lunch_break_enabled:
|
||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||
if self.dinner_break_enabled:
|
||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||
work_minutes -= break_minutes
|
||
# 음수 방지
|
||
work_minutes = max(0, work_minutes)
|
||
overtime_earned = (work_minutes // 30) * 30
|
||
overtime_actual = work_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
|
||
)
|
||
|
||
# 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))
|
||
|
||
if is_non_working_day:
|
||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (점심/저녁/외출 제외)
|
||
work_minutes = int(total_hours * 60)
|
||
if lunch_enabled:
|
||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||
if dinner_enabled:
|
||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||
work_minutes -= break_minutes
|
||
# 음수 방지
|
||
work_minutes = max(0, work_minutes)
|
||
# 30분 단위로 절삭
|
||
overtime_earned = (work_minutes // 30) * 30
|
||
overtime_actual = work_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
|
||
)
|
||
|
||
# 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))
|
||
|
||
if is_non_working_day:
|
||
work_minutes = int(total_hours * 60)
|
||
if lunch_enabled:
|
||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||
if dinner_enabled:
|
||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||
work_minutes -= break_minutes
|
||
# 음수 방지
|
||
work_minutes = max(0, work_minutes)
|
||
overtime_earned = (work_minutes // 30) * 30
|
||
overtime_actual = work_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
|
||
)
|
||
|
||
# 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):
|
||
"""수동 출근 시간 입력"""
|
||
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 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}"
|
||
)
|
||
|
||
def show_stats(self):
|
||
"""통계 창 표시"""
|
||
dialog = StatsView(self, self.db)
|
||
dialog.exec_()
|
||
|
||
def show_calendar(self):
|
||
"""캘린더 창 표시"""
|
||
dialog = CalendarView(self, self.db)
|
||
dialog.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 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
|
||
|
||
info = check_for_update(__version__)
|
||
if info is None:
|
||
if not silent:
|
||
QMessageBox.information(
|
||
self,
|
||
"업데이트 확인",
|
||
f"현재 최신 버전입니다 (v{__version__})."
|
||
)
|
||
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()
|
||
|
||
# UI 업데이트
|
||
self.update_overtime_balance()
|
||
self.update_leave_balance()
|
||
|
||
# 시간 표시 형식이 변경되었을 경우 디스플레이 즉시 업데이트
|
||
if self.is_clocked_in and self.clock_in_time:
|
||
self.update_display()
|
||
|
||
def generate_daily_report(self):
|
||
"""오늘 하루 근무 내역 보고서 생성 및 클립보드 복사"""
|
||
from datetime import date
|
||
|
||
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_minutes = self.db.get_total_break_minutes_today()
|
||
if break_minutes > 0:
|
||
break_hours = break_minutes // 60
|
||
break_mins = break_minutes % 60
|
||
report_lines.append(f"🚶 외출 시간: {break_hours}시간 {break_mins}분")
|
||
|
||
# 외출 상세 내역
|
||
break_records = self.db.get_today_break_records()
|
||
for br in 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['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['reason'] else ""
|
||
report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{reason}")
|
||
report_lines.append("")
|
||
|
||
# 점심시간
|
||
lunch_break = work_record.get('lunch_break', False)
|
||
if lunch_break:
|
||
report_lines.append(f"🍱 점심시간: 포함 (1시간)")
|
||
report_lines.append("")
|
||
|
||
# 추가 근무 적립
|
||
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
|
||
)
|