Added — 도전과제 시스템 (153개 자동 평가) - core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제 - ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿) - 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push - achievements 테이블 확장 (code/category/tier/is_secret/progress/target) - hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키 Changed — 다크 테마 디자인 리뉴얼 - ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress) - 통계/도움말/도전과제 다이얼로그 일관 다크 톤 - matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend) - 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드) Fixed — 안정성·일관성 - 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch) - DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환 - DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자) - crash_handler 다단계 폴백 (DB → 파일 → stderr) - updater PID race: 지수 backoff 재시도 (총 ~9초) - Discord URL 형식 검증 (snowflake regex) - 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증 - check_dinner_reminder 신규, 알림 임계값 5개 설정화 - closeEvent timer/notifier 정리 (aboutToQuit hook) - 마이그레이션 12개 모두 _conn() + try/finally - DB 인덱스 5개 추가 (break/overtime/leave date) Tests - pytest 116/116 PASS, 통합 시나리오 48/48 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""
|
|
첫 실행 온보딩 위저드.
|
|
|
|
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
|