Phase 3 (also v2.5.0 in CHANGELOG): - Weekly auto report on Monday - Matplotlib chart hover annotation - Clock-in time distribution histogram - Leave usage calendar with color coding Phase 4 (v2.6.0): - Font scale 100/125/150% (instant apply) - High-contrast mode (black bg + yellow text) - Authenticode signing infra in release.ps1 (env-gated, optional) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1350 lines
55 KiB
Python
1350 lines
55 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,
|
|
NOTIFICATION_BEFORE_MINUTES,
|
|
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
|
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
|
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
|
GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY,
|
|
GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED,
|
|
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 create_notification_group(self) -> QGroupBox:
|
|
"""알림 설정 그룹"""
|
|
group = QGroupBox(tr('group.notification'))
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(6)
|
|
|
|
# 알림 체크박스들을 2열로 배치
|
|
check_row1 = QHBoxLayout()
|
|
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
|
self.clock_out_notification_check.setChecked(True)
|
|
self.lunch_notification_check = QCheckBox("점심시간 등록 알림")
|
|
self.lunch_notification_check.setChecked(True)
|
|
check_row1.addWidget(self.clock_out_notification_check)
|
|
check_row1.addWidget(self.lunch_notification_check)
|
|
layout.addLayout(check_row1)
|
|
|
|
check_row2 = QHBoxLayout()
|
|
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
|
self.overtime_notification_check.setChecked(True)
|
|
self.health_notification_check = QCheckBox("건강 경고 알림")
|
|
self.health_notification_check.setChecked(True)
|
|
check_row2.addWidget(self.overtime_notification_check)
|
|
check_row2.addWidget(self.health_notification_check)
|
|
layout.addLayout(check_row2)
|
|
|
|
# 퇴근 N분 전 알림 시점 설정
|
|
before_row = QHBoxLayout()
|
|
before_label = QLabel("퇴근 알림 시점:")
|
|
before_label.setFixedWidth(110)
|
|
self.notif_before_spin = QSpinBox()
|
|
self.notif_before_spin.setRange(1, 120)
|
|
self.notif_before_spin.setSingleStep(5)
|
|
self.notif_before_spin.setValue(30)
|
|
self.notif_before_spin.setSuffix(" 분 전")
|
|
self.notif_before_spin.setFixedWidth(110)
|
|
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
|
before_row.addWidget(before_label)
|
|
before_row.addWidget(self.notif_before_spin)
|
|
before_row.addStretch()
|
|
layout.addLayout(before_row)
|
|
|
|
# 시간 형식 + 테마 한 줄에
|
|
format_row = QHBoxLayout()
|
|
time_format_label = QLabel("시간 형식:")
|
|
time_format_label.setFixedWidth(70)
|
|
self.time_format_combo = QComboBox()
|
|
self.time_format_combo.addItem("24시간 (17:30)", "24")
|
|
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12")
|
|
self.time_format_combo.setFixedWidth(180)
|
|
|
|
theme_label = QLabel("테마:")
|
|
theme_label.setFixedWidth(40)
|
|
self.theme_combo = QComboBox()
|
|
self.theme_combo.addItem("라이트", "light")
|
|
self.theme_combo.addItem("다크", "dark")
|
|
self.theme_combo.setFixedWidth(90)
|
|
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
|
|
|
format_row.addWidget(time_format_label)
|
|
format_row.addWidget(self.time_format_combo)
|
|
format_row.addSpacing(16)
|
|
format_row.addWidget(theme_label)
|
|
format_row.addWidget(self.theme_combo)
|
|
format_row.addStretch()
|
|
layout.addLayout(format_row)
|
|
|
|
# 접근성: 글꼴 크기 + 고대비
|
|
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 패키지로 음력/임시 공휴일 포함 자동 추가"""
|
|
current_year = datetime.now().year
|
|
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"한국 공휴일 자동 추가",
|
|
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
|
"포함:\n"
|
|
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
|
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
|
"• 정부 지정 대체·임시공휴일\n\n"
|
|
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
|
|
QMessageBox.Yes | QMessageBox.No
|
|
)
|
|
if reply != QMessageBox.Yes:
|
|
return
|
|
|
|
added = self.db.add_korean_holidays_auto(current_year)
|
|
if added < 0:
|
|
# 패키지 미설치 시 고정 공휴일로 폴백
|
|
self.db.add_korean_holidays(current_year)
|
|
self.update_holiday_count()
|
|
QMessageBox.warning(
|
|
self,
|
|
"패키지 미설치",
|
|
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n"
|
|
"음력/임시공휴일 자동 등록을 원하시면:\n"
|
|
" pip install holidays"
|
|
)
|
|
return
|
|
|
|
self.update_holiday_count()
|
|
QMessageBox.information(
|
|
self,
|
|
"추가 완료",
|
|
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다."
|
|
)
|
|
|
|
def add_custom_holiday(self):
|
|
"""사용자 정의 공휴일 추가"""
|
|
from PyQt5.QtWidgets import QInputDialog, QLineEdit
|
|
|
|
# 날짜 입력
|
|
today = datetime.now().date().isoformat()
|
|
date_str, ok = QInputDialog.getText(
|
|
self,
|
|
"공휴일 추가",
|
|
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):",
|
|
QLineEdit.Normal,
|
|
today
|
|
)
|
|
|
|
if not ok or not date_str:
|
|
return
|
|
|
|
# 날짜 형식 검증
|
|
try:
|
|
datetime.strptime(date_str, "%Y-%m-%d")
|
|
except ValueError:
|
|
QMessageBox.warning(
|
|
self,
|
|
"입력 오류",
|
|
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)"
|
|
)
|
|
return
|
|
|
|
# 공휴일 이름 입력
|
|
name, ok = QInputDialog.getText(
|
|
self,
|
|
"공휴일 추가",
|
|
"공휴일 이름을 입력하세요:",
|
|
QLineEdit.Normal,
|
|
""
|
|
)
|
|
|
|
if not ok or not name:
|
|
return
|
|
|
|
# 공휴일 추가
|
|
self.db.add_holiday(date_str, name, is_recurring=False)
|
|
self.update_holiday_count()
|
|
|
|
QMessageBox.information(
|
|
self,
|
|
"추가 완료",
|
|
f"공휴일이 추가되었습니다.\n{date_str}: {name}"
|
|
)
|
|
|
|
def view_holidays(self):
|
|
"""공휴일 목록 보기"""
|
|
current_year = datetime.now().year
|
|
holidays = self.db.get_holidays_by_year(current_year)
|
|
|
|
if not holidays:
|
|
QMessageBox.information(
|
|
self,
|
|
"공휴일 목록",
|
|
f"{current_year}년에 등록된 공휴일이 없습니다."
|
|
)
|
|
return
|
|
|
|
# 목록 생성
|
|
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n"
|
|
for h in holidays:
|
|
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
|
weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()]
|
|
recurring = " (매년)" if h['is_recurring'] else ""
|
|
holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n"
|
|
|
|
holiday_list += f"\n총 {len(holidays)}개"
|
|
|
|
# 삭제 옵션 제공
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"공휴일 목록",
|
|
holiday_list + "\n\n공휴일을 삭제하시겠습니까?",
|
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
self.delete_holiday_dialog()
|
|
|
|
def delete_holiday_dialog(self):
|
|
"""공휴일 삭제 다이얼로그"""
|
|
from PyQt5.QtWidgets import QInputDialog
|
|
|
|
current_year = datetime.now().year
|
|
holidays = self.db.get_holidays_by_year(current_year)
|
|
|
|
if not holidays:
|
|
return
|
|
|
|
# 공휴일 선택
|
|
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
|
item, ok = QInputDialog.getItem(
|
|
self,
|
|
"공휴일 삭제",
|
|
"삭제할 공휴일을 선택하세요:",
|
|
items,
|
|
0,
|
|
False
|
|
)
|
|
|
|
if ok and item:
|
|
date_str = item.split(":")[0]
|
|
self.db.delete_holiday_by_date(date_str)
|
|
self.update_holiday_count()
|
|
QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.")
|
|
|
|
def create_data_group(self) -> QGroupBox:
|
|
"""데이터 관리 그룹"""
|
|
group = QGroupBox(tr('group.data'))
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(6)
|
|
|
|
# CSV 내보내기 버튼들 한 줄
|
|
export_layout = QHBoxLayout()
|
|
|
|
export_work_btn = QPushButton("근무기록")
|
|
export_work_btn.setObjectName("btn_small")
|
|
export_work_btn.clicked.connect(self.export_work_records)
|
|
export_layout.addWidget(export_work_btn)
|
|
|
|
export_overtime_btn = QPushButton("연장근무")
|
|
export_overtime_btn.setObjectName("btn_small")
|
|
export_overtime_btn.clicked.connect(self.export_overtime_summary)
|
|
export_layout.addWidget(export_overtime_btn)
|
|
|
|
monthly_btn = QPushButton("월간 요약")
|
|
monthly_btn.setObjectName("btn_small")
|
|
monthly_btn.clicked.connect(self.export_monthly_summary)
|
|
export_layout.addWidget(monthly_btn)
|
|
|
|
export_label = QLabel("CSV 내보내기")
|
|
export_label.setObjectName("note_text")
|
|
export_layout.addWidget(export_label)
|
|
export_layout.addStretch()
|
|
|
|
layout.addLayout(export_layout)
|
|
|
|
# CSV 가져오기
|
|
import_layout = QHBoxLayout()
|
|
import_btn = QPushButton("📥 CSV 가져오기")
|
|
import_btn.setObjectName("btn_small")
|
|
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
|
import_btn.clicked.connect(self._import_csv)
|
|
import_layout.addWidget(import_btn)
|
|
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
|
|
import_label.setObjectName("note_text")
|
|
import_layout.addWidget(import_label)
|
|
import_layout.addStretch()
|
|
layout.addLayout(import_layout)
|
|
|
|
# DB 경로 설정 (클라우드 동기화 가능)
|
|
db_path_layout = QHBoxLayout()
|
|
db_path_label = QLabel("DB 경로:")
|
|
db_path_label.setFixedWidth(60)
|
|
self.db_path_edit = QLineEdit()
|
|
self.db_path_edit.setReadOnly(True)
|
|
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
|
|
db_path_btn = QPushButton("변경...")
|
|
db_path_btn.setObjectName("btn_small")
|
|
db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.")
|
|
db_path_btn.clicked.connect(self._change_db_path)
|
|
db_path_layout.addWidget(db_path_label)
|
|
db_path_layout.addWidget(self.db_path_edit, 1)
|
|
db_path_layout.addWidget(db_path_btn)
|
|
layout.addLayout(db_path_layout)
|
|
|
|
# 자동 외출 (화면 잠금 시)
|
|
self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀")
|
|
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
|
layout.addWidget(self.auto_break_check)
|
|
|
|
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
|
feedback_layout = QHBoxLayout()
|
|
feedback_label = QLabel("Gitea 피드백:")
|
|
feedback_label.setFixedWidth(80)
|
|
self.gitea_token_edit = QLineEdit()
|
|
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
|
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
|
|
feedback_layout.addWidget(feedback_label)
|
|
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
|
layout.addLayout(feedback_layout)
|
|
|
|
self.gitea_feedback_enabled_check = QCheckBox(
|
|
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
|
)
|
|
layout.addWidget(self.gitea_feedback_enabled_check)
|
|
|
|
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
|
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
|
self.clock_in_unlock_check.setToolTip(
|
|
"PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다."
|
|
)
|
|
layout.addWidget(self.clock_in_unlock_check)
|
|
|
|
# 업데이트 확인
|
|
update_layout = QHBoxLayout()
|
|
from core.version import __version__
|
|
version_label = QLabel(f"버전: v{__version__}")
|
|
version_label.setObjectName("note_text")
|
|
update_btn = QPushButton("업데이트 확인 (F5)")
|
|
update_btn.setObjectName("btn_small")
|
|
update_btn.clicked.connect(self._check_updates)
|
|
update_layout.addWidget(version_label)
|
|
update_layout.addStretch()
|
|
update_layout.addWidget(update_btn)
|
|
layout.addLayout(update_layout)
|
|
|
|
group.setLayout(layout)
|
|
return group
|
|
|
|
def _change_db_path(self):
|
|
"""DB 경로 변경 (재시작 후 적용)"""
|
|
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
|
new_path, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"데이터베이스 파일 선택",
|
|
current,
|
|
"SQLite Database (*.db)"
|
|
)
|
|
if not new_path:
|
|
return
|
|
# 파일 미존재 시 빈 파일 생성하지 않고, 경로만 저장 — 다음 실행 시 새 DB로 init
|
|
self.db.set_setting(DB_PATH_OVERRIDE, new_path)
|
|
self.db_path_edit.setText(new_path)
|
|
QMessageBox.information(
|
|
self,
|
|
"DB 경로 변경",
|
|
f"새 경로가 저장되었습니다:\n{new_path}\n\n"
|
|
"기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n"
|
|
"프로그램을 재시작하세요."
|
|
)
|
|
|
|
def load_settings(self):
|
|
"""설정 불러오기"""
|
|
settings = self.db.get_settings()
|
|
|
|
if settings:
|
|
# work_minutes 우선, 없으면 work_hours*60 폴백
|
|
work_minutes = settings.get(WORK_MINUTES)
|
|
if work_minutes is None:
|
|
try:
|
|
work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60))
|
|
except (ValueError, TypeError):
|
|
work_minutes = 480
|
|
work_minutes = int(work_minutes)
|
|
|
|
# blockSignals: load 시 _on_work_time_user_edit가 프리셋을 잘못 전환하지 않도록
|
|
self.work_hours_spin.blockSignals(True)
|
|
self.work_minutes_spin.blockSignals(True)
|
|
try:
|
|
self.work_hours_spin.setValue(work_minutes // 60)
|
|
self.work_minutes_spin.setValue(work_minutes % 60)
|
|
finally:
|
|
self.work_hours_spin.blockSignals(False)
|
|
self.work_minutes_spin.blockSignals(False)
|
|
|
|
lunch_minutes = int(settings.get(LUNCH_DURATION_MINUTES, 60))
|
|
self.lunch_spin.blockSignals(True)
|
|
try:
|
|
self.lunch_spin.setValue(lunch_minutes)
|
|
finally:
|
|
self.lunch_spin.blockSignals(False)
|
|
|
|
# 현재 (work_minutes, lunch_minutes)와 일치하는 프리셋 선택
|
|
current = (work_minutes, lunch_minutes)
|
|
preset_idx = self.work_preset_combo.count() - 1 # 기본값: 사용자 정의
|
|
for i in range(self.work_preset_combo.count()):
|
|
if self.work_preset_combo.itemData(i) == current:
|
|
preset_idx = i
|
|
break
|
|
self.work_preset_combo.blockSignals(True)
|
|
self.work_preset_combo.setCurrentIndex(preset_idx)
|
|
self.work_preset_combo.blockSignals(False)
|
|
|
|
self.auto_lunch_check.setChecked(settings.get(AUTO_LUNCH, False))
|
|
|
|
self.dinner_spin.setValue(int(settings.get(DINNER_DURATION_MINUTES, 60)))
|
|
|
|
# 자동 외출 (화면 잠금)
|
|
if hasattr(self, 'auto_break_check'):
|
|
self.auto_break_check.setChecked(settings.get(AUTO_BREAK_ON_LOCK, False))
|
|
if hasattr(self, 'clock_in_unlock_check'):
|
|
self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False))
|
|
|
|
# 목표
|
|
if hasattr(self, 'goal_ot_h'):
|
|
ot_min = int(settings.get(GOAL_OVERTIME_MAX_MONTHLY, 0) or 0)
|
|
self.goal_ot_h.setValue(ot_min // 60)
|
|
self.goal_ot_m.setValue(ot_min % 60)
|
|
if hasattr(self, 'goal_avg'):
|
|
self.goal_avg.setValue(int(float(settings.get(GOAL_AVG_HOURS_DAILY, 0) or 0)))
|
|
|
|
# Gitea 피드백
|
|
if hasattr(self, 'gitea_token_edit'):
|
|
self.gitea_token_edit.setText(self.db.get_setting(GITEA_FEEDBACK_TOKEN, '') or '')
|
|
if hasattr(self, 'gitea_feedback_enabled_check'):
|
|
self.gitea_feedback_enabled_check.setChecked(
|
|
settings.get(GITEA_FEEDBACK_ENABLED, False)
|
|
)
|
|
|
|
# 접근성
|
|
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))
|
|
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
|
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
|
if hasattr(self, 'notif_before_spin'):
|
|
try:
|
|
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
|
except (ValueError, TypeError):
|
|
self.notif_before_spin.setValue(30)
|
|
|
|
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
|
time_format = settings.get(TIME_FORMAT, '24')
|
|
if isinstance(time_format, int):
|
|
time_format = str(time_format)
|
|
index = self.time_format_combo.findData(time_format)
|
|
if index >= 0:
|
|
self.time_format_combo.setCurrentIndex(index)
|
|
|
|
# 테마
|
|
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
|
|
|
|
# 언어 선택 적용
|
|
if hasattr(self, 'language_combo'):
|
|
lang = settings.get(LANGUAGE, 'ko') or 'ko'
|
|
idx = self.language_combo.findData(lang)
|
|
if idx >= 0:
|
|
self.language_combo.setCurrentIndex(idx)
|
|
|
|
# 연장근무 (콤보박스는 정수로 저장)
|
|
overtime_unit = settings.get(OVERTIME_UNIT, 30)
|
|
if isinstance(overtime_unit, str):
|
|
overtime_unit = int(overtime_unit)
|
|
index = self.overtime_unit_combo.findData(overtime_unit)
|
|
if index >= 0:
|
|
self.overtime_unit_combo.setCurrentIndex(index)
|
|
self.auto_overtime_check.setChecked(settings.get(AUTO_OVERTIME, True))
|
|
|
|
# 휴가
|
|
self.annual_leave_days.setValue(settings.get(ANNUAL_LEAVE_DAYS, 15))
|
|
|
|
# 기존 연장근무 초기값 로드 (settings에서)
|
|
initial_overtime = int(self.db.get_setting(INITIAL_OVERTIME_MINUTES, '0'))
|
|
self.initial_overtime_hours.setValue(initial_overtime // 60)
|
|
self.initial_overtime_mins.setValue(initial_overtime % 60)
|
|
|
|
# 기존 사용 연차 초기값 로드 (settings에서)
|
|
initial_leave_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0'))
|
|
self.used_leave_hours.setValue(int(initial_leave_hours))
|
|
self.used_leave_mins.setValue(int((initial_leave_hours % 1) * 60))
|
|
|
|
# 남은 연차 계산
|
|
self.update_remaining_leave()
|
|
|
|
def _import_csv(self):
|
|
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, "CSV 가져오기",
|
|
os.path.expanduser("~"),
|
|
"CSV files (*.csv);;All files (*.*)",
|
|
)
|
|
if not path:
|
|
return
|
|
try:
|
|
from utils.csv_importer import parse_csv, import_records
|
|
rows = parse_csv(path)
|
|
except (FileNotFoundError, ValueError) as e:
|
|
QMessageBox.critical(self, "파싱 실패", str(e))
|
|
return
|
|
|
|
if not rows:
|
|
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"충돌 처리",
|
|
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
|
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
|
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
|
)
|
|
if reply == QMessageBox.Cancel:
|
|
return
|
|
policy = 'overwrite' if reply == QMessageBox.Yes else 'skip'
|
|
|
|
try:
|
|
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "가져오기 실패", str(e))
|
|
return
|
|
|
|
QMessageBox.information(
|
|
self, "완료",
|
|
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
|
)
|
|
|
|
def _check_updates(self):
|
|
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
|
|
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
|
|
self.parent_window.check_for_updates(silent=False)
|
|
|
|
def _restart_app(self):
|
|
"""언어 변경 후 자동 재시작 (사용자 명시 동의 시)."""
|
|
from PyQt5.QtWidgets import QApplication
|
|
import sys, os
|
|
QApplication.quit()
|
|
# 빌드된 exe / 개발 환경 모두 처리
|
|
if getattr(sys, 'frozen', False):
|
|
os.execv(sys.executable, [sys.executable])
|
|
else:
|
|
os.execv(sys.executable, [sys.executable, *sys.argv])
|
|
|
|
def save_settings(self):
|
|
"""설정 저장"""
|
|
# 점심시간은 분 단위 그대로 저장
|
|
lunch_minutes = self.lunch_spin.value()
|
|
# 저녁시간은 분 단위 그대로 저장
|
|
dinner_minutes = self.dinner_spin.value()
|
|
|
|
work_minutes_total = self.work_hours_spin.value() * 60 + self.work_minutes_spin.value()
|
|
if work_minutes_total < 30:
|
|
QMessageBox.warning(self, tr('msg.input_error.title'),
|
|
tr('msg.work_min_too_small'))
|
|
return
|
|
|
|
# work_hours는 db.save_settings()가 자동 동기화하므로 보내지 않음 (단일 진실 소스)
|
|
settings = {
|
|
WORK_MINUTES: work_minutes_total,
|
|
LUNCH_DURATION_MINUTES: lunch_minutes,
|
|
DINNER_DURATION_MINUTES: dinner_minutes,
|
|
AUTO_LUNCH: self.auto_lunch_check.isChecked(),
|
|
NOTIF_CLOCK_OUT: self.clock_out_notification_check.isChecked(),
|
|
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
|
|
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
|
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
|
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
|
TIME_FORMAT: self.time_format_combo.currentData(),
|
|
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
|
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
|
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
|
|
}
|
|
if hasattr(self, 'auto_break_check'):
|
|
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
|
if hasattr(self, 'clock_in_unlock_check'):
|
|
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
|
|
if hasattr(self, 'goal_ot_h'):
|
|
settings[GOAL_OVERTIME_MAX_MONTHLY] = self.goal_ot_h.value() * 60 + self.goal_ot_m.value()
|
|
if hasattr(self, 'goal_avg'):
|
|
settings[GOAL_AVG_HOURS_DAILY] = self.goal_avg.value()
|
|
if hasattr(self, 'gitea_token_edit'):
|
|
self.db.set_setting(GITEA_FEEDBACK_TOKEN, self.gitea_token_edit.text().strip())
|
|
if hasattr(self, 'gitea_feedback_enabled_check'):
|
|
settings[GITEA_FEEDBACK_ENABLED] = self.gitea_feedback_enabled_check.isChecked()
|
|
if hasattr(self, '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
|
|
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_()
|