v2.3.0: Phase 1 + E1 — onboarding wizard, salary, inline edit, today card, health break, Discord webhook
Added (6 user-friendly features):
- Onboarding wizard (5 steps, forced on first launch, re-runnable from Help menu)
- Salary estimation (optional, hourly wage + overtime multiplier)
- Inline clock-in/out time editing (click label to edit)
- Today summary card (post-clockout, auto-hide on next clock-in)
- Health break reminder (continuous N-hour at desk warning)
- Discord webhook notifications (clock-in/out/health, mobile push, no server)
Database:
- break_records.break_type column ('break'/'lunch'/'dinner')
- notification_log table (dedupe + analytics)
- Auto-complete onboarding for existing users (work_records exists)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
14d88656fe
commit
e4011d9fc3
@@ -26,15 +26,39 @@ class NotificationOrchestrator:
|
||||
if remaining_seconds < 0:
|
||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
||||
|
||||
# 5분 간격 throttle: 건강/주간/누적
|
||||
# 5분 간격 throttle: 건강/주간/누적/휴식권고
|
||||
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||
self._last_5min_bucket = now.minute
|
||||
|
||||
# 휴식 권고 (장시간 연속 근무)
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
||||
|
||||
consecutive = self.db.get_consecutive_overtime_days()
|
||||
if consecutive >= 3:
|
||||
n.notify_health_warning(consecutive)
|
||||
self._discord_health(consecutive, break_minutes, now)
|
||||
|
||||
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
||||
if weekly_hours > 52:
|
||||
n.notify_weekly_hours(weekly_hours)
|
||||
balance_minutes = self.db.get_total_overtime_balance()
|
||||
if balance_minutes >= 1200:
|
||||
n.notify_overtime_threshold(balance_minutes / 60.0)
|
||||
|
||||
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
|
||||
"""건강 경고 Discord push (옵션)."""
|
||||
if self.db.has_notification_today('discord', 'health'):
|
||||
return
|
||||
if self.db.get_setting('discord_notif_health', 'true').lower() != 'true':
|
||||
return
|
||||
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
from utils import discord_webhook
|
||||
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
||||
ok = discord_webhook.send_health_warning(url, elapsed)
|
||||
self.db.log_notification('discord', 'health', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
+94
-1
@@ -219,6 +219,11 @@ class MainWindow(QMainWindow):
|
||||
self.date_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(self.date_label)
|
||||
|
||||
# 1.5 오늘 요약 카드 (퇴근 후 표시, 평소엔 숨김)
|
||||
from ui.today_summary import TodaySummaryCard
|
||||
self.today_summary_card = TodaySummaryCard()
|
||||
main_layout.addWidget(self.today_summary_card)
|
||||
|
||||
# 2. 출근 정보 그룹
|
||||
clock_in_group = self.create_clock_in_group()
|
||||
main_layout.addWidget(clock_in_group)
|
||||
@@ -356,7 +361,11 @@ class MainWindow(QMainWindow):
|
||||
self.clock_in_value = QLabel("--:--:--")
|
||||
self.clock_in_value.setObjectName("time_value")
|
||||
self.clock_in_value.setMinimumWidth(90)
|
||||
self.edit_clock_in_button = QPushButton("수정")
|
||||
# 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
|
||||
self.clock_in_value.setCursor(Qt.PointingHandCursor)
|
||||
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
|
||||
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
|
||||
self.edit_clock_in_button = QPushButton("✏️ 수정")
|
||||
self.edit_clock_in_button.setObjectName("btn_small")
|
||||
self.edit_clock_in_button.setFixedWidth(70)
|
||||
self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
|
||||
@@ -1169,6 +1178,12 @@ class MainWindow(QMainWindow):
|
||||
# 잔액 업데이트
|
||||
self.update_overtime_balance()
|
||||
|
||||
# Discord 웹훅 push (옵션)
|
||||
self._discord_push_clock_out(now, total_hours, overtime_actual, overtime_earned)
|
||||
|
||||
# 오늘 요약 카드 표시
|
||||
self._show_today_summary(total_hours, overtime_actual, overtime_earned, break_minutes)
|
||||
|
||||
def cancel_clock_out(self):
|
||||
"""퇴근 취소"""
|
||||
# 확인 대화상자
|
||||
@@ -1579,6 +1594,13 @@ class MainWindow(QMainWindow):
|
||||
f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}"
|
||||
)
|
||||
|
||||
# Discord 웹훅 (옵션)
|
||||
self._discord_push_clock_in(selected_time)
|
||||
|
||||
# 오늘 요약 카드 숨김 (새 출근 시작)
|
||||
if hasattr(self, 'today_summary_card'):
|
||||
self.today_summary_card.hide()
|
||||
|
||||
def show_stats(self):
|
||||
"""통계 창 표시"""
|
||||
dialog = StatsView(self, self.db)
|
||||
@@ -1619,6 +1641,77 @@ class MainWindow(QMainWindow):
|
||||
dialog = HelpView(self)
|
||||
dialog.exec_()
|
||||
|
||||
def show_onboarding(self):
|
||||
"""온보딩 위저드 다시 보기."""
|
||||
from ui.onboarding_view import OnboardingWizard
|
||||
wizard = OnboardingWizard(self.db, self)
|
||||
if wizard.exec_():
|
||||
self.reload_settings()
|
||||
QMessageBox.information(self, "설정 업데이트", "변경된 설정이 즉시 반영되었습니다.")
|
||||
|
||||
# ===== Discord 웹훅 push (옵션, 실패 silent) =====
|
||||
def _show_today_summary(self, total_hours, overtime_actual, overtime_earned, break_minutes):
|
||||
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
|
||||
if not hasattr(self, 'today_summary_card'):
|
||||
return
|
||||
# 점심 시간 계산 (lunch_break_enabled면 설정값, 아니면 0)
|
||||
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
|
||||
|
||||
# 추정 급여 (옵션)
|
||||
salary_text = ""
|
||||
if self.db.get_setting('salary_enabled', 'false').lower() == 'true':
|
||||
try:
|
||||
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
|
||||
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
|
||||
if wage > 0:
|
||||
from core.salary import estimate_pay, format_won
|
||||
fake_record = {'total_hours': total_hours, 'overtime_minutes': overtime_actual}
|
||||
result = estimate_pay([fake_record], wage, rate)
|
||||
salary_text = f"오늘 추정: {format_won(result['total'])}"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
self.today_summary_card.show_summary(
|
||||
total_hours=total_hours,
|
||||
lunch_minutes=lunch_min,
|
||||
break_minutes=break_minutes,
|
||||
overtime_actual=overtime_actual,
|
||||
overtime_earned=overtime_earned,
|
||||
salary_text=salary_text,
|
||||
)
|
||||
|
||||
def _discord_url(self) -> str:
|
||||
return self.db.get_setting('discord_webhook_url', '') or ''
|
||||
|
||||
def _discord_push_clock_in(self, when):
|
||||
if self.db.get_setting('discord_notif_clock_in', 'true').lower() != 'true':
|
||||
return
|
||||
url = self._discord_url()
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
from utils import discord_webhook
|
||||
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
|
||||
self.db.log_notification('discord', 'clock_in', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned):
|
||||
if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true':
|
||||
return
|
||||
url = self._discord_url()
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
from utils import discord_webhook
|
||||
ok = discord_webhook.send_clock_out(
|
||||
url, when.strftime('%H:%M:%S'),
|
||||
total_hours, overtime_actual, overtime_earned,
|
||||
)
|
||||
self.db.log_notification('discord', 'clock_out', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_for_updates(self, silent: bool = False):
|
||||
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
||||
from core.version import __version__
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
첫 실행 온보딩 위저드.
|
||||
|
||||
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 ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# (label, work_minutes, lunch_minutes)
|
||||
WORK_PRESETS = [
|
||||
("표준 8시간 (점심 60분)", 480, 60),
|
||||
("단축근무 7시간 30분 (점심 30분)", 450, 30),
|
||||
("단축근무 7시간 (점심 60분)", 420, 60),
|
||||
("단축근무 6시간 (점심 30분)", 360, 30),
|
||||
("반일 4시간 (점심 0분)", 240, 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, (label, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(label)
|
||||
self.button_group.addButton(rb, i)
|
||||
layout.addWidget(rb)
|
||||
if i == 0:
|
||||
rb.setChecked(True)
|
||||
|
||||
# 사용자 정의
|
||||
custom_box = QGroupBox("사용자 정의")
|
||||
custom_layout = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton("직접 입력:")
|
||||
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(" 시간")
|
||||
self.minutes_spin = QSpinBox()
|
||||
self.minutes_spin.setRange(0, 59)
|
||||
self.minutes_spin.setSingleStep(15)
|
||||
self.minutes_spin.setSuffix(" 분")
|
||||
self.lunch_spin = QSpinBox()
|
||||
self.lunch_spin.setRange(0, 120)
|
||||
self.lunch_spin.setSingleStep(5)
|
||||
self.lunch_spin.setValue(60)
|
||||
self.lunch_spin.setPrefix("점심 ")
|
||||
self.lunch_spin.setSuffix(" 분")
|
||||
custom_layout.addWidget(self.custom_radio)
|
||||
custom_layout.addWidget(self.hours_spin)
|
||||
custom_layout.addWidget(self.minutes_spin)
|
||||
custom_layout.addWidget(self.lunch_spin)
|
||||
custom_layout.addStretch()
|
||||
custom_box.setLayout(custom_layout)
|
||||
layout.addWidget(custom_box)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_minutes(self):
|
||||
idx = self.button_group.checkedId()
|
||||
if 0 <= idx < len(WORK_PRESETS):
|
||||
_, wm, lm = WORK_PRESETS[idx]
|
||||
return wm, lm
|
||||
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_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
|
||||
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 = self.work_page.selected_minutes()
|
||||
if wm < 30:
|
||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||
return
|
||||
|
||||
settings = {
|
||||
'work_minutes': wm,
|
||||
'lunch_duration_minutes': lm,
|
||||
'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,
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -99,6 +99,11 @@ class StatsView(QDialog):
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
# 추정 급여 카드 (옵션 활성 시)
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
summary_group = QGroupBox(tr('stats.monthly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
@@ -124,6 +129,7 @@ class StatsView(QDialog):
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
layout.addWidget(self.salary_label)
|
||||
|
||||
# 월간 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
@@ -206,9 +212,36 @@ class StatsView(QDialog):
|
||||
if hasattr(self, 'monthly_chart'):
|
||||
draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', []))
|
||||
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
self._update_salary_estimate(monthly_stats.get('records', []))
|
||||
|
||||
# 패턴 분석
|
||||
self.analyze_patterns(monthly_stats.get('records', []))
|
||||
|
||||
def _update_salary_estimate(self, records):
|
||||
"""월간 추정 급여 표시 (SALARY_ENABLED=true 일 때만)."""
|
||||
if not hasattr(self, 'salary_label'):
|
||||
return
|
||||
if self.db.get_setting('salary_enabled', 'false').lower() != 'true':
|
||||
self.salary_label.setVisible(False)
|
||||
return
|
||||
try:
|
||||
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
|
||||
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
|
||||
except (ValueError, TypeError):
|
||||
self.salary_label.setVisible(False)
|
||||
return
|
||||
if wage <= 0:
|
||||
self.salary_label.setVisible(False)
|
||||
return
|
||||
from core.salary import estimate_pay, format_won
|
||||
result = estimate_pay(records, wage, rate)
|
||||
self.salary_label.setText(
|
||||
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
||||
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
||||
)
|
||||
self.salary_label.setVisible(True)
|
||||
|
||||
def analyze_patterns(self, records):
|
||||
"""패턴 분석"""
|
||||
if not records:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
퇴근 후 표시되는 "오늘 요약" 카드 위젯.
|
||||
|
||||
다음 출근 시 자동 숨김. 메인 화면 상단에 conditional하게 표시.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
class TodaySummaryCard(QFrame):
|
||||
"""퇴근 처리 직후 표시되는 요약 카드."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("today_summary_card")
|
||||
self.setStyleSheet("""
|
||||
QFrame#today_summary_card {
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
QLabel { padding: 1px; }
|
||||
""")
|
||||
self.setVisible(False)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(10, 6, 10, 6)
|
||||
layout.setSpacing(2)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("📋 오늘의 요약")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
close_btn = QPushButton("✕")
|
||||
close_btn.setFixedSize(20, 20)
|
||||
close_btn.setStyleSheet("border: none; font-weight: bold;")
|
||||
close_btn.clicked.connect(self.hide)
|
||||
header.addWidget(close_btn)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.total_label = QLabel("")
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
|
||||
layout.addWidget(self.total_label)
|
||||
layout.addWidget(self.detail_label)
|
||||
layout.addWidget(self.salary_label)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def show_summary(self, total_hours: float, lunch_minutes: int,
|
||||
break_minutes: int, overtime_actual: int,
|
||||
overtime_earned: int, salary_text: str = "") -> None:
|
||||
"""카드 내용 채우고 표시.
|
||||
|
||||
Args:
|
||||
total_hours: 총 근무시간(시간)
|
||||
lunch_minutes: 점심 시간(분)
|
||||
break_minutes: 외출 시간(분)
|
||||
overtime_actual: 실제 연장근무(분)
|
||||
overtime_earned: 적립 연장근무(분)
|
||||
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
||||
"""
|
||||
h = int(total_hours)
|
||||
m = int((total_hours - h) * 60)
|
||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
||||
|
||||
details = []
|
||||
if lunch_minutes > 0:
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
if break_minutes > 0:
|
||||
details.append(f"외출 {break_minutes}분")
|
||||
if overtime_actual > 0:
|
||||
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
|
||||
self.detail_label.setText(" · ".join(details) if details else "")
|
||||
self.detail_label.setVisible(bool(details))
|
||||
|
||||
if salary_text:
|
||||
self.salary_label.setText(f"💰 {salary_text}")
|
||||
self.salary_label.setVisible(True)
|
||||
else:
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
self.setVisible(True)
|
||||
Reference in New Issue
Block a user