Clock_out_Time_Calculator/ui/main_window.py
KINDNICK 14d88656fe v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect):
- auto_overtime: when OFF, prompt user on clock-out before banking
- overtime_unit: 15/30/60-min truncation choice now actually applied
- notification_before_minutes: 30-min hardcode -> user-configurable 1~120

Removed dead keys (no readers in business logic):
- auto_detect_boot, notification_enabled, annual_leave_used

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:29:44 +09:00

2086 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
메인 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 초기화"""
from core.version import __version__
self.setWindowTitle(f"{tr('window.main_title')} 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)
# 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()
# 적립 단위(분) — 사용자 설정. 기본 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:
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
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 // unit_minutes) * unit_minutes
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,
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:
from utils.time_format import format_hours_minutes
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()
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,
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()
# 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
)