Clock_out_Time_Calculator/ui/settings_view.py

1462 lines
61 KiB
Python

"""
설정 뷰
"""
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_()