""" 첫 실행 온보딩 위저드. 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("👋 환영합니다!") self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.") layout = QVBoxLayout() intro = QLabel( "이 앱은:\n" "• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n" "• 30분 단위 연장근무 적립\n" "• 연차·반차·외출 시간 추적\n" "• 매일 퇴근 시간을 1초마다 카운트다운\n\n" "[다음] 버튼을 눌러 시작하세요." ) intro.setWordWrap(True) layout.addWidget(intro) self.setLayout(layout) class WorkPatternPage(QWizardPage): def __init__(self): super().__init__() self.setTitle("🕘 근무 패턴") self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.") 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("⏰ 출근 시간 감지 방식") self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.") layout = QVBoxLayout() self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)") self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)") self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)") self.option_boot.setChecked(True) for opt in (self.option_boot, self.option_unlock, self.option_manual): layout.addWidget(opt) info = QLabel( "\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다." ) info.setWordWrap(True) info.setStyleSheet("color: #888; 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("🌴 연차 + 💰 급여 (옵션)") self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.") layout = QVBoxLayout() # 연차 leave_box = QGroupBox("연간 연차") leave_layout = QHBoxLayout() self.leave_spin = QSpinBox() self.leave_spin.setRange(0, 30) self.leave_spin.setValue(15) self.leave_spin.setSuffix(" 일") leave_layout.addWidget(QLabel("내 연차:")) leave_layout.addWidget(self.leave_spin) leave_layout.addStretch() leave_box.setLayout(leave_layout) layout.addWidget(leave_box) # 급여 (옵션) salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)") salary_layout = QVBoxLayout() self.salary_enabled = QCheckBox("급여 추정 활성화") salary_layout.addWidget(self.salary_enabled) wage_row = QHBoxLayout() wage_row.addWidget(QLabel("시급:")) self.wage_spin = QSpinBox() self.wage_spin.setRange(0, 1000000) self.wage_spin.setSingleStep(1000) self.wage_spin.setSuffix(" 원/시간") 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("연장수당 가산률:")) self.rate_combo = QComboBox() self.rate_combo.addItem("1.0배 (가산 없음)", 1.0) self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5) self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 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("💬 Discord 알림 (선택)") self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)") layout = QVBoxLayout() self.enable_check = QCheckBox("Discord 웹훅 알림 사용") layout.addWidget(self.enable_check) self.url_edit = QLineEdit() self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...") self.url_edit.setEnabled(False) layout.addWidget(self.url_edit) guide = QLabel( "셋업 방법:\n" "1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n" "2. 새 웹훅 만들기 → URL 복사\n" "3. 위 입력란에 붙여넣기" ) guide.setStyleSheet("color: #888; padding: 6px;") guide.setWordWrap(True) layout.addWidget(guide) test_row = QHBoxLayout() self.test_btn = QPushButton("테스트 메시지 보내기") 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, "URL 필요", "웹훅 URL을 먼저 입력해주세요.") return from utils import discord_webhook if not discord_webhook.is_valid_webhook_url(url): QMessageBox.warning( self, "URL 형식 오류", "Discord 웹훅 URL 형식이 아닙니다.\n" "예: https://discord.com/api/webhooks/{ID}/{TOKEN}" ) return ok = discord_webhook.send_test(url) if ok: QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.") else: QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.") class FinishPage(QWizardPage): def __init__(self): super().__init__() self.setTitle("🎉 준비 완료!") self.setSubTitle("이제 출근부터 자동 추적됩니다.") layout = QVBoxLayout() msg = QLabel( "설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n" "온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n" "🕐 단축키:\n" " • Ctrl+O — 출퇴근 토글\n" " • F1 — 도움말\n" " • F5 — 업데이트 확인\n" " • Ctrl+, — 설정" ) 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("Clock-out Calculator — 시작 설정") 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, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.") 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