""" 설정 뷰 """ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSpinBox, QCheckBox, QGroupBox, QComboBox, QTimeEdit, QMessageBox, QFileDialog, QScrollArea, QWidget, QLineEdit) from PyQt5.QtCore import Qt, QTime from PyQt5.QtWidgets import QApplication from datetime import datetime import sys import os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.database import Database from core.i18n import tr from core.settings_keys import ( WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME, NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH, NOTIFICATION_BEFORE_MINUTES, LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS, OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS, HEALTH_BREAK_HOURS, HEALTH_BREAK_ENABLED, THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK, GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY, GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED, FONT_SCALE, HIGH_CONTRAST, ) from utils.csv_exporter import CSVExporter from ui.leave_view import AddLeaveDialog from ui.styles import apply_dark_titlebar class SettingsView(QDialog): """설정 다이얼로그""" def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db if db else Database() self.parent_window = parent self.init_ui() self.load_settings() self._settings_loaded = True apply_dark_titlebar(self) def init_ui(self): """UI 초기화""" self.setWindowTitle(tr('window.settings')) self.setModal(True) self.setMinimumSize(600, 700) # 메인 레이아웃 main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # 제목 title = QLabel(tr('settings.title')) title.setObjectName("dialog_title") title.setAlignment(Qt.AlignCenter) main_layout.addWidget(title) # 스크롤 영역 scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # 스크롤 내용 scroll_content = QWidget() scroll_content.setObjectName("scroll_content") layout = QVBoxLayout() layout.setSpacing(16) layout.setContentsMargins(20, 10, 20, 10) # 근무 시간 설정 work_time_group = self.create_work_time_group() layout.addWidget(work_time_group) # 알림 설정 notification_group = self.create_notification_group() layout.addWidget(notification_group) # 연장근무 설정 overtime_group = self.create_overtime_group() layout.addWidget(overtime_group) self.update_overtime_balance_display() # 휴가 설정 leave_group = self.create_leave_group() layout.addWidget(leave_group) # 목표 설정 그룹 goal_group = self.create_goal_group() layout.addWidget(goal_group) # 공휴일 설정 holiday_group = self.create_holiday_group() layout.addWidget(holiday_group) # 데이터 관리 data_group = self.create_data_group() layout.addWidget(data_group) layout.addStretch() scroll_content.setLayout(layout) scroll_area.setWidget(scroll_content) main_layout.addWidget(scroll_area) # 버튼들 (스크롤 영역 밖에) button_layout = QHBoxLayout() button_layout.setContentsMargins(20, 10, 20, 20) save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.setMinimumHeight(40) save_button.clicked.connect(self.save_settings) button_layout.addWidget(save_button) close_button = QPushButton(tr('btn.close')) close_button.setMinimumHeight(40) close_button.clicked.connect(self.close) button_layout.addWidget(close_button) main_layout.addLayout(button_layout) self.setLayout(main_layout) def create_work_time_group(self) -> QGroupBox: """근무 시간 설정 그룹""" group = QGroupBox(tr('group.work_time')) layout = QVBoxLayout() layout.setSpacing(8) # 근무 패턴 프리셋 preset_layout = QHBoxLayout() preset_label = QLabel(tr('settings.work_pattern')) preset_label.setFixedWidth(130) self.work_preset_combo = QComboBox() # (label, work_minutes, lunch_minutes) self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60)) self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30)) self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60)) self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30)) self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0)) self.work_preset_combo.addItem(tr('settings.preset.custom'), None) self.work_preset_combo.setFixedWidth(260) self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed) preset_layout.addWidget(preset_label) preset_layout.addWidget(self.work_preset_combo) preset_layout.addStretch() layout.addLayout(preset_layout) # 하루 기본 근무 시간 (시 + 분 분리 입력) work_hours_layout = QHBoxLayout() work_hours_label = QLabel(tr('settings.daily_work')) work_hours_label.setFixedWidth(130) self.work_hours_spin = QSpinBox() self.work_hours_spin.setRange(0, 12) self.work_hours_spin.setValue(8) self.work_hours_spin.setSuffix(tr('settings.suffix_hour')) self.work_hours_spin.setFixedWidth(100) self.work_minutes_spin = QSpinBox() self.work_minutes_spin.setRange(0, 59) self.work_minutes_spin.setValue(0) self.work_minutes_spin.setSingleStep(15) self.work_minutes_spin.setSuffix(tr('settings.suffix_minute')) self.work_minutes_spin.setFixedWidth(100) # 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로 self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit) self.work_minutes_spin.valueChanged.connect(self._on_work_time_user_edit) work_hours_layout.addWidget(work_hours_label) work_hours_layout.addWidget(self.work_hours_spin) work_hours_layout.addWidget(self.work_minutes_spin) work_hours_layout.addStretch() layout.addLayout(work_hours_layout) # 점심시간 기본값 lunch_layout = QHBoxLayout() lunch_label = QLabel(tr('settings.lunch_default')) lunch_label.setFixedWidth(130) self.lunch_spin = QSpinBox() self.lunch_spin.setRange(0, 120) self.lunch_spin.setValue(60) self.lunch_spin.setSingleStep(5) self.lunch_spin.setSuffix(tr('settings.suffix_minute')) self.lunch_spin.setFixedWidth(110) self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit) lunch_layout.addWidget(lunch_label) lunch_layout.addWidget(self.lunch_spin) # 점심시간 자동 적용 self.auto_lunch_check = QCheckBox(tr('settings.auto_apply')) self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip')) lunch_layout.addWidget(self.auto_lunch_check) lunch_layout.addStretch() layout.addLayout(lunch_layout) # 저녁시간 기본값 dinner_layout = QHBoxLayout() dinner_label = QLabel(tr('settings.dinner_default')) dinner_label.setFixedWidth(130) self.dinner_spin = QSpinBox() self.dinner_spin.setRange(0, 120) self.dinner_spin.setValue(60) self.dinner_spin.setSingleStep(5) self.dinner_spin.setSuffix(tr('settings.suffix_minute')) self.dinner_spin.setFixedWidth(110) dinner_layout.addWidget(dinner_label) dinner_layout.addWidget(self.dinner_spin) dinner_layout.addStretch() layout.addLayout(dinner_layout) group.setLayout(layout) return group def on_preset_changed(self, index): """근무 패턴 프리셋 변경 시 시간/분/점심 자동 입력""" data = self.work_preset_combo.itemData(index) if data is None: # 사용자 정의: 입력값 유지 return work_minutes, lunch_minutes = data # 시그널 차단으로 _on_work_time_user_edit가 다시 프리셋을 바꾸지 않도록 self.work_hours_spin.blockSignals(True) self.work_minutes_spin.blockSignals(True) self.lunch_spin.blockSignals(True) try: self.work_hours_spin.setValue(work_minutes // 60) self.work_minutes_spin.setValue(work_minutes % 60) self.lunch_spin.setValue(lunch_minutes) finally: self.work_hours_spin.blockSignals(False) self.work_minutes_spin.blockSignals(False) self.lunch_spin.blockSignals(False) def _on_work_time_user_edit(self, *_): """사용자가 시간/분/점심을 직접 수정하면 프리셋 콤보를 '사용자 정의'로 전환""" if not hasattr(self, '_settings_loaded'): return # 현재 값과 일치하는 프리셋이 있는지 확인 current = ( self.work_hours_spin.value() * 60 + self.work_minutes_spin.value(), self.lunch_spin.value() ) for i in range(self.work_preset_combo.count()): data = self.work_preset_combo.itemData(i) if data == current: if self.work_preset_combo.currentIndex() != i: self.work_preset_combo.blockSignals(True) self.work_preset_combo.setCurrentIndex(i) self.work_preset_combo.blockSignals(False) return # 일치하는 프리셋 없음 → 사용자 정의 custom_index = self.work_preset_combo.count() - 1 if self.work_preset_combo.currentIndex() != custom_index: self.work_preset_combo.blockSignals(True) self.work_preset_combo.setCurrentIndex(custom_index) self.work_preset_combo.blockSignals(False) def _load_threshold_safely(self, settings: dict, setting_key: str, attr_name: str, default: int) -> None: """settings dict에서 임계값을 읽어 SpinBox에 안전하게 적용. get_settings() 결과는 이미 타입 변환된 dict이라 추가 DB 호출 없이 사용. """ if not hasattr(self, attr_name): return spin = getattr(self, attr_name) try: val = int(settings.get(setting_key, default)) except (ValueError, TypeError): val = default # 이미 설정된 setRange를 존중 — 사용자 옛 값이 범위 밖이면 클램프 spin.setValue(max(spin.minimum(), min(spin.maximum(), val))) @staticmethod def _make_threshold_spin(lo: int, hi: int, default: int, suffix: str) -> QSpinBox: """고급 임계값용 표준 SpinBox.""" sb = QSpinBox() sb.setRange(lo, hi) sb.setValue(default) sb.setSuffix(suffix) sb.setFixedWidth(110) return sb @staticmethod def _labeled_row(label_text: str, widget) -> QHBoxLayout: """좌측 라벨(고정 폭) + 위젯 + 오른쪽 stretch 한 줄 레이아웃.""" row = QHBoxLayout() lbl = QLabel(label_text) lbl.setFixedWidth(140) row.addWidget(lbl) row.addWidget(widget) row.addStretch() return row def create_notification_group(self) -> QGroupBox: """알림 설정 그룹""" group = QGroupBox(tr('group.notification')) layout = QVBoxLayout() layout.setSpacing(6) # 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개) check_row1 = QHBoxLayout() self.clock_out_notification_check = QCheckBox(tr('settings.notif_clock_out')) self.clock_out_notification_check.setChecked(True) self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch')) self.lunch_notification_check.setChecked(True) check_row1.addWidget(self.clock_out_notification_check) check_row1.addWidget(self.lunch_notification_check) layout.addLayout(check_row1) check_row2 = QHBoxLayout() self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner')) self.dinner_notification_check.setChecked(True) self.overtime_notification_check = QCheckBox(tr('settings.notif_overtime')) self.overtime_notification_check.setChecked(True) check_row2.addWidget(self.dinner_notification_check) check_row2.addWidget(self.overtime_notification_check) layout.addLayout(check_row2) check_row3 = QHBoxLayout() self.health_notification_check = QCheckBox(tr('settings.notif_health')) self.health_notification_check.setChecked(True) self.health_break_notification_check = QCheckBox(tr('settings.notif_break')) self.health_break_notification_check.setChecked(True) self.health_break_notification_check.setToolTip( tr('settings.notif_break_tooltip') ) check_row3.addWidget(self.health_notification_check) check_row3.addWidget(self.health_break_notification_check) layout.addLayout(check_row3) # 퇴근 N분 전 알림 시점 설정 before_row = QHBoxLayout() before_label = QLabel(tr('settings.notif_before')) before_label.setFixedWidth(110) self.notif_before_spin = QSpinBox() self.notif_before_spin.setRange(1, 120) self.notif_before_spin.setSingleStep(5) self.notif_before_spin.setValue(30) self.notif_before_spin.setSuffix(' ' + tr('settings.notif_before_spin_suffix')) self.notif_before_spin.setFixedWidth(110) self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip')) before_row.addWidget(before_label) before_row.addWidget(self.notif_before_spin) before_row.addStretch() layout.addLayout(before_row) # === 고급 임계값 (접이식 그룹박스) === adv_box = QGroupBox(tr('settings.advanced_thresholds')) adv_box.setCheckable(True) adv_box.setChecked(False) # 기본 접힘 adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip')) adv_layout = QVBoxLayout() adv_layout.setSpacing(4) # 점심 알림 임계 (출근 후 N시간) self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour')) self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip')) adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin)) # 저녁 알림 임계 (출근 후 N시간) self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour')) self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip')) adv_layout.addLayout(self._labeled_row(tr('settings.dinner_alert_after'), self.dinner_reminder_spin)) # 연장근무 누적 임계 self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour')) self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip')) adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin)) # 주 X시간 임계 self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour')) self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip')) adv_layout.addLayout(self._labeled_row(tr('settings.weekly_limit'), self.weekly_hours_spin)) # 연속 연장근무 일수 self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day')) self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback='')) adv_layout.addLayout(self._labeled_row(tr('settings.consecutive_ot'), self.health_consecutive_spin)) # 휴식 권고 (연속 근무 시간) self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour')) self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip')) adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin)) adv_box.setLayout(adv_layout) layout.addWidget(adv_box) # 시간 형식 + 테마 한 줄에 format_row = QHBoxLayout() time_format_label = QLabel(tr('settings.time_format')) time_format_label.setFixedWidth(70) self.time_format_combo = QComboBox() self.time_format_combo.addItem(tr('settings.time_format_24'), "24") self.time_format_combo.addItem(tr('settings.time_format_12'), "12") self.time_format_combo.setFixedWidth(180) theme_label = QLabel(tr('settings.theme')) theme_label.setFixedWidth(40) self.theme_combo = QComboBox() self.theme_combo.addItem(tr('settings.theme_light'), "light") self.theme_combo.addItem(tr('settings.theme_dark'), "dark") self.theme_combo.setFixedWidth(90) self.theme_combo.currentIndexChanged.connect(self.on_theme_changed) format_row.addWidget(time_format_label) format_row.addWidget(self.time_format_combo) format_row.addSpacing(16) format_row.addWidget(theme_label) format_row.addWidget(self.theme_combo) format_row.addStretch() layout.addLayout(format_row) # 접근성: 글꼴 크기 + 고대비 a11y_row = QHBoxLayout() a11y_row.addWidget(QLabel(tr('settings.font_scale'))) self.font_scale_combo = QComboBox() self.font_scale_combo.addItem("100%", "1.0") self.font_scale_combo.addItem("125%", "1.25") self.font_scale_combo.addItem("150%", "1.5") self.font_scale_combo.setFixedWidth(90) a11y_row.addWidget(self.font_scale_combo) a11y_row.addSpacing(16) self.high_contrast_check = QCheckBox(tr('settings.high_contrast')) self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip')) a11y_row.addWidget(self.high_contrast_check) a11y_row.addStretch() layout.addLayout(a11y_row) # 언어 선택 from core.i18n import available_languages, language_label lang_row = QHBoxLayout() lang_label = QLabel(tr('label.language')) lang_label.setFixedWidth(120) self.language_combo = QComboBox() for code in available_languages(): self.language_combo.addItem(language_label(code), code) self.language_combo.setFixedWidth(140) self.language_combo.setToolTip(tr('group.language_restart_tooltip', fallback='')) lang_row.addWidget(lang_label) lang_row.addWidget(self.language_combo) lang_row.addStretch() layout.addLayout(lang_row) group.setLayout(layout) return group def create_overtime_group(self) -> QGroupBox: """연장근무 설정 그룹""" group = QGroupBox(tr('group.overtime')) layout = QVBoxLayout() layout.setSpacing(6) # 잔액 + 계산 단위 한 줄 top_row = QHBoxLayout() self.current_overtime_label = QLabel(tr('settings.current_balance')) self.current_overtime_label.setObjectName("badge_success") top_row.addWidget(self.current_overtime_label) top_row.addStretch() unit_label = QLabel(tr('settings.calc_unit')) self.overtime_unit_combo = QComboBox() self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30) self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60) self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15) self.overtime_unit_combo.setFixedWidth(100) top_row.addWidget(unit_label) top_row.addWidget(self.overtime_unit_combo) layout.addLayout(top_row) # 초기 연장근무 설정 initial_overtime_layout = QHBoxLayout() initial_overtime_label = QLabel(tr('settings.initial_overtime')) initial_overtime_label.setFixedWidth(100) self.initial_overtime_hours = QSpinBox() self.initial_overtime_hours.setRange(0, 200) self.initial_overtime_hours.setValue(0) self.initial_overtime_hours.setSuffix(tr('settings.suffix_hour')) self.initial_overtime_hours.setFixedWidth(110) self.initial_overtime_mins = QSpinBox() self.initial_overtime_mins.setRange(0, 59) self.initial_overtime_mins.setValue(0) self.initial_overtime_mins.setSuffix(tr('settings.suffix_minute')) self.initial_overtime_mins.setFixedWidth(100) apply_overtime_btn = QPushButton(tr('btn.apply')) apply_overtime_btn.setObjectName("btn_small") apply_overtime_btn.setFixedWidth(50) apply_overtime_btn.clicked.connect(self.apply_initial_overtime) self.auto_overtime_check = QCheckBox(tr('settings.auto_bank')) self.auto_overtime_check.setChecked(True) self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip')) initial_overtime_layout.addWidget(initial_overtime_label) initial_overtime_layout.addWidget(self.initial_overtime_hours) initial_overtime_layout.addWidget(self.initial_overtime_mins) initial_overtime_layout.addWidget(apply_overtime_btn) initial_overtime_layout.addStretch() initial_overtime_layout.addWidget(self.auto_overtime_check) layout.addLayout(initial_overtime_layout) initial_overtime_note = QLabel(tr('settings.initial_overtime_note', fallback='')) initial_overtime_note.setObjectName("note_text") layout.addWidget(initial_overtime_note) group.setLayout(layout) return group def create_goal_group(self) -> QGroupBox: """월간 목표 설정 그룹 (0=비활성).""" group = QGroupBox(tr('settings.goal_group')) layout = QVBoxLayout() layout.setSpacing(6) # 연장근무 상한 ot_row = QHBoxLayout() ot_label = QLabel(tr('settings.monthly_ot_cap')) ot_label.setFixedWidth(150) self.goal_ot_h = QSpinBox() self.goal_ot_h.setRange(0, 100) self.goal_ot_h.setSuffix(tr('settings.suffix_hour')) self.goal_ot_h.setFixedWidth(100) self.goal_ot_m = QSpinBox() self.goal_ot_m.setRange(0, 59) self.goal_ot_m.setSingleStep(30) self.goal_ot_m.setSuffix(tr('settings.suffix_minute')) self.goal_ot_m.setFixedWidth(90) ot_row.addWidget(ot_label) ot_row.addWidget(self.goal_ot_h) ot_row.addWidget(self.goal_ot_m) ot_row.addStretch() layout.addLayout(ot_row) # 일평균 목표 avg_row = QHBoxLayout() avg_label = QLabel(tr('settings.daily_avg_goal')) avg_label.setFixedWidth(150) self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식 self.goal_avg.setRange(0, 24) self.goal_avg.setSuffix(tr('settings.suffix_hour')) self.goal_avg.setFixedWidth(100) avg_row.addWidget(avg_label) avg_row.addWidget(self.goal_avg) avg_row.addStretch() layout.addLayout(avg_row) note = QLabel(tr('settings.goal_note', fallback='')) note.setObjectName("note_text") layout.addWidget(note) group.setLayout(layout) return group def create_leave_group(self) -> QGroupBox: """휴가 설정 그룹""" group = QGroupBox(tr('group.leave')) layout = QVBoxLayout() layout.setSpacing(6) # 연차 개수 + 남은 연차 한 줄 top_row = QHBoxLayout() annual_leave_label = QLabel(tr('settings.annual_leave')) annual_leave_label.setFixedWidth(70) self.annual_leave_days = QSpinBox() self.annual_leave_days.setRange(0, 30) self.annual_leave_days.setValue(15) self.annual_leave_days.setSuffix(tr('label.unit_day')) self.annual_leave_days.setFixedWidth(100) top_row.addWidget(annual_leave_label) top_row.addWidget(self.annual_leave_days) top_row.addStretch() self.remaining_leave_label = QLabel(tr('settings.remaining_leave')) self.remaining_leave_label.setObjectName("badge_leave") top_row.addWidget(self.remaining_leave_label) layout.addLayout(top_row) # 기존 사용 연차 설정 used_leave_layout = QHBoxLayout() used_leave_label = QLabel(tr('settings.used_leave')) used_leave_label.setFixedWidth(70) self.used_leave_hours = QSpinBox() self.used_leave_hours.setRange(0, 200) self.used_leave_hours.setValue(0) self.used_leave_hours.setSuffix(tr('settings.suffix_hour')) self.used_leave_hours.setFixedWidth(110) self.used_leave_mins = QSpinBox() self.used_leave_mins.setRange(0, 59) self.used_leave_mins.setValue(0) self.used_leave_mins.setSuffix(tr('settings.suffix_minute')) self.used_leave_mins.setSingleStep(30) self.used_leave_mins.setFixedWidth(100) apply_used_leave_btn = QPushButton(tr('btn.apply')) apply_used_leave_btn.setObjectName("btn_small") apply_used_leave_btn.setFixedWidth(50) apply_used_leave_btn.clicked.connect(self.apply_used_leave) used_leave_layout.addWidget(used_leave_label) used_leave_layout.addWidget(self.used_leave_hours) used_leave_layout.addWidget(self.used_leave_mins) used_leave_layout.addWidget(apply_used_leave_btn) used_leave_layout.addStretch() layout.addLayout(used_leave_layout) used_leave_note = QLabel(tr('settings.used_leave_note', fallback='')) used_leave_note.setObjectName("note_text") layout.addWidget(used_leave_note) group.setLayout(layout) return group def create_holiday_group(self) -> QGroupBox: """공휴일 설정 그룹""" group = QGroupBox(tr('group.holiday')) layout = QVBoxLayout() layout.setSpacing(6) # 공휴일 목록 + 버튼 한 줄 button_layout = QHBoxLayout() holiday_list_label = QLabel(tr('settings.registered')) button_layout.addWidget(holiday_list_label) self.holiday_count_label = QLabel(tr('settings.holiday_count', count=0, year=datetime.now().year)) self.holiday_count_label.setObjectName("info_text") button_layout.addWidget(self.holiday_count_label) button_layout.addStretch() add_korean_btn = QPushButton(tr('settings.add_korean_holidays')) add_korean_btn.setObjectName("btn_small") add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip')) add_korean_btn.clicked.connect(self.add_korean_holidays_auto) button_layout.addWidget(add_korean_btn) add_custom_btn = QPushButton(tr('btn.add')) add_custom_btn.setObjectName("btn_small") add_custom_btn.clicked.connect(self.add_custom_holiday) button_layout.addWidget(add_custom_btn) view_holidays_btn = QPushButton(tr('settings.list')) view_holidays_btn.setObjectName("btn_small") view_holidays_btn.clicked.connect(self.view_holidays) button_layout.addWidget(view_holidays_btn) layout.addLayout(button_layout) holiday_note = QLabel(tr('settings.holiday_note', fallback='')) holiday_note.setObjectName("note_text") layout.addWidget(holiday_note) group.setLayout(layout) # 공휴일 개수 업데이트 self.update_holiday_count() return group def update_holiday_count(self): """공휴일 개수 표시 업데이트""" current_year = datetime.now().year holidays = self.db.get_holidays_by_year(current_year) self.holiday_count_label.setText(tr('settings.holiday_count', count=len(holidays), year=current_year)) def add_korean_holidays_auto(self): """holidays 패키지로 음력/임시 공휴일 포함 자동 추가. 11월 이후 호출 시 자동으로 다음 연도까지 등록 — 연말 경계에서 신정 등이 누락되는 것 방지. """ now = datetime.now() current_year = now.year # 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응) include_next = now.month >= 11 target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1) if include_next else tr('settings.korean_holidays_years_label_single', year=current_year)) reply = QMessageBox.question( self, tr('settings.korean_holidays_title'), tr('settings.korean_holidays_body', years=target_label), QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return added = self.db.add_korean_holidays_auto(current_year, include_next_year=include_next) if added < 0: # 패키지 미설치 시 고정 공휴일로 폴백 self.db.add_korean_holidays(current_year) if include_next: self.db.add_korean_holidays(current_year + 1) self.update_holiday_count() QMessageBox.warning( self, tr('settings.package_not_installed'), tr('settings.package_fallback_body', hint=tr('settings.package_install_hint')) ) return self.update_holiday_count() QMessageBox.information( self, tr('settings.add_done'), tr('settings.korean_holidays_added', year=current_year, count=added) ) def add_custom_holiday(self): """사용자 정의 공휴일 추가""" from PyQt5.QtWidgets import QInputDialog, QLineEdit # 날짜 입력 today = datetime.now().date().isoformat() date_str, ok = QInputDialog.getText( self, tr('settings.holiday_add_title'), tr('settings.holiday_date_prompt'), 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 # 공휴일 이름 입력 name, ok = QInputDialog.getText( self, tr('settings.holiday_add_title'), tr('settings.holiday_name_prompt'), QLineEdit.Normal, "" ) if not ok or not name: return # 공휴일 추가 self.db.add_holiday(date_str, name, is_recurring=False) self.update_holiday_count() QMessageBox.information( self, tr('settings.add_done'), tr('settings.holiday_added', date=date_str, name=name) ) def view_holidays(self): """공휴일 목록 보기""" current_year = datetime.now().year holidays = self.db.get_holidays_by_year(current_year) if not holidays: QMessageBox.information( self, tr('settings.holiday_list_title'), tr('stats.no_data') ) return # 목록 생성 holiday_list = tr('settings.holiday_list_header', year=current_year) for h in holidays: date_obj = datetime.strptime(h['date'], "%Y-%m-%d") weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}") recurring = tr('label.recurring_yearly') if h['is_recurring'] else "" holiday_list += tr('settings.holiday_list_item', date=h['date'], weekday=weekday, name=h['name'], recurring=recurring) holiday_list += '\n' + tr('settings.holiday_total', count=len(holidays)) # 삭제 옵션 제공 reply = QMessageBox.question( self, tr('settings.holiday_list_title'), holiday_list + tr('settings.holiday_delete_confirm'), QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel ) if reply == QMessageBox.Yes: self.delete_holiday_dialog() def delete_holiday_dialog(self): """공휴일 삭제 다이얼로그""" from PyQt5.QtWidgets import QInputDialog current_year = datetime.now().year holidays = self.db.get_holidays_by_year(current_year) if not holidays: return # 공휴일 선택 items = [f"{h['date']}: {h['name']}" for h in holidays] item, ok = QInputDialog.getItem( self, tr('settings.holiday_delete_title'), tr('settings.holiday_delete_prompt'), items, 0, False ) if ok and item: date_str = item.split(":")[0] self.db.delete_holiday_by_date(date_str) self.update_holiday_count() QMessageBox.information(self, tr('settings.delete_done'), tr('settings.holiday_deleted', item=item)) def create_data_group(self) -> QGroupBox: """데이터 관리 그룹""" group = QGroupBox(tr('group.data')) layout = QVBoxLayout() layout.setSpacing(6) # CSV 내보내기 버튼들 한 줄 export_layout = QHBoxLayout() export_work_btn = QPushButton(tr('settings.export_work')) export_work_btn.setObjectName("btn_small") export_work_btn.clicked.connect(self.export_work_records) export_layout.addWidget(export_work_btn) export_overtime_btn = QPushButton(tr('settings.export_overtime')) export_overtime_btn.setObjectName("btn_small") export_overtime_btn.clicked.connect(self.export_overtime_summary) export_layout.addWidget(export_overtime_btn) monthly_btn = QPushButton(tr('settings.export_monthly')) monthly_btn.setObjectName("btn_small") monthly_btn.clicked.connect(self.export_monthly_summary) export_layout.addWidget(monthly_btn) export_label = QLabel(tr('settings.export_csv')) export_label.setObjectName("note_text") export_layout.addWidget(export_label) export_layout.addStretch() layout.addLayout(export_layout) # CSV 가져오기 import_layout = QHBoxLayout() import_btn = QPushButton(tr('settings.import_csv')) import_btn.setObjectName("btn_small") import_btn.setToolTip(tr('settings.import_tooltip')) import_btn.clicked.connect(self._import_csv) import_layout.addWidget(import_btn) import_label = QLabel(tr('settings.import_format')) import_label.setObjectName("note_text") import_layout.addWidget(import_label) import_layout.addStretch() layout.addLayout(import_layout) # DB 경로 설정 (클라우드 동기화 가능) db_path_layout = QHBoxLayout() db_path_label = QLabel(tr('settings.db_path_label')) db_path_label.setFixedWidth(60) self.db_path_edit = QLineEdit() self.db_path_edit.setReadOnly(True) self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db') db_path_btn = QPushButton(tr('settings.change')) db_path_btn.setObjectName("btn_small") db_path_btn.setToolTip(tr('settings.db_path_tooltip')) db_path_btn.clicked.connect(self._change_db_path) db_path_layout.addWidget(db_path_label) db_path_layout.addWidget(self.db_path_edit, 1) db_path_layout.addWidget(db_path_btn) layout.addLayout(db_path_layout) # 자동 외출 (화면 잠금 시) self.auto_break_check = QCheckBox(tr('settings.auto_break_lock')) self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback='')) layout.addWidget(self.auto_break_check) # Gitea 피드백 토큰 (옵션, crash 자동 보고용) feedback_layout = QHBoxLayout() feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback='')) feedback_label.setFixedWidth(80) self.gitea_token_edit = QLineEdit() self.gitea_token_edit.setEchoMode(QLineEdit.Password) self.gitea_token_edit.setPlaceholderText(tr('settings.gitea_token_placeholder', fallback='')) feedback_layout.addWidget(feedback_label) feedback_layout.addWidget(self.gitea_token_edit, 1) layout.addLayout(feedback_layout) self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback')) layout.addWidget(self.gitea_feedback_enabled_check) # 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용) self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock')) self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback='')) layout.addWidget(self.clock_in_unlock_check) # 업데이트 확인 update_layout = QHBoxLayout() from core.version import __version__ version_label = QLabel(tr('settings.version', version=__version__)) version_label.setObjectName("note_text") update_btn = QPushButton(tr('settings.check_update')) update_btn.setObjectName("btn_small") update_btn.clicked.connect(self._check_updates) update_layout.addWidget(version_label) update_layout.addStretch() update_layout.addWidget(update_btn) layout.addLayout(update_layout) group.setLayout(layout) return group def _change_db_path(self): """DB 경로 변경 (재시작 후 적용)""" current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db' new_path, _ = QFileDialog.getSaveFileName( self, tr('settings.select_db'), current, "SQLite Database (*.db)" ) if not new_path: return # 파일 미존재 시 빈 파일 생성하지 않고, 경로만 저장 — 다음 실행 시 새 DB로 init self.db.set_setting(DB_PATH_OVERRIDE, new_path) self.db_path_edit.setText(new_path) QMessageBox.information( self, tr('settings.db_path_label')[:-1], tr('settings.db_path_saved', path=new_path) ) def load_settings(self): """설정 불러오기""" settings = self.db.get_settings() if settings: # 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 work_minutes = int(work_minutes) # blockSignals: load 시 _on_work_time_user_edit가 프리셋을 잘못 전환하지 않도록 self.work_hours_spin.blockSignals(True) self.work_minutes_spin.blockSignals(True) try: self.work_hours_spin.setValue(work_minutes // 60) self.work_minutes_spin.setValue(work_minutes % 60) finally: self.work_hours_spin.blockSignals(False) self.work_minutes_spin.blockSignals(False) lunch_minutes = int(settings.get(LUNCH_DURATION_MINUTES, 60)) self.lunch_spin.blockSignals(True) try: self.lunch_spin.setValue(lunch_minutes) finally: self.lunch_spin.blockSignals(False) # 현재 (work_minutes, lunch_minutes)와 일치하는 프리셋 선택 current = (work_minutes, lunch_minutes) preset_idx = self.work_preset_combo.count() - 1 # 기본값: 사용자 정의 for i in range(self.work_preset_combo.count()): if self.work_preset_combo.itemData(i) == current: preset_idx = i break self.work_preset_combo.blockSignals(True) self.work_preset_combo.setCurrentIndex(preset_idx) self.work_preset_combo.blockSignals(False) self.auto_lunch_check.setChecked(settings.get(AUTO_LUNCH, False)) self.dinner_spin.setValue(int(settings.get(DINNER_DURATION_MINUTES, 60))) # 자동 외출 (화면 잠금) if hasattr(self, 'auto_break_check'): self.auto_break_check.setChecked(settings.get(AUTO_BREAK_ON_LOCK, False)) if hasattr(self, 'clock_in_unlock_check'): self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False)) # 목표 if hasattr(self, 'goal_ot_h'): ot_min = int(settings.get(GOAL_OVERTIME_MAX_MONTHLY, 0) or 0) self.goal_ot_h.setValue(ot_min // 60) self.goal_ot_m.setValue(ot_min % 60) if hasattr(self, 'goal_avg'): self.goal_avg.setValue(int(float(settings.get(GOAL_AVG_HOURS_DAILY, 0) or 0))) # Gitea 피드백 if hasattr(self, 'gitea_token_edit'): self.gitea_token_edit.setText(self.db.get_setting(GITEA_FEEDBACK_TOKEN, '') or '') if hasattr(self, 'gitea_feedback_enabled_check'): self.gitea_feedback_enabled_check.setChecked( settings.get(GITEA_FEEDBACK_ENABLED, False) ) # 접근성 if hasattr(self, 'font_scale_combo'): scale = str(settings.get(FONT_SCALE, '1.0')) idx = self.font_scale_combo.findData(scale) if idx >= 0: self.font_scale_combo.setCurrentIndex(idx) if hasattr(self, 'high_contrast_check'): self.high_contrast_check.setChecked(settings.get(HIGH_CONTRAST, False)) # 알림 self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True)) self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) if hasattr(self, 'dinner_notification_check'): self.dinner_notification_check.setChecked(settings.get(NOTIF_DINNER, True)) self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True)) self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True)) if hasattr(self, 'health_break_notification_check'): self.health_break_notification_check.setChecked( settings.get(HEALTH_BREAK_ENABLED, True)) if hasattr(self, 'notif_before_spin'): try: self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30))) except (ValueError, TypeError): self.notif_before_spin.setValue(30) # 고급 임계값 self._load_threshold_safely(settings, LUNCH_REMINDER_HOURS, 'lunch_reminder_spin', 4) self._load_threshold_safely(settings, DINNER_REMINDER_HOURS, 'dinner_reminder_spin', 8) self._load_threshold_safely(settings, OVERTIME_THRESHOLD_HOURS, 'overtime_threshold_spin', 20) self._load_threshold_safely(settings, WEEKLY_HOURS_THRESHOLD, 'weekly_hours_spin', 52) self._load_threshold_safely(settings, HEALTH_CONSECUTIVE_OT_DAYS, 'health_consecutive_spin', 3) self._load_threshold_safely(settings, HEALTH_BREAK_HOURS, 'health_break_hours_spin', 4) # 시간 형식 (콤보박스는 문자열로 저장하므로 변환) time_format = settings.get(TIME_FORMAT, '24') if isinstance(time_format, int): time_format = str(time_format) index = self.time_format_combo.findData(time_format) if index >= 0: self.time_format_combo.setCurrentIndex(index) # 테마 self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1) # 언어 선택 적용 if hasattr(self, 'language_combo'): lang = settings.get(LANGUAGE, 'ko') or 'ko' idx = self.language_combo.findData(lang) if idx >= 0: self.language_combo.setCurrentIndex(idx) # 연장근무 (콤보박스는 정수로 저장) overtime_unit = settings.get(OVERTIME_UNIT, 30) if isinstance(overtime_unit, str): overtime_unit = int(overtime_unit) index = self.overtime_unit_combo.findData(overtime_unit) if index >= 0: self.overtime_unit_combo.setCurrentIndex(index) self.auto_overtime_check.setChecked(settings.get(AUTO_OVERTIME, True)) # 휴가 self.annual_leave_days.setValue(settings.get(ANNUAL_LEAVE_DAYS, 15)) # 기존 연장근무 초기값 로드 (settings에서) initial_overtime = int(self.db.get_setting(INITIAL_OVERTIME_MINUTES, '0')) self.initial_overtime_hours.setValue(initial_overtime // 60) self.initial_overtime_mins.setValue(initial_overtime % 60) # 기존 사용 연차 초기값 로드 (settings에서) initial_leave_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) self.used_leave_hours.setValue(int(initial_leave_hours)) self.used_leave_mins.setValue(int((initial_leave_hours % 1) * 60)) # 남은 연차 계산 self.update_remaining_leave() def _import_csv(self): """CSV 파일에서 근무 기록 일괄 가져오기.""" path, _ = QFileDialog.getOpenFileName( self, tr('settings.import_csv'), os.path.expanduser("~"), "CSV files (*.csv);;All files (*.*)", ) if not path: return try: from utils.csv_importer import parse_csv, import_records rows = parse_csv(path) except (FileNotFoundError, ValueError) as e: QMessageBox.critical(self, tr('settings.parse_failed'), str(e)) return if not rows: QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body')) return reply = QMessageBox.question( self, tr('settings.conflict_title'), tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'), QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, ) if reply == QMessageBox.Cancel: return policy = 'overwrite' if reply == QMessageBox.Yes else 'skip' try: added, updated, skipped = import_records(self.db, rows, on_conflict=policy) except Exception as e: QMessageBox.critical(self, tr('settings.import_failed'), str(e)) return QMessageBox.information( self, tr('settings.import_complete'), tr('settings.import_result', added=added, updated=updated, skipped=skipped) ) def _check_updates(self): """설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임.""" if self.parent_window and hasattr(self.parent_window, 'check_for_updates'): self.parent_window.check_for_updates(silent=False) def _restart_app(self): """언어 변경 후 자동 재시작 (사용자 명시 동의 시).""" from PyQt5.QtWidgets import QApplication import sys, os QApplication.quit() # 빌드된 exe / 개발 환경 모두 처리 if getattr(sys, 'frozen', False): os.execv(sys.executable, [sys.executable]) else: os.execv(sys.executable, [sys.executable, *sys.argv]) def save_settings(self): """설정 저장""" # 점심시간은 분 단위 그대로 저장 lunch_minutes = self.lunch_spin.value() # 저녁시간은 분 단위 그대로 저장 dinner_minutes = self.dinner_spin.value() work_minutes_total = self.work_hours_spin.value() * 60 + self.work_minutes_spin.value() if work_minutes_total < 30: QMessageBox.warning(self, tr('msg.input_error.title'), tr('msg.work_min_too_small')) return # work_hours는 db.save_settings()가 자동 동기화하므로 보내지 않음 (단일 진실 소스) settings = { WORK_MINUTES: work_minutes_total, LUNCH_DURATION_MINUTES: lunch_minutes, DINNER_DURATION_MINUTES: dinner_minutes, AUTO_LUNCH: self.auto_lunch_check.isChecked(), NOTIF_CLOCK_OUT: self.clock_out_notification_check.isChecked(), NOTIF_LUNCH: self.lunch_notification_check.isChecked(), NOTIF_OVERTIME: self.overtime_notification_check.isChecked(), NOTIF_HEALTH: self.health_notification_check.isChecked(), NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(), # 고급 임계값 LUNCH_REMINDER_HOURS: self.lunch_reminder_spin.value(), DINNER_REMINDER_HOURS: self.dinner_reminder_spin.value(), OVERTIME_THRESHOLD_HOURS: self.overtime_threshold_spin.value(), WEEKLY_HOURS_THRESHOLD: self.weekly_hours_spin.value(), HEALTH_CONSECUTIVE_OT_DAYS: self.health_consecutive_spin.value(), HEALTH_BREAK_HOURS: self.health_break_hours_spin.value(), TIME_FORMAT: self.time_format_combo.currentData(), OVERTIME_UNIT: self.overtime_unit_combo.currentData(), AUTO_OVERTIME: self.auto_overtime_check.isChecked(), ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(), } if hasattr(self, 'dinner_notification_check'): settings[NOTIF_DINNER] = self.dinner_notification_check.isChecked() if hasattr(self, 'health_break_notification_check'): settings[HEALTH_BREAK_ENABLED] = self.health_break_notification_check.isChecked() if hasattr(self, 'auto_break_check'): settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked() if hasattr(self, 'clock_in_unlock_check'): settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked() if hasattr(self, 'goal_ot_h'): settings[GOAL_OVERTIME_MAX_MONTHLY] = self.goal_ot_h.value() * 60 + self.goal_ot_m.value() if hasattr(self, 'goal_avg'): settings[GOAL_AVG_HOURS_DAILY] = self.goal_avg.value() if hasattr(self, 'gitea_token_edit'): self.db.set_setting(GITEA_FEEDBACK_TOKEN, self.gitea_token_edit.text().strip()) if hasattr(self, 'gitea_feedback_enabled_check'): settings[GITEA_FEEDBACK_ENABLED] = self.gitea_feedback_enabled_check.isChecked() if hasattr(self, 'font_scale_combo'): settings[FONT_SCALE] = self.font_scale_combo.currentData() if hasattr(self, 'high_contrast_check'): settings[HIGH_CONTRAST] = self.high_contrast_check.isChecked() if hasattr(self, 'language_combo'): settings[LANGUAGE] = self.language_combo.currentData() self.db.save_settings(settings) # 테마 저장 self.db.set_setting(THEME, self.theme_combo.currentData()) # 연차 잔액 재계산 (총 연차 - 올해 사용한 연차) current_year = datetime.now().year all_leaves = self.db.get_all_leave_records(limit=365) year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] used_annual_days = sum(r['days'] for r in year_leaves) new_balance = settings[ANNUAL_LEAVE_DAYS] - used_annual_days self.db.set_leave_balance(new_balance) QMessageBox.information( self, tr('settings.save_done'), tr('settings.save_done_body') ) # 부모 윈도우에 설정 변경 알림 if self.parent_window and hasattr(self.parent_window, 'reload_settings'): self.parent_window.reload_settings() # 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장 if hasattr(self, 'language_combo'): from core.i18n import get_language from ui.i18n_runtime import set_language_and_retranslate new_lang = self.language_combo.currentData() if new_lang and new_lang != get_language(): set_language_and_retranslate(new_lang) reply = QMessageBox.question( self, tr('settings.restart_title'), tr('settings.restart_body'), QMessageBox.Yes | QMessageBox.No, ) if reply == QMessageBox.Yes: self._restart_app() def on_theme_changed(self, index): """테마 콤보박스 변경 시 즉시 적용""" if not hasattr(self, '_settings_loaded'): return theme_name = self.theme_combo.currentData() if theme_name: # DB에 저장 self.db.set_setting(THEME, theme_name) # 부모 윈도우를 통해 테마 적용 (setStyleSheet이 메인 윈도우에 설정되므로) if self.parent_window and hasattr(self.parent_window, 'apply_theme'): self.parent_window.apply_theme(theme_name) def apply_initial_overtime(self): """기존 연장근무 설정 (프로그램 사용 전 쌓인 시간 - 절대값)""" try: hours = self.initial_overtime_hours.value() mins = self.initial_overtime_mins.value() new_initial_minutes = hours * 60 + mins # 기존 초기값 조회 old_initial_minutes = int(self.db.get_setting(INITIAL_OVERTIME_MINUTES, '0')) old_hours = old_initial_minutes // 60 old_mins = old_initial_minutes % 60 reply = QMessageBox.question( self, tr('settings.initial_overtime_title'), tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # settings에 초기값 저장 self.db.set_setting(INITIAL_OVERTIME_MINUTES,str(new_initial_minutes)) QMessageBox.information( self, tr('settings.initial_overtime_done'), tr('settings.initial_overtime_done_body', hours=hours, mins=mins) ) # 부모 윈도우 잔액 업데이트 if self.parent_window and hasattr(self.parent_window, 'update_overtime_balance'): self.parent_window.update_overtime_balance() # 현재 창의 잔액 표시도 업데이트 self.update_overtime_balance_display() except Exception as e: QMessageBox.critical( self, tr('settings.error'), tr('settings.initial_overtime_error', error=str(e)) ) def update_overtime_balance_display(self): """연장근무 잔액 표시 업데이트""" balance_minutes = self.db.get_total_overtime_balance() hours = balance_minutes // 60 minutes = balance_minutes % 60 self.current_overtime_label.setText(tr('settings.current_overtime_balance', hours=hours, minutes=minutes, balance=balance_minutes)) def update_remaining_leave(self): """남은 연차 계산 및 표시""" # 기존 사용 연차 초기값 (프로그램 사용 전) initial_leave_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) initial_leave_days = initial_leave_hours / 8.0 # 올해 사용한 연차 조회 (프로그램에서 기록된 것) current_year = datetime.now().year all_leaves = self.db.get_all_leave_records(limit=365) year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] # 모든 연차 타입 합산 (days 필드 사용) used_annual_days = sum(r['days'] for r in year_leaves) # 총 사용량 = 초기값 + 프로그램에서 기록된 것 total_used = initial_leave_days + used_annual_days # 총 연차 total_annual = self.annual_leave_days.value() # 남은 연차 remaining = total_annual - total_used self.remaining_leave_label.setText( tr('settings.remaining_leave_fmt', remaining=remaining, total=total_annual, used=total_used) ) def export_work_records(self): """근무 기록 내보내기""" # 내보낼 기록 가져오기 now = datetime.now() stats = self.db.get_monthly_stats(now.year, now.month) records = stats.get('records', []) if not records: QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records')) return # 파일 경로 선택 default_filename = f"work_records_{now.year}{now.month:02d}.csv" filename, _ = QFileDialog.getSaveFileName( self, tr('settings.save_work_title'), default_filename, "CSV Files (*.csv)" ) if filename: try: saved_path = CSVExporter.export_work_records(records, filename, db=self.db) QMessageBox.information( self, tr('settings.export_done'), tr('settings.work_exported', path=saved_path) ) except Exception as e: QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e))) def export_overtime_summary(self): """연장근무 내역 내보내기""" filename, _ = QFileDialog.getSaveFileName( self, tr('settings.save_ot_title'), f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv", "CSV Files (*.csv)" ) if filename: try: saved_path = CSVExporter.export_overtime_summary(self.db, filename) QMessageBox.information( self, tr('settings.export_done'), tr('settings.ot_exported', path=saved_path) ) except Exception as e: QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e))) def export_monthly_summary(self): """월간 요약 내보내기""" now = datetime.now() filename, _ = QFileDialog.getSaveFileName( self, tr('settings.save_monthly_title'), f"monthly_summary_{now.year}{now.month:02d}.csv", "CSV Files (*.csv)" ) if filename: try: saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename) QMessageBox.information( self, tr('settings.export_done'), tr('settings.monthly_exported', path=saved_path) ) except Exception as e: QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e))) def apply_used_leave(self): """기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)""" hours = self.used_leave_hours.value() mins = self.used_leave_mins.value() new_initial_hours = hours + (mins / 60.0) # 기존 초기값 조회 old_initial_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) old_hours = int(old_initial_hours) old_mins = int((old_initial_hours % 1) * 60) reply = QMessageBox.question( self, tr('settings.initial_leave_title'), tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # settings에 초기값 저장 self.db.set_setting(INITIAL_LEAVE_USED_HOURS,str(new_initial_hours)) QMessageBox.information( self, tr('settings.initial_leave_done'), tr('settings.initial_leave_done_body', hours=hours, mins=mins) ) # 남은 연차 재계산 self.update_remaining_leave() # 부모 윈도우의 연차 잔액도 업데이트 if self.parent_window and hasattr(self.parent_window, 'update_leave_balance'): self.parent_window.update_leave_balance() def add_past_leave_usage(self): """이전 사용 연차 기록하기""" dialog = AddLeaveDialog(self, self.db) if dialog.exec_() == QDialog.Accepted: # 남은 연차 재계산 self.update_remaining_leave() # 부모 윈도우의 연차 잔액도 업데이트 if self.parent_window and hasattr(self.parent_window, 'update_leave_balance'): self.parent_window.update_leave_balance() # 테스트 코드 if __name__ == "__main__": from PyQt5.QtWidgets import QApplication app = QApplication(sys.argv) dialog = SettingsView() dialog.exec_()