Clock_out_Time_Calculator/ui/settings_view.py
KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의)
- Windows 이벤트 뷰어 자동 출퇴근 감지
- 30분 단위 연장근무 적립/사용 시스템
- 1.0/0.5/0.25일 연차·반차·반반차
- 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출
- 한국 공휴일 자동 등록 (음력 포함, holidays 패키지)
- matplotlib 차트 기반 주간/월간/패턴 통계
- 미니 위젯 + 시스템 트레이 통합
- 한국어/English i18n
- 자가 업데이트 (updater.exe + Gitea Releases)

아키텍처:
- core/ (db, time_calculator, notifier, i18n, version, settings_keys)
- ui/ (main_window + 9 dialogs + 3 controllers)
- utils/ (backup, lock_detector, debug_log, updater_client, time_format)
- tests/ (66 pytest 단위) + 통합/i18n GUI 검증

CI/CD:
- .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트
- .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 12:54:40 +09:00

1150 lines
46 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_OVERTIME, NOTIF_HEALTH,
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
)
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)
# 공휴일 설정
holiday_group = self.create_holiday_group()
layout.addWidget(holiday_group)
# 데이터 관리
data_group = self.create_data_group()
layout.addWidget(data_group)
layout.addStretch()
scroll_content.setLayout(layout)
scroll_area.setWidget(scroll_content)
main_layout.addWidget(scroll_area)
# 버튼들 (스크롤 영역 밖에)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 10, 20, 20)
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.setMinimumHeight(40)
save_button.clicked.connect(self.save_settings)
button_layout.addWidget(save_button)
close_button = QPushButton(tr('btn.close'))
close_button.setMinimumHeight(40)
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
def create_work_time_group(self) -> QGroupBox:
"""근무 시간 설정 그룹"""
group = QGroupBox(tr('group.work_time'))
layout = QVBoxLayout()
layout.setSpacing(8)
# 근무 패턴 프리셋
preset_layout = QHBoxLayout()
preset_label = QLabel("근무 패턴:")
preset_label.setFixedWidth(130)
self.work_preset_combo = QComboBox()
# (label, work_minutes, lunch_minutes)
self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60))
self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30))
self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60))
self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30))
self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0))
self.work_preset_combo.addItem("사용자 정의", None)
self.work_preset_combo.setFixedWidth(260)
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
preset_layout.addWidget(preset_label)
preset_layout.addWidget(self.work_preset_combo)
preset_layout.addStretch()
layout.addLayout(preset_layout)
# 하루 기본 근무 시간 (시 + 분 분리 입력)
work_hours_layout = QHBoxLayout()
work_hours_label = QLabel("하루 기본 근무:")
work_hours_label.setFixedWidth(130)
self.work_hours_spin = QSpinBox()
self.work_hours_spin.setRange(0, 12)
self.work_hours_spin.setValue(8)
self.work_hours_spin.setSuffix(" 시간")
self.work_hours_spin.setFixedWidth(100)
self.work_minutes_spin = QSpinBox()
self.work_minutes_spin.setRange(0, 59)
self.work_minutes_spin.setValue(0)
self.work_minutes_spin.setSingleStep(15)
self.work_minutes_spin.setSuffix("")
self.work_minutes_spin.setFixedWidth(100)
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
self.work_minutes_spin.valueChanged.connect(self._on_work_time_user_edit)
work_hours_layout.addWidget(work_hours_label)
work_hours_layout.addWidget(self.work_hours_spin)
work_hours_layout.addWidget(self.work_minutes_spin)
work_hours_layout.addStretch()
layout.addLayout(work_hours_layout)
# 점심시간 기본값
lunch_layout = QHBoxLayout()
lunch_label = QLabel("점심시간 기본:")
lunch_label.setFixedWidth(130)
self.lunch_spin = QSpinBox()
self.lunch_spin.setRange(0, 120)
self.lunch_spin.setValue(60)
self.lunch_spin.setSingleStep(5)
self.lunch_spin.setSuffix("")
self.lunch_spin.setFixedWidth(110)
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
lunch_layout.addWidget(lunch_label)
lunch_layout.addWidget(self.lunch_spin)
# 점심시간 자동 적용
self.auto_lunch_check = QCheckBox("자동 적용")
self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용")
lunch_layout.addWidget(self.auto_lunch_check)
lunch_layout.addStretch()
layout.addLayout(lunch_layout)
# 저녁시간 기본값
dinner_layout = QHBoxLayout()
dinner_label = QLabel("저녁시간 기본:")
dinner_label.setFixedWidth(130)
self.dinner_spin = QSpinBox()
self.dinner_spin.setRange(0, 120)
self.dinner_spin.setValue(60)
self.dinner_spin.setSingleStep(5)
self.dinner_spin.setSuffix("")
self.dinner_spin.setFixedWidth(110)
dinner_layout.addWidget(dinner_label)
dinner_layout.addWidget(self.dinner_spin)
dinner_layout.addStretch()
layout.addLayout(dinner_layout)
group.setLayout(layout)
return group
def on_preset_changed(self, index):
"""근무 패턴 프리셋 변경 시 시간/분/점심 자동 입력"""
data = self.work_preset_combo.itemData(index)
if data is None:
# 사용자 정의: 입력값 유지
return
work_minutes, lunch_minutes = data
# 시그널 차단으로 _on_work_time_user_edit가 다시 프리셋을 바꾸지 않도록
self.work_hours_spin.blockSignals(True)
self.work_minutes_spin.blockSignals(True)
self.lunch_spin.blockSignals(True)
try:
self.work_hours_spin.setValue(work_minutes // 60)
self.work_minutes_spin.setValue(work_minutes % 60)
self.lunch_spin.setValue(lunch_minutes)
finally:
self.work_hours_spin.blockSignals(False)
self.work_minutes_spin.blockSignals(False)
self.lunch_spin.blockSignals(False)
def _on_work_time_user_edit(self, *_):
"""사용자가 시간/분/점심을 직접 수정하면 프리셋 콤보를 '사용자 정의'로 전환"""
if not hasattr(self, '_settings_loaded'):
return
# 현재 값과 일치하는 프리셋이 있는지 확인
current = (
self.work_hours_spin.value() * 60 + self.work_minutes_spin.value(),
self.lunch_spin.value()
)
for i in range(self.work_preset_combo.count()):
data = self.work_preset_combo.itemData(i)
if data == current:
if self.work_preset_combo.currentIndex() != i:
self.work_preset_combo.blockSignals(True)
self.work_preset_combo.setCurrentIndex(i)
self.work_preset_combo.blockSignals(False)
return
# 일치하는 프리셋 없음 → 사용자 정의
custom_index = self.work_preset_combo.count() - 1
if self.work_preset_combo.currentIndex() != custom_index:
self.work_preset_combo.blockSignals(True)
self.work_preset_combo.setCurrentIndex(custom_index)
self.work_preset_combo.blockSignals(False)
def create_notification_group(self) -> QGroupBox:
"""알림 설정 그룹"""
group = QGroupBox(tr('group.notification'))
layout = QVBoxLayout()
layout.setSpacing(6)
# 알림 체크박스들을 2열로 배치
check_row1 = QHBoxLayout()
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
self.clock_out_notification_check.setChecked(True)
self.lunch_notification_check = QCheckBox("점심시간 등록 알림")
self.lunch_notification_check.setChecked(True)
check_row1.addWidget(self.clock_out_notification_check)
check_row1.addWidget(self.lunch_notification_check)
layout.addLayout(check_row1)
check_row2 = QHBoxLayout()
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
self.overtime_notification_check.setChecked(True)
self.health_notification_check = QCheckBox("건강 경고 알림")
self.health_notification_check.setChecked(True)
check_row2.addWidget(self.overtime_notification_check)
check_row2.addWidget(self.health_notification_check)
layout.addLayout(check_row2)
# 시간 형식 + 테마 한 줄에
format_row = QHBoxLayout()
time_format_label = QLabel("시간 형식:")
time_format_label.setFixedWidth(70)
self.time_format_combo = QComboBox()
self.time_format_combo.addItem("24시간 (17:30)", "24")
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12")
self.time_format_combo.setFixedWidth(180)
theme_label = QLabel("테마:")
theme_label.setFixedWidth(40)
self.theme_combo = QComboBox()
self.theme_combo.addItem("라이트", "light")
self.theme_combo.addItem("다크", "dark")
self.theme_combo.setFixedWidth(90)
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
format_row.addWidget(time_format_label)
format_row.addWidget(self.time_format_combo)
format_row.addSpacing(16)
format_row.addWidget(theme_label)
format_row.addWidget(self.theme_combo)
format_row.addStretch()
layout.addLayout(format_row)
# 언어 선택
from core.i18n import available_languages, language_label
lang_row = QHBoxLayout()
lang_label = QLabel("언어 / Language:")
lang_label.setFixedWidth(120)
self.language_combo = QComboBox()
for code in available_languages():
self.language_combo.addItem(language_label(code), code)
self.language_combo.setFixedWidth(140)
self.language_combo.setToolTip("언어 변경은 재시작 후 완전히 적용됩니다.")
lang_row.addWidget(lang_label)
lang_row.addWidget(self.language_combo)
lang_row.addStretch()
layout.addLayout(lang_row)
group.setLayout(layout)
return group
def create_overtime_group(self) -> QGroupBox:
"""연장근무 설정 그룹"""
group = QGroupBox(tr('group.overtime'))
layout = QVBoxLayout()
layout.setSpacing(6)
# 잔액 + 계산 단위 한 줄
top_row = QHBoxLayout()
self.current_overtime_label = QLabel("현재 잔액: 계산 중...")
self.current_overtime_label.setObjectName("badge_success")
top_row.addWidget(self.current_overtime_label)
top_row.addStretch()
unit_label = QLabel("계산 단위:")
self.overtime_unit_combo = QComboBox()
self.overtime_unit_combo.addItem("30분", 30)
self.overtime_unit_combo.addItem("1시간", 60)
self.overtime_unit_combo.addItem("15분", 15)
self.overtime_unit_combo.setFixedWidth(100)
top_row.addWidget(unit_label)
top_row.addWidget(self.overtime_unit_combo)
layout.addLayout(top_row)
# 초기 연장근무 설정
initial_overtime_layout = QHBoxLayout()
initial_overtime_label = QLabel("기존 연장근무:")
initial_overtime_label.setFixedWidth(100)
self.initial_overtime_hours = QSpinBox()
self.initial_overtime_hours.setRange(0, 200)
self.initial_overtime_hours.setValue(0)
self.initial_overtime_hours.setSuffix(" 시간")
self.initial_overtime_hours.setFixedWidth(110)
self.initial_overtime_mins = QSpinBox()
self.initial_overtime_mins.setRange(0, 59)
self.initial_overtime_mins.setValue(0)
self.initial_overtime_mins.setSuffix("")
self.initial_overtime_mins.setFixedWidth(100)
apply_overtime_btn = QPushButton("적용")
apply_overtime_btn.setObjectName("btn_small")
apply_overtime_btn.setFixedWidth(50)
apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
self.auto_overtime_check = QCheckBox("자동 적립")
self.auto_overtime_check.setChecked(True)
self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립")
initial_overtime_layout.addWidget(initial_overtime_label)
initial_overtime_layout.addWidget(self.initial_overtime_hours)
initial_overtime_layout.addWidget(self.initial_overtime_mins)
initial_overtime_layout.addWidget(apply_overtime_btn)
initial_overtime_layout.addStretch()
initial_overtime_layout.addWidget(self.auto_overtime_check)
layout.addLayout(initial_overtime_layout)
initial_overtime_note = QLabel("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)")
initial_overtime_note.setObjectName("note_text")
layout.addWidget(initial_overtime_note)
group.setLayout(layout)
return group
def create_leave_group(self) -> QGroupBox:
"""휴가 설정 그룹"""
group = QGroupBox(tr('group.leave'))
layout = QVBoxLayout()
layout.setSpacing(6)
# 연차 개수 + 남은 연차 한 줄
top_row = QHBoxLayout()
annual_leave_label = QLabel("연간 연차:")
annual_leave_label.setFixedWidth(70)
self.annual_leave_days = QSpinBox()
self.annual_leave_days.setRange(0, 30)
self.annual_leave_days.setValue(15)
self.annual_leave_days.setSuffix("")
self.annual_leave_days.setFixedWidth(100)
top_row.addWidget(annual_leave_label)
top_row.addWidget(self.annual_leave_days)
top_row.addStretch()
self.remaining_leave_label = QLabel("남은 연차: 계산 중...")
self.remaining_leave_label.setObjectName("badge_leave")
top_row.addWidget(self.remaining_leave_label)
layout.addLayout(top_row)
# 기존 사용 연차 설정
used_leave_layout = QHBoxLayout()
used_leave_label = QLabel("기존 사용:")
used_leave_label.setFixedWidth(70)
self.used_leave_hours = QSpinBox()
self.used_leave_hours.setRange(0, 200)
self.used_leave_hours.setValue(0)
self.used_leave_hours.setSuffix(" 시간")
self.used_leave_hours.setFixedWidth(110)
self.used_leave_mins = QSpinBox()
self.used_leave_mins.setRange(0, 59)
self.used_leave_mins.setValue(0)
self.used_leave_mins.setSuffix("")
self.used_leave_mins.setSingleStep(30)
self.used_leave_mins.setFixedWidth(100)
apply_used_leave_btn = QPushButton("적용")
apply_used_leave_btn.setObjectName("btn_small")
apply_used_leave_btn.setFixedWidth(50)
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
used_leave_layout.addWidget(used_leave_label)
used_leave_layout.addWidget(self.used_leave_hours)
used_leave_layout.addWidget(self.used_leave_mins)
used_leave_layout.addWidget(apply_used_leave_btn)
used_leave_layout.addStretch()
layout.addLayout(used_leave_layout)
used_leave_note = QLabel("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)")
used_leave_note.setObjectName("note_text")
layout.addWidget(used_leave_note)
group.setLayout(layout)
return group
def create_holiday_group(self) -> QGroupBox:
"""공휴일 설정 그룹"""
group = QGroupBox(tr('group.holiday'))
layout = QVBoxLayout()
layout.setSpacing(6)
# 공휴일 목록 + 버튼 한 줄
button_layout = QHBoxLayout()
holiday_list_label = QLabel("등록:")
button_layout.addWidget(holiday_list_label)
self.holiday_count_label = QLabel("0개")
self.holiday_count_label.setObjectName("info_text")
button_layout.addWidget(self.holiday_count_label)
button_layout.addStretch()
add_korean_btn = QPushButton("한국 공휴일 (자동)")
add_korean_btn.setObjectName("btn_small")
add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록")
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
button_layout.addWidget(add_korean_btn)
add_custom_btn = QPushButton("추가")
add_custom_btn.setObjectName("btn_small")
add_custom_btn.clicked.connect(self.add_custom_holiday)
button_layout.addWidget(add_custom_btn)
view_holidays_btn = QPushButton("목록")
view_holidays_btn.setObjectName("btn_small")
view_holidays_btn.clicked.connect(self.view_holidays)
button_layout.addWidget(view_holidays_btn)
layout.addLayout(button_layout)
holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다")
holiday_note.setObjectName("note_text")
layout.addWidget(holiday_note)
group.setLayout(layout)
# 공휴일 개수 업데이트
self.update_holiday_count()
return group
def update_holiday_count(self):
"""공휴일 개수 표시 업데이트"""
current_year = datetime.now().year
holidays = self.db.get_holidays_by_year(current_year)
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
def add_korean_holidays_auto(self):
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가"""
current_year = datetime.now().year
reply = QMessageBox.question(
self,
"한국 공휴일 자동 추가",
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
"포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
"• 정부 지정 대체·임시공휴일\n\n"
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
added = self.db.add_korean_holidays_auto(current_year)
if added < 0:
# 패키지 미설치 시 고정 공휴일로 폴백
self.db.add_korean_holidays(current_year)
self.update_holiday_count()
QMessageBox.warning(
self,
"패키지 미설치",
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n"
"음력/임시공휴일 자동 등록을 원하시면:\n"
" pip install holidays"
)
return
self.update_holiday_count()
QMessageBox.information(
self,
"추가 완료",
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다."
)
def add_custom_holiday(self):
"""사용자 정의 공휴일 추가"""
from PyQt5.QtWidgets import QInputDialog, QLineEdit
# 날짜 입력
today = datetime.now().date().isoformat()
date_str, ok = QInputDialog.getText(
self,
"공휴일 추가",
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):",
QLineEdit.Normal,
today
)
if not ok or not date_str:
return
# 날짜 형식 검증
try:
datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
QMessageBox.warning(
self,
"입력 오류",
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)"
)
return
# 공휴일 이름 입력
name, ok = QInputDialog.getText(
self,
"공휴일 추가",
"공휴일 이름을 입력하세요:",
QLineEdit.Normal,
""
)
if not ok or not name:
return
# 공휴일 추가
self.db.add_holiday(date_str, name, is_recurring=False)
self.update_holiday_count()
QMessageBox.information(
self,
"추가 완료",
f"공휴일이 추가되었습니다.\n{date_str}: {name}"
)
def view_holidays(self):
"""공휴일 목록 보기"""
current_year = datetime.now().year
holidays = self.db.get_holidays_by_year(current_year)
if not holidays:
QMessageBox.information(
self,
"공휴일 목록",
f"{current_year}년에 등록된 공휴일이 없습니다."
)
return
# 목록 생성
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n"
for h in holidays:
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
weekday = ['', '', '', '', '', '', ''][date_obj.weekday()]
recurring = " (매년)" if h['is_recurring'] else ""
holiday_list += f"{h['date']} ({weekday}): {h['name']}{recurring}\n"
holiday_list += f"\n{len(holidays)}"
# 삭제 옵션 제공
reply = QMessageBox.question(
self,
"공휴일 목록",
holiday_list + "\n\n공휴일을 삭제하시겠습니까?",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
)
if reply == QMessageBox.Yes:
self.delete_holiday_dialog()
def delete_holiday_dialog(self):
"""공휴일 삭제 다이얼로그"""
from PyQt5.QtWidgets import QInputDialog
current_year = datetime.now().year
holidays = self.db.get_holidays_by_year(current_year)
if not holidays:
return
# 공휴일 선택
items = [f"{h['date']}: {h['name']}" for h in holidays]
item, ok = QInputDialog.getItem(
self,
"공휴일 삭제",
"삭제할 공휴일을 선택하세요:",
items,
0,
False
)
if ok and item:
date_str = item.split(":")[0]
self.db.delete_holiday_by_date(date_str)
self.update_holiday_count()
QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.")
def create_data_group(self) -> QGroupBox:
"""데이터 관리 그룹"""
group = QGroupBox(tr('group.data'))
layout = QVBoxLayout()
layout.setSpacing(6)
# CSV 내보내기 버튼들 한 줄
export_layout = QHBoxLayout()
export_work_btn = QPushButton("근무기록")
export_work_btn.setObjectName("btn_small")
export_work_btn.clicked.connect(self.export_work_records)
export_layout.addWidget(export_work_btn)
export_overtime_btn = QPushButton("연장근무")
export_overtime_btn.setObjectName("btn_small")
export_overtime_btn.clicked.connect(self.export_overtime_summary)
export_layout.addWidget(export_overtime_btn)
monthly_btn = QPushButton("월간 요약")
monthly_btn.setObjectName("btn_small")
monthly_btn.clicked.connect(self.export_monthly_summary)
export_layout.addWidget(monthly_btn)
export_label = QLabel("CSV 내보내기")
export_label.setObjectName("note_text")
export_layout.addWidget(export_label)
export_layout.addStretch()
layout.addLayout(export_layout)
# 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)
# 첫 잠금 해제 = 출근 (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))
# 알림
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
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 _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(),
TIME_FORMAT: self.time_format_combo.currentData(),
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
}
if hasattr(self, 'auto_break_check'):
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
if hasattr(self, 'clock_in_unlock_check'):
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
if hasattr(self, 'language_combo'):
settings[LANGUAGE] = self.language_combo.currentData()
self.db.save_settings(settings)
# 테마 저장
self.db.set_setting(THEME, self.theme_combo.currentData())
# 연차 잔액 재계산 (총 연차 - 올해 사용한 연차)
current_year = datetime.now().year
all_leaves = self.db.get_all_leave_records(limit=365)
year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))]
used_annual_days = sum(r['days'] for r in year_leaves)
new_balance = settings[ANNUAL_LEAVE_DAYS] - used_annual_days
self.db.set_leave_balance(new_balance)
QMessageBox.information(
self,
"저장 완료",
"설정이 저장되었습니다."
)
# 부모 윈도우에 설정 변경 알림
if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
self.parent_window.reload_settings()
# 언어 변경 감지 → 재시작 제안
if hasattr(self, 'language_combo'):
from core.i18n import get_language
new_lang = self.language_combo.currentData()
if new_lang and new_lang != get_language():
reply = QMessageBox.question(
self,
"재시작 필요 / Restart required",
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n"
"Restart now to fully apply the language change?",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._restart_app()
def on_theme_changed(self, index):
"""테마 콤보박스 변경 시 즉시 적용"""
if not hasattr(self, '_settings_loaded'):
return
theme_name = self.theme_combo.currentData()
if theme_name:
# DB에 저장
self.db.set_setting(THEME, theme_name)
# 부모 윈도우를 통해 테마 적용 (setStyleSheet이 메인 윈도우에 설정되므로)
if self.parent_window and hasattr(self.parent_window, 'apply_theme'):
self.parent_window.apply_theme(theme_name)
def apply_initial_overtime(self):
"""기존 연장근무 설정 (프로그램 사용 전 쌓인 시간 - 절대값)"""
try:
hours = self.initial_overtime_hours.value()
mins = self.initial_overtime_mins.value()
new_initial_minutes = hours * 60 + mins
# 기존 초기값 조회
old_initial_minutes = int(self.db.get_setting(INITIAL_OVERTIME_MINUTES, '0'))
old_hours = old_initial_minutes // 60
old_mins = old_initial_minutes % 60
reply = QMessageBox.question(
self,
"기존 연장근무 설정",
f"현재 설정: {old_hours}시간 {old_mins}\n"
f"변경할 값: {hours}시간 {mins}\n\n"
f"기존 연장근무 시간을 변경하시겠습니까?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
# settings에 초기값 저장
self.db.set_setting(INITIAL_OVERTIME_MINUTES,str(new_initial_minutes))
QMessageBox.information(
self,
"설정 완료",
f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다."
)
# 부모 윈도우 잔액 업데이트
if self.parent_window and hasattr(self.parent_window, 'update_overtime_balance'):
self.parent_window.update_overtime_balance()
# 현재 창의 잔액 표시도 업데이트
self.update_overtime_balance_display()
except Exception as e:
QMessageBox.critical(
self,
"오류",
f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}"
)
def update_overtime_balance_display(self):
"""연장근무 잔액 표시 업데이트"""
balance_minutes = self.db.get_total_overtime_balance()
hours = balance_minutes // 60
minutes = balance_minutes % 60
self.current_overtime_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance_minutes}분)")
def update_remaining_leave(self):
"""남은 연차 계산 및 표시"""
# 기존 사용 연차 초기값 (프로그램 사용 전)
initial_leave_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0'))
initial_leave_days = initial_leave_hours / 8.0
# 올해 사용한 연차 조회 (프로그램에서 기록된 것)
current_year = datetime.now().year
all_leaves = self.db.get_all_leave_records(limit=365)
year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))]
# 모든 연차 타입 합산 (days 필드 사용)
used_annual_days = sum(r['days'] for r in year_leaves)
# 총 사용량 = 초기값 + 프로그램에서 기록된 것
total_used = initial_leave_days + used_annual_days
# 총 연차
total_annual = self.annual_leave_days.value()
# 남은 연차
remaining = total_annual - total_used
self.remaining_leave_label.setText(
f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)"
)
def export_work_records(self):
"""근무 기록 내보내기"""
# 내보낼 기록 가져오기
now = datetime.now()
stats = self.db.get_monthly_stats(now.year, now.month)
records = stats.get('records', [])
if not records:
QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.")
return
# 파일 경로 선택
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
filename, _ = QFileDialog.getSaveFileName(
self,
"근무 기록 저장",
default_filename,
"CSV Files (*.csv)"
)
if filename:
try:
saved_path = CSVExporter.export_work_records(records, filename)
QMessageBox.information(
self,
"내보내기 완료",
f"근무 기록이 저장되었습니다.\n{saved_path}"
)
except Exception as e:
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
def export_overtime_summary(self):
"""연장근무 내역 내보내기"""
filename, _ = QFileDialog.getSaveFileName(
self,
"연장근무 내역 저장",
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
"CSV Files (*.csv)"
)
if filename:
try:
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
QMessageBox.information(
self,
"내보내기 완료",
f"연장근무 내역이 저장되었습니다.\n{saved_path}"
)
except Exception as e:
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
def export_monthly_summary(self):
"""월간 요약 내보내기"""
now = datetime.now()
filename, _ = QFileDialog.getSaveFileName(
self,
"월간 요약 저장",
f"monthly_summary_{now.year}{now.month:02d}.csv",
"CSV Files (*.csv)"
)
if filename:
try:
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
QMessageBox.information(
self,
"내보내기 완료",
f"월간 요약이 저장되었습니다.\n{saved_path}"
)
except Exception as e:
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
def apply_used_leave(self):
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
hours = self.used_leave_hours.value()
mins = self.used_leave_mins.value()
new_initial_hours = hours + (mins / 60.0)
# 기존 초기값 조회
old_initial_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0'))
old_hours = int(old_initial_hours)
old_mins = int((old_initial_hours % 1) * 60)
reply = QMessageBox.question(
self,
"기존 사용 연차 설정",
f"현재 설정: {old_hours}시간 {old_mins}\n"
f"변경할 값: {hours}시간 {mins}\n\n"
f"기존 사용 연차를 변경하시겠습니까?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
# settings에 초기값 저장
self.db.set_setting(INITIAL_LEAVE_USED_HOURS,str(new_initial_hours))
QMessageBox.information(
self,
"설정 완료",
f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다."
)
# 남은 연차 재계산
self.update_remaining_leave()
# 부모 윈도우의 연차 잔액도 업데이트
if self.parent_window and hasattr(self.parent_window, 'update_leave_balance'):
self.parent_window.update_leave_balance()
def add_past_leave_usage(self):
"""이전 사용 연차 기록하기"""
dialog = AddLeaveDialog(self, self.db)
if dialog.exec_() == QDialog.Accepted:
# 남은 연차 재계산
self.update_remaining_leave()
# 부모 윈도우의 연차 잔액도 업데이트
if self.parent_window and hasattr(self.parent_window, 'update_leave_balance'):
self.parent_window.update_leave_balance()
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = SettingsView()
dialog.exec_()