Clock_out_Time_Calculator/ui/settings_view.py
KINDNICK 97dd4e39f7 v2.10.0: \uc815\ubd80 \ud2b9\uc77c\uc815\ubcf4 API \uc5f0\ub3d9 + \uc77c \uc790\ub3d9 \ub3d9\uae30\ud654
\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0
\uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback.

- utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item)
- Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain
- migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654
  (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var)
- Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8

Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green
\ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green

\uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5,
fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
2026-05-01 13:51:33 +09:00

1481 lines
62 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("설정")
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 _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("퇴근 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.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
self.dinner_notification_check.setChecked(True)
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
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("건강 경고 알림")
self.health_notification_check.setChecked(True)
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
self.health_break_notification_check.setChecked(True)
self.health_break_notification_check.setToolTip(
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
)
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("퇴근 알림 시점:")
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)
# === 고급 임계값 (접이식 그룹박스) ===
adv_box = QGroupBox("고급 임계값")
adv_box.setCheckable(True)
adv_box.setChecked(False) # 기본 접힘
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
adv_layout = QVBoxLayout()
adv_layout.setSpacing(4)
# 점심 알림 임계 (출근 후 N시간)
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
# 저녁 알림 임계 (출근 후 N시간)
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
# 연장근무 누적 임계
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
# 주 X시간 임계
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
# 연속 연장근무 일수
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, "")
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
# 휴식 권고 (연속 근무 시간)
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
adv_box.setLayout(adv_layout)
layout.addWidget(adv_box)
# 시간 형식 + 테마 한 줄에
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)
# 접근성: 글꼴 크기 + 고대비
a11y_row = QHBoxLayout()
a11y_row.addWidget(QLabel("글꼴 크기:"))
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("고대비 모드")
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
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("언어 / 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 패키지로 음력/임시 공휴일 포함 자동 추가.
11월 이후 호출 시 자동으로 다음 연도까지 등록 — 연말 경계에서
신정 등이 누락되는 것 방지.
"""
now = datetime.now()
current_year = now.year
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
include_next = now.month >= 11
target_label = (f"{current_year}년 + {current_year + 1}"
if include_next else f"{current_year}")
reply = QMessageBox.question(
self,
"한국 공휴일 자동 추가",
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
"포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
"• 정부 지정 대체·임시공휴일\n\n"
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
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,
"패키지 미설치",
"'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)
)
# 접근성
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, '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(),
# 고급 임계값
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,
"저장 완료",
"설정이 저장되었습니다."
)
# 부모 윈도우에 설정 변경 알림
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,
"재시작 / Restart",
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
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, db=self.db)
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_()