""" 첫 실행 온보딩 위저드. 5단계: 환영 → 근무패턴 → 출근감지 → 연차/시급 → Discord(옵션) → 완료 첫 실행 시 강제 표시. 완료 시 ONBOARDING_COMPLETED=true 저장. 재방문은 메인 메뉴 → "온보딩 다시 보기"로 가능. """ from __future__ import annotations from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout, QLabel, QRadioButton, QButtonGroup, QSpinBox, QCheckBox, QLineEdit, QComboBox, QPushButton, QMessageBox, QGroupBox) from PyQt5.QtCore import Qt from core.i18n import tr from ui.styles import apply_dark_titlebar # (i18n_key, work_minutes, lunch_minutes, dinner_minutes) # 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시. # dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는 # 직접 입력 모드에서 저녁 분(minutes)을 따로 설정. WORK_PRESETS = [ ('onboarding.preset.standard_8h', 480, 60, 0), ('onboarding.preset.short_7h30m', 450, 30, 0), ('onboarding.preset.short_7h', 420, 60, 0), ('onboarding.preset.short_6h', 360, 30, 0), ('onboarding.preset.half_4h', 240, 0, 0), ] class WelcomePage(QWizardPage): def __init__(self): super().__init__() self.setTitle(tr('onboarding.welcome_title')) self.setSubTitle(tr('onboarding.welcome_subtitle')) layout = QVBoxLayout() intro = QLabel(tr('onboarding.welcome_intro')) intro.setWordWrap(True) layout.addWidget(intro) self.setLayout(layout) class WorkPatternPage(QWizardPage): def __init__(self): super().__init__() self.setTitle(tr('onboarding.work_pattern_title')) self.setSubTitle(tr('onboarding.work_pattern_subtitle')) layout = QVBoxLayout() self.button_group = QButtonGroup(self) for i, (key, _, _, _) in enumerate(WORK_PRESETS): rb = QRadioButton(tr(key)) self.button_group.addButton(rb, i) layout.addWidget(rb) if i == 0: rb.setChecked(True) # 사용자 정의 custom_box = QGroupBox(tr('onboarding.preset.custom_box')) custom_layout = QVBoxLayout() custom_layout.setSpacing(4) suffix_h = tr('onboarding.preset.suffix_hours') suffix_m = tr('onboarding.preset.suffix_minutes') row1 = QHBoxLayout() self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio')) self.button_group.addButton(self.custom_radio, len(WORK_PRESETS)) self.hours_spin = QSpinBox() self.hours_spin.setRange(0, 12) self.hours_spin.setValue(8) self.hours_spin.setSuffix(suffix_h) self.minutes_spin = QSpinBox() self.minutes_spin.setRange(0, 59) self.minutes_spin.setSingleStep(15) self.minutes_spin.setSuffix(suffix_m) row1.addWidget(self.custom_radio) row1.addWidget(self.hours_spin) row1.addWidget(self.minutes_spin) row1.addStretch() custom_layout.addLayout(row1) row2 = QHBoxLayout() self.lunch_spin = QSpinBox() self.lunch_spin.setRange(0, 120) self.lunch_spin.setSingleStep(5) self.lunch_spin.setValue(60) self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix')) self.lunch_spin.setSuffix(suffix_m) self.dinner_spin = QSpinBox() self.dinner_spin.setRange(0, 120) self.dinner_spin.setSingleStep(5) self.dinner_spin.setValue(0) self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix')) self.dinner_spin.setSuffix(suffix_m) self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip')) row2.addSpacing(20) row2.addWidget(self.lunch_spin) row2.addWidget(self.dinner_spin) row2.addStretch() custom_layout.addLayout(row2) custom_box.setLayout(custom_layout) layout.addWidget(custom_box) self.setLayout(layout) def selected_minutes(self): """returns (work_minutes, lunch_minutes, dinner_minutes)""" idx = self.button_group.checkedId() if 0 <= idx < len(WORK_PRESETS): _, wm, lm, dm = WORK_PRESETS[idx] return wm, lm, dm return (self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value(), self.dinner_spin.value()) class ClockInDetectionPage(QWizardPage): def __init__(self): super().__init__() self.setTitle(tr('onboarding.detection_title')) self.setSubTitle(tr('onboarding.detection_subtitle')) layout = QVBoxLayout() self.option_boot = QRadioButton(tr('onboarding.detection_boot')) self.option_unlock = QRadioButton(tr('onboarding.detection_unlock')) self.option_manual = QRadioButton(tr('onboarding.detection_manual')) self.option_boot.setChecked(True) for opt in (self.option_boot, self.option_unlock, self.option_manual): layout.addWidget(opt) info = QLabel(tr('onboarding.detection_info')) info.setWordWrap(True) info.setStyleSheet("color: #909296; padding: 8px;") layout.addWidget(info) layout.addStretch() self.setLayout(layout) def detection_mode(self) -> str: if self.option_unlock.isChecked(): return 'unlock' if self.option_manual.isChecked(): return 'manual' return 'boot' class LeaveSalaryPage(QWizardPage): def __init__(self): super().__init__() self.setTitle(tr('onboarding.leave_salary_title')) self.setSubTitle(tr('onboarding.leave_salary_subtitle')) layout = QVBoxLayout() # 연차 leave_box = QGroupBox(tr('onboarding.leave_group')) leave_layout = QHBoxLayout() self.leave_spin = QSpinBox() self.leave_spin.setRange(0, 30) self.leave_spin.setValue(15) self.leave_spin.setSuffix(tr('label.unit_day')) leave_layout.addWidget(QLabel(tr('onboarding.my_leave'))) leave_layout.addWidget(self.leave_spin) leave_layout.addStretch() leave_box.setLayout(leave_layout) layout.addWidget(leave_box) # 급여 (옵션) salary_box = QGroupBox(tr('onboarding.salary_group')) salary_layout = QVBoxLayout() self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled')) salary_layout.addWidget(self.salary_enabled) wage_row = QHBoxLayout() wage_row.addWidget(QLabel(tr('onboarding.hourly_wage'))) self.wage_spin = QSpinBox() self.wage_spin.setRange(0, 1000000) self.wage_spin.setSingleStep(1000) self.wage_spin.setSuffix(tr('onboarding.wage_suffix')) self.wage_spin.setValue(0) self.wage_spin.setEnabled(False) wage_row.addWidget(self.wage_spin) wage_row.addStretch() salary_layout.addLayout(wage_row) rate_row = QHBoxLayout() rate_row.addWidget(QLabel(tr('onboarding.overtime_rate'))) self.rate_combo = QComboBox() self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0) self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5) self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0) self.rate_combo.setCurrentIndex(1) self.rate_combo.setEnabled(False) rate_row.addWidget(self.rate_combo) rate_row.addStretch() salary_layout.addLayout(rate_row) self.salary_enabled.toggled.connect(self.wage_spin.setEnabled) self.salary_enabled.toggled.connect(self.rate_combo.setEnabled) salary_box.setLayout(salary_layout) layout.addWidget(salary_box) layout.addStretch() self.setLayout(layout) class DiscordPage(QWizardPage): def __init__(self): super().__init__() self.setTitle(tr('onboarding.discord_title')) self.setSubTitle(tr('onboarding.discord_subtitle')) layout = QVBoxLayout() self.enable_check = QCheckBox(tr('onboarding.discord_enable')) layout.addWidget(self.enable_check) self.url_edit = QLineEdit() self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder')) self.url_edit.setEnabled(False) layout.addWidget(self.url_edit) guide = QLabel(tr('onboarding.discord_guide')) guide.setStyleSheet("color: #909296; padding: 6px;") guide.setWordWrap(True) layout.addWidget(guide) test_row = QHBoxLayout() self.test_btn = QPushButton(tr('onboarding.discord_test')) self.test_btn.setEnabled(False) self.test_btn.clicked.connect(self._test_webhook) test_row.addWidget(self.test_btn) test_row.addStretch() layout.addLayout(test_row) self.enable_check.toggled.connect(self.url_edit.setEnabled) self.enable_check.toggled.connect(self.test_btn.setEnabled) layout.addStretch() self.setLayout(layout) def _test_webhook(self): url = self.url_edit.text().strip() if not url: QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body')) return from utils import discord_webhook if not discord_webhook.is_valid_webhook_url(url): QMessageBox.warning( self, tr('onboarding.discord_url_invalid_title'), tr('onboarding.discord_url_invalid_body') ) return ok = discord_webhook.send_test(url) if ok: QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body')) else: QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body')) class FinishPage(QWizardPage): def __init__(self): super().__init__() self.setTitle(tr('onboarding.finish_title')) self.setSubTitle(tr('onboarding.finish_subtitle')) layout = QVBoxLayout() msg = QLabel(tr('onboarding.finish_msg')) msg.setWordWrap(True) layout.addWidget(msg) self.setLayout(layout) class OnboardingWizard(QWizard): """5단계 첫 실행 위저드. accept() 시 모든 설정 저장 + ONBOARDING_COMPLETED=true.""" def __init__(self, db, parent=None): super().__init__(parent) self.db = db self.setWindowTitle(tr('onboarding.window_title')) self.setMinimumSize(600, 500) self.setWizardStyle(QWizard.ModernStyle) self.setOption(QWizard.NoBackButtonOnStartPage, True) self.welcome_page = WelcomePage() self.work_page = WorkPatternPage() self.detect_page = ClockInDetectionPage() self.leave_page = LeaveSalaryPage() self.discord_page = DiscordPage() self.finish_page = FinishPage() for page in (self.welcome_page, self.work_page, self.detect_page, self.leave_page, self.discord_page, self.finish_page): self.addPage(page) apply_dark_titlebar(self) def accept(self): # 1. 근무 패턴 wm, lm, dm = self.work_page.selected_minutes() if wm < 30: QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small')) return settings = { 'work_minutes': wm, 'lunch_duration_minutes': lm, # 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기 'annual_leave_days': self.leave_page.leave_spin.value(), 'annual_leave_total': self.leave_page.leave_spin.value(), 'salary_enabled': self.leave_page.salary_enabled.isChecked(), 'hourly_wage': self.leave_page.wage_spin.value(), 'overtime_rate': self.leave_page.rate_combo.currentData(), 'onboarding_completed': True, } if dm > 0: settings['dinner_duration_minutes'] = dm # 2. 출근 감지 방식 mode = self.detect_page.detection_mode() settings['clock_in_on_unlock'] = (mode == 'unlock') # 3. Discord 웹훅 (옵션) if self.discord_page.enable_check.isChecked(): settings['discord_webhook_url'] = self.discord_page.url_edit.text().strip() self.db.save_settings(settings) super().accept() def maybe_show_onboarding(db, parent=None) -> bool: """ONBOARDING_COMPLETED=false 일 때만 위저드 표시. 사용자가 끝까지 완료하면 True.""" if db.get_setting('onboarding_completed', 'false').lower() == 'true': return False # 이미 완료 wizard = OnboardingWizard(db, parent) return wizard.exec_() == QWizard.Accepted