""" 설정 뷰 """ 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_OVERTIME, NOTIF_HEALTH, NOTIFICATION_BEFORE_MINUTES, 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, ) 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("설정") 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("근무 패턴:") preset_label.setFixedWidth(130) self.work_preset_combo = QComboBox() # (label, work_minutes, lunch_minutes) self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60)) self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30)) self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60)) self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30)) self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0)) self.work_preset_combo.addItem("사용자 정의", 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("하루 기본 근무:") 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(" 시간") 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(" 분") 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("점심시간 기본:") 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(" 분") 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("자동 적용") self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용") lunch_layout.addWidget(self.auto_lunch_check) lunch_layout.addStretch() layout.addLayout(lunch_layout) # 저녁시간 기본값 dinner_layout = QHBoxLayout() dinner_label = QLabel("저녁시간 기본:") 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(" 분") 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 create_notification_group(self) -> QGroupBox: """알림 설정 그룹""" group = QGroupBox(tr('group.notification')) layout = QVBoxLayout() layout.setSpacing(6) # 알림 체크박스들을 2열로 배치 check_row1 = QHBoxLayout() self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림") self.clock_out_notification_check.setChecked(True) self.lunch_notification_check = QCheckBox("점심시간 등록 알림") 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.overtime_notification_check = QCheckBox("연장근무 적립 알림") self.overtime_notification_check.setChecked(True) self.health_notification_check = QCheckBox("건강 경고 알림") self.health_notification_check.setChecked(True) check_row2.addWidget(self.overtime_notification_check) check_row2.addWidget(self.health_notification_check) layout.addLayout(check_row2) # 퇴근 N분 전 알림 시점 설정 before_row = QHBoxLayout() before_label = QLabel("퇴근 알림 시점:") 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(" 분 전") self.notif_before_spin.setFixedWidth(110) self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)") before_row.addWidget(before_label) before_row.addWidget(self.notif_before_spin) before_row.addStretch() layout.addLayout(before_row) # 시간 형식 + 테마 한 줄에 format_row = QHBoxLayout() time_format_label = QLabel("시간 형식:") time_format_label.setFixedWidth(70) self.time_format_combo = QComboBox() self.time_format_combo.addItem("24시간 (17:30)", "24") self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12") self.time_format_combo.setFixedWidth(180) theme_label = QLabel("테마:") theme_label.setFixedWidth(40) self.theme_combo = QComboBox() self.theme_combo.addItem("라이트", "light") self.theme_combo.addItem("다크", "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) # 언어 선택 from core.i18n import available_languages, language_label lang_row = QHBoxLayout() lang_label = QLabel("언어 / 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("언어 변경은 재시작 후 완전히 적용됩니다.") 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("현재 잔액: 계산 중...") self.current_overtime_label.setObjectName("badge_success") top_row.addWidget(self.current_overtime_label) top_row.addStretch() unit_label = QLabel("계산 단위:") self.overtime_unit_combo = QComboBox() self.overtime_unit_combo.addItem("30분", 30) self.overtime_unit_combo.addItem("1시간", 60) self.overtime_unit_combo.addItem("15분", 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("기존 연장근무:") 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(" 시간") 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(" 분") self.initial_overtime_mins.setFixedWidth(100) apply_overtime_btn = QPushButton("적용") 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("자동 적립") self.auto_overtime_check.setChecked(True) self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립") 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("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)") 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("🎯 월간 목표 (0=비활성)") layout = QVBoxLayout() layout.setSpacing(6) # 연장근무 상한 ot_row = QHBoxLayout() ot_label = QLabel("월 연장근무 상한:") ot_label.setFixedWidth(150) self.goal_ot_h = QSpinBox() self.goal_ot_h.setRange(0, 100) self.goal_ot_h.setSuffix(" 시간") 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(" 분") 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("일 평균 근무 목표:") avg_label.setFixedWidth(150) self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식 self.goal_avg.setRange(0, 24) self.goal_avg.setSuffix(" 시간") 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("※ 통계 → 월간 탭에서 진행률 확인") 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("연간 연차:") 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(" 일") 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("남은 연차: 계산 중...") 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("기존 사용:") 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(" 시간") 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(" 분") self.used_leave_mins.setSingleStep(30) self.used_leave_mins.setFixedWidth(100) apply_used_leave_btn = QPushButton("적용") 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("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)") 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("등록:") button_layout.addWidget(holiday_list_label) self.holiday_count_label = QLabel("0개") self.holiday_count_label.setObjectName("info_text") button_layout.addWidget(self.holiday_count_label) button_layout.addStretch() add_korean_btn = QPushButton("한국 공휴일 (자동)") add_korean_btn.setObjectName("btn_small") add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록") add_korean_btn.clicked.connect(self.add_korean_holidays_auto) button_layout.addWidget(add_korean_btn) add_custom_btn = QPushButton("추가") 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("목록") 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("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다") 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(f"{len(holidays)}개 ({current_year}년)") def add_korean_holidays_auto(self): """holidays 패키지로 음력/임시 공휴일 포함 자동 추가""" current_year = datetime.now().year reply = QMessageBox.question( self, "한국 공휴일 자동 추가", f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n" "포함:\n" "• 양력 공휴일 (신정/삼일절/어린이날 등)\n" "• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n" "• 정부 지정 대체·임시공휴일\n\n" "※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return added = self.db.add_korean_holidays_auto(current_year) if added < 0: # 패키지 미설치 시 고정 공휴일로 폴백 self.db.add_korean_holidays(current_year) self.update_holiday_count() QMessageBox.warning( self, "패키지 미설치", "'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n" "음력/임시공휴일 자동 등록을 원하시면:\n" " pip install holidays" ) return self.update_holiday_count() QMessageBox.information( self, "추가 완료", f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다." ) def add_custom_holiday(self): """사용자 정의 공휴일 추가""" from PyQt5.QtWidgets import QInputDialog, QLineEdit # 날짜 입력 today = datetime.now().date().isoformat() date_str, ok = QInputDialog.getText( self, "공휴일 추가", "공휴일 날짜를 입력하세요 (YYYY-MM-DD):", QLineEdit.Normal, today ) if not ok or not date_str: return # 날짜 형식 검증 try: datetime.strptime(date_str, "%Y-%m-%d") except ValueError: QMessageBox.warning( self, "입력 오류", "날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)" ) return # 공휴일 이름 입력 name, ok = QInputDialog.getText( self, "공휴일 추가", "공휴일 이름을 입력하세요:", 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, "추가 완료", f"공휴일이 추가되었습니다.\n{date_str}: {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, "공휴일 목록", f"{current_year}년에 등록된 공휴일이 없습니다." ) return # 목록 생성 holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n" for h in holidays: date_obj = datetime.strptime(h['date'], "%Y-%m-%d") weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()] recurring = " (매년)" if h['is_recurring'] else "" holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n" holiday_list += f"\n총 {len(holidays)}개" # 삭제 옵션 제공 reply = QMessageBox.question( self, "공휴일 목록", holiday_list + "\n\n공휴일을 삭제하시겠습니까?", 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, "공휴일 삭제", "삭제할 공휴일을 선택하세요:", 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, "삭제 완료", f"{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("근무기록") 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("연장근무") export_overtime_btn.setObjectName("btn_small") export_overtime_btn.clicked.connect(self.export_overtime_summary) export_layout.addWidget(export_overtime_btn) monthly_btn = QPushButton("월간 요약") monthly_btn.setObjectName("btn_small") monthly_btn.clicked.connect(self.export_monthly_summary) export_layout.addWidget(monthly_btn) export_label = QLabel("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("📥 CSV 가져오기") import_btn.setObjectName("btn_small") import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷") import_btn.clicked.connect(self._import_csv) import_layout.addWidget(import_btn) import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)") 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("DB 경로:") 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("변경...") db_path_btn.setObjectName("btn_small") db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.") 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("화면 잠금 시 자동 외출/복귀") self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.") layout.addWidget(self.auto_break_check) # Gitea 피드백 토큰 (옵션, crash 자동 보고용) feedback_layout = QHBoxLayout() feedback_label = QLabel("Gitea 피드백:") feedback_label.setFixedWidth(80) self.gitea_token_edit = QLineEdit() self.gitea_token_edit.setEchoMode(QLineEdit.Password) self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)") feedback_layout.addWidget(feedback_label) feedback_layout.addWidget(self.gitea_token_edit, 1) layout.addLayout(feedback_layout) self.gitea_feedback_enabled_check = QCheckBox( "오류 발생 시 'Gitea에 보고' 버튼 활성화" ) layout.addWidget(self.gitea_feedback_enabled_check) # 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용) self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용") self.clock_in_unlock_check.setToolTip( "PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다." ) layout.addWidget(self.clock_in_unlock_check) # 업데이트 확인 update_layout = QHBoxLayout() from core.version import __version__ version_label = QLabel(f"버전: v{__version__}") version_label.setObjectName("note_text") update_btn = QPushButton("업데이트 확인 (F5)") 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, "데이터베이스 파일 선택", 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, "DB 경로 변경", f"새 경로가 저장되었습니다:\n{new_path}\n\n" "기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n" "프로그램을 재시작하세요." ) 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) ) # 알림 self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True)) self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True)) self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, 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) # 시간 형식 (콤보박스는 문자열로 저장하므로 변환) 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, 'light') == '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, "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, "파싱 실패", str(e)) return if not rows: QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.") return reply = QMessageBox.question( self, "충돌 처리", f"{len(rows)}건의 행을 가져오겠습니다.\n\n" "기존 일자와 충돌하면 어떻게 처리할까요?\n" "Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소", 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, "가져오기 실패", str(e)) return QMessageBox.information( self, "완료", f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {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(), 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, '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, '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, "저장 완료", "설정이 저장되었습니다." ) # 부모 윈도우에 설정 변경 알림 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 new_lang = self.language_combo.currentData() if new_lang and new_lang != get_language(): reply = QMessageBox.question( self, "재시작 필요 / Restart required", "언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n" "Restart now to fully apply the language change?", 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, "기존 연장근무 설정", f"현재 설정: {old_hours}시간 {old_mins}분\n" f"변경할 값: {hours}시간 {mins}분\n\n" f"기존 연장근무 시간을 변경하시겠습니까?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # settings에 초기값 저장 self.db.set_setting(INITIAL_OVERTIME_MINUTES,str(new_initial_minutes)) QMessageBox.information( self, "설정 완료", f"기존 연장근무가 {hours}시간 {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, "오류", f"기존 연장근무 설정 중 오류가 발생했습니다:\n{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(f"현재 잔액: {hours}시간 {minutes}분 ({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( f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)" ) 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, "내보내기 실패", "내보낼 기록이 없습니다.") return # 파일 경로 선택 default_filename = f"work_records_{now.year}{now.month:02d}.csv" filename, _ = QFileDialog.getSaveFileName( self, "근무 기록 저장", default_filename, "CSV Files (*.csv)" ) if filename: try: saved_path = CSVExporter.export_work_records(records, filename) QMessageBox.information( self, "내보내기 완료", f"근무 기록이 저장되었습니다.\n{saved_path}" ) except Exception as e: QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") def export_overtime_summary(self): """연장근무 내역 내보내기""" filename, _ = QFileDialog.getSaveFileName( self, "연장근무 내역 저장", 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, "내보내기 완료", f"연장근무 내역이 저장되었습니다.\n{saved_path}" ) except Exception as e: QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") def export_monthly_summary(self): """월간 요약 내보내기""" now = datetime.now() filename, _ = QFileDialog.getSaveFileName( self, "월간 요약 저장", 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, "내보내기 완료", f"월간 요약이 저장되었습니다.\n{saved_path}" ) except Exception as e: QMessageBox.critical(self, "내보내기 실패", f"오류: {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, "기존 사용 연차 설정", f"현재 설정: {old_hours}시간 {old_mins}분\n" f"변경할 값: {hours}시간 {mins}분\n\n" f"기존 사용 연차를 변경하시겠습니까?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # settings에 초기값 저장 self.db.set_setting(INITIAL_LEAVE_USED_HOURS,str(new_initial_hours)) QMessageBox.information( self, "설정 완료", f"기존 사용 연차가 {hours}시간 {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_()