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