diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e32bc9..e62d8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.3.0] — 2026-04-30 + +### Added — Phase 1 + E1 (소비자 친화 6종) +- **첫 실행 온보딩 위저드** (강제) — 5단계: 환영 → 근무패턴 → 출근 감지 방식 → 연차/시급(옵션) → Discord(옵션) → 완료 + - 신규 사용자: 자동 표시 / 기존 사용자(work_records 있음): 자동 완료 처리 + - "도움말 → 온보딩 다시 보기" 메뉴로 언제든 재실행 가능 +- **시급 → 추정 급여** (옵션) — 포괄임금이 아닐 때만 활성화 + - 통계 화면 월간 탭에 "이번 달 추정 급여" 카드 + - 퇴근 후 "오늘 요약" 카드에도 추정 급여 표시 + - 연장수당 가산률 1.0 / 1.5 / 2.0 선택 +- **출퇴근 시각 인라인 편집** — 메인 화면 출근/퇴근 라벨 클릭 → 즉시 수정 다이얼로그 +- **퇴근 후 "오늘 요약" 카드** — 메인 화면 상단에 총 근무/점심/외출/연장/추정급여 표시 + - 다음 출근 시 자동 숨김 / X 버튼으로 수동 닫기 +- **장시간 근무 휴식 권고 알림** — 연속 N시간(기본 4시간) 자리 비움 없으면 "🌿 잠시 일어나세요" 토스트 + - 5분 throttle + 일 1회 가드 (notification_log 테이블) +- **Discord 웹훅 알림** (옵션) — 출퇴근/휴식권고 모바일 push + - 봇 등록·서버 운영 0. 채널 웹훅 URL만 입력 + - 출근(녹색) / 퇴근 정시(파랑) / 퇴근 연장(주황) / 건강경고(분홍) embed + - 온보딩에서 즉시 활성화 + "테스트 메시지" 버튼 + +### Database +- `break_records.break_type` 컬럼 추가 ('break' / 'lunch' / 'dinner' 구분) +- `notification_log` 테이블 신규 (channel, event_type, sent_at, success — 중복 발송 가드 + 통계용) +- 기존 사용자 `onboarding_completed` 자동 true 처리 마이그레이션 + +### Settings (신규 11개) +- `onboarding_completed`, `salary_enabled`, `hourly_wage`, `overtime_rate` +- `health_break_enabled`, `health_break_hours` +- `discord_webhook_url`, `discord_notif_clock_in`, `discord_notif_clock_out`, `discord_notif_health` + ## [2.2.4] — 2026-04-30 ### Added diff --git a/core/database.py b/core/database.py index be6bbaa..de23714 100644 --- a/core/database.py +++ b/core/database.py @@ -161,6 +161,9 @@ class Database: self.migrate_cleanup_balance_adjustments() self.migrate_work_hours_to_minutes() self.migrate_annual_leave_keys() + self.migrate_v23_break_type() + self.migrate_v23_notification_log() + self.migrate_v23_onboarding_for_existing() # 기본 설정 초기화 self.init_default_settings() @@ -467,6 +470,111 @@ class Database: finally: conn.close() + def migrate_v23_break_type(self): + """break_records에 break_type 컬럼 추가 (v2.3.0). + 값: 'break'(기본 외출) / 'lunch' / 'dinner'. + 기존 점심 1시간 자동 적용 모드와 무관 — 실제 시간 입력용. + """ + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute("PRAGMA table_info(break_records)") + cols = [row[1] for row in cursor.fetchall()] + if 'break_type' not in cols: + cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'") + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_v23_notification_log(self): + """알림 발송 이력 테이블 (v2.3.0). 중복 발송 방지 + 통계.""" + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute(''' + CREATE TABLE IF NOT EXISTS notification_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL, + event_type TEXT NOT NULL, + payload TEXT, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN DEFAULT 1 + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_notif_log_event + ON notification_log(event_type, sent_at) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"notification_log 생성 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def log_notification(self, channel: str, event_type: str, + payload: str = None, success: bool = True) -> None: + """알림 발송 이력 기록 (중복 방지 가드용).""" + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute( + "INSERT INTO notification_log (channel, event_type, payload, success) VALUES (?, ?, ?, ?)", + (channel, event_type, payload, success) + ) + conn.commit() + finally: + conn.close() + + def has_notification_today(self, channel: str, event_type: str) -> bool: + """오늘 같은 (channel, event_type) 발송 이력 존재 여부.""" + conn = self.get_connection() + cursor = conn.cursor() + try: + cursor.execute( + "SELECT COUNT(*) FROM notification_log " + "WHERE channel = ? AND event_type = ? AND DATE(sent_at) = DATE('now', 'localtime')", + (channel, event_type) + ) + return cursor.fetchone()[0] > 0 + finally: + conn.close() + + def migrate_v23_onboarding_for_existing(self): + """기존 사용자(이미 work_records 데이터 있음)는 온보딩 자동 완료 처리. + + v2.3.0 도입 시 한 번만 실행. 신규 DB(데이터 0)는 영향 없음 → 첫 실행 시 위저드. + """ + conn = self.get_connection() + cursor = conn.cursor() + try: + # 이미 완료/스킵 마크 있으면 패스 + cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'") + row = cursor.fetchone() + if row and row[0] == 'true': + return + + # 기존 work_records 데이터가 1건 이상 있으면 자동 완료 + cursor.execute("SELECT COUNT(*) FROM work_records") + count = cursor.fetchone()[0] + if count > 0: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr) + finally: + conn.close() + def get_setting_int(self, key: str, default: int = 0) -> int: """설정을 int로 안전하게 조회 (변환 실패 시 default).""" raw = self.get_setting(key, None) @@ -533,6 +641,17 @@ class Database: 'workday_boundary_hour': '6', 'overtime_unit': '30', 'time_format': '24', + # v2.3.0 + 'onboarding_completed': 'false', + 'salary_enabled': 'false', + 'hourly_wage': '0', + 'overtime_rate': '1.5', + 'health_break_enabled': 'true', + 'health_break_hours': '4', + 'discord_webhook_url': '', + 'discord_notif_clock_in': 'true', + 'discord_notif_clock_out': 'true', + 'discord_notif_health': 'true', } conn = self.get_connection() diff --git a/core/notifier.py b/core/notifier.py index 03f3d6b..44bb2df 100644 --- a/core/notifier.py +++ b/core/notifier.py @@ -164,6 +164,33 @@ class Notifier(QObject): ) self.notified_weekly = True + def check_health_break(self, clock_in_time, break_minutes: int, current_time=None): + """장시간 연속 근무 휴식 알림. + + 조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과, + 오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서. + """ + if current_time is None: + current_time = datetime.now() + if self.db is None: + return + if not self._enabled('health_break_enabled'): + return + if self.db.has_notification_today('system', 'health_break'): + return + try: + threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4'))) + except (ValueError, TypeError): + threshold_hours = 4 + elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60 + if elapsed_sec >= threshold_hours * 3600: + from core.i18n import tr + self.notification_signal.emit( + tr('notif.health_break.title') if False else "🌿 휴식 권고", + f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.", + ) + self.db.log_notification('system', 'health_break') + def reset_notifications(self): """알림 상태 리셋 (날짜 변경 시)""" self.notified_30min = False diff --git a/core/salary.py b/core/salary.py new file mode 100644 index 0000000..3ffff8b --- /dev/null +++ b/core/salary.py @@ -0,0 +1,50 @@ +""" +급여 추정 (옵션). + +포괄임금제 회사면 사용 안 함. 시급 + 연장수당 가산률만 받아서 단순 계산. +""" +from __future__ import annotations +from typing import List, Dict + + +def estimate_pay(records: List[Dict], hourly_wage: float, + overtime_rate: float = 1.5) -> Dict[str, float]: + """근무 기록 리스트로부터 추정 급여 계산. + + Args: + records: [{'total_hours': float, 'overtime_minutes': int}, ...] + hourly_wage: 시급(원) + overtime_rate: 연장수당 가산률 (기본 1.5배 - 한국 노동법) + + Returns: + {'base': 기본급, 'overtime': 연장수당, 'total': 합계} + """ + if hourly_wage <= 0: + return {'base': 0.0, 'overtime': 0.0, 'total': 0.0} + + base_hours = 0.0 + overtime_hours = 0.0 + + for r in records: + total = float(r.get('total_hours') or 0) + ot_min = int(r.get('overtime_minutes') or 0) + ot_hours = ot_min / 60.0 + # 정규 근무 = 총 - 연장 + regular = max(0.0, total - ot_hours) + base_hours += regular + overtime_hours += ot_hours + + base = base_hours * hourly_wage + overtime = overtime_hours * hourly_wage * overtime_rate + return { + 'base': base, + 'overtime': overtime, + 'total': base + overtime, + 'base_hours': base_hours, + 'overtime_hours': overtime_hours, + } + + +def format_won(amount: float) -> str: + """원화 포맷팅. 1234567 → '1,234,567원'.""" + return f"{int(round(amount)):,}원" diff --git a/core/settings_keys.py b/core/settings_keys.py index ae926a4..b2a537a 100644 --- a/core/settings_keys.py +++ b/core/settings_keys.py @@ -44,6 +44,25 @@ DB_PATH_OVERRIDE = 'db_path_override' # 백업 LAST_BACKUP_DATE = 'last_backup_date' +# === v2.3.0 신규 === +# 온보딩 +ONBOARDING_COMPLETED = 'onboarding_completed' + +# 급여 (옵션, 포괄임금이면 미설정) +SALARY_ENABLED = 'salary_enabled' +HOURLY_WAGE = 'hourly_wage' +OVERTIME_RATE = 'overtime_rate' # 1.5 + +# 휴식 알림 +HEALTH_BREAK_ENABLED = 'health_break_enabled' +HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4 + +# Discord 웹훅 +DISCORD_WEBHOOK_URL = 'discord_webhook_url' +DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in' +DISCORD_NOTIF_CLOCK_OUT = 'discord_notif_clock_out' +DISCORD_NOTIF_HEALTH = 'discord_notif_health' + # 마이그레이션 sentinel ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated' BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2' diff --git a/core/version.py b/core/version.py index a3d3674..d5701fe 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.2.4' +__version__ = '2.3.0' diff --git a/main.py b/main.py index 9898b4f..ce12552 100644 --- a/main.py +++ b/main.py @@ -128,6 +128,14 @@ def main(): from utils.debug_log import dlog dlog(f"backup failed: {e}") + # 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시 + try: + from ui.onboarding_view import maybe_show_onboarding + maybe_show_onboarding(db) + except Exception as e: + from utils.debug_log import dlog + dlog(f"onboarding skipped: {e}") + # 메인 윈도우 생성 및 표시 try: window = MainWindow() diff --git a/ui/controllers/notification_orchestrator.py b/ui/controllers/notification_orchestrator.py index c5319b8..41e486b 100644 --- a/ui/controllers/notification_orchestrator.py +++ b/ui/controllers/notification_orchestrator.py @@ -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 diff --git a/ui/main_window.py b/ui/main_window.py index 03b8825..67eaf27 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -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__ diff --git a/ui/onboarding_view.py b/ui/onboarding_view.py new file mode 100644 index 0000000..8f87515 --- /dev/null +++ b/ui/onboarding_view.py @@ -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 diff --git a/ui/stats_view.py b/ui/stats_view.py index 43814fa..bf0e095 100644 --- a/ui/stats_view.py +++ b/ui/stats_view.py @@ -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: diff --git a/ui/today_summary.py b/ui/today_summary.py new file mode 100644 index 0000000..81724cb --- /dev/null +++ b/ui/today_summary.py @@ -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) diff --git a/utils/discord_webhook.py b/utils/discord_webhook.py new file mode 100644 index 0000000..ed4ef77 --- /dev/null +++ b/utils/discord_webhook.py @@ -0,0 +1,108 @@ +""" +Discord 웹훅 알림 (단방향 push). + +URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안 막음). +""" +from __future__ import annotations +import json +import urllib.request +import urllib.error +from datetime import datetime +from typing import Optional, List + +# Discord embed 색상 (decimal) +COLOR_GREEN = 0x57F287 +COLOR_BLUE = 0x5865F2 +COLOR_YELLOW = 0xFEE75C +COLOR_PINK = 0xEB459E +COLOR_ORANGE = 0xED4245 + + +def send(webhook_url: str, title: str, description: str, + color: int = COLOR_BLUE, fields: Optional[List[dict]] = None, + timeout: int = 5) -> bool: + """Discord 웹훅으로 embed 메시지 발송. + + Args: + webhook_url: Discord webhook URL (https://discord.com/api/webhooks/{ID}/{TOKEN}) + title, description: embed 본문 + color: embed 좌측 색상 바 (10진수, 0xRRGGBB) + fields: [{"name": "필드명", "value": "값", "inline": True}, ...] + timeout: 요청 타임아웃(초) + + Returns: + 성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False. + """ + if not webhook_url or not webhook_url.startswith('https://'): + return False + + payload = { + "embeds": [{ + "title": title, + "description": description, + "color": color, + "fields": fields or [], + "timestamp": datetime.utcnow().isoformat(), + "footer": {"text": "Clock-out Time Calculator"}, + }] + } + data = json.dumps(payload, ensure_ascii=False).encode('utf-8') + req = urllib.request.Request( + webhook_url, data=data, + headers={'Content-Type': 'application/json; charset=utf-8'}, + method='POST', + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return 200 <= resp.status < 300 + except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError): + return False + + +def send_test(webhook_url: str) -> bool: + """온보딩/설정에서 호출하는 테스트 메시지.""" + return send( + webhook_url, + title="🔔 테스트 알림", + description="Clock-out Time Calculator의 Discord 연동이 정상 작동합니다.", + color=COLOR_GREEN, + ) + + +def send_clock_in(webhook_url: str, time_str: str) -> bool: + return send( + webhook_url, + title="🟢 출근", + description=f"오늘 {time_str}에 출근 기록되었습니다.", + color=COLOR_GREEN, + ) + + +def send_clock_out(webhook_url: str, time_str: str, total_hours: float, + overtime_minutes: int, overtime_earned: int) -> bool: + fields = [ + {"name": "총 근무시간", "value": f"{total_hours:.1f}시간", "inline": True}, + ] + if overtime_minutes > 0: + fields.append({ + "name": "연장근무", + "value": f"{overtime_minutes}분 → {overtime_earned}분 적립", + "inline": True, + }) + color = COLOR_YELLOW if overtime_minutes > 0 else COLOR_BLUE + return send( + webhook_url, + title="✅ 퇴근", + description=f"오늘 {time_str}에 퇴근 처리되었습니다.", + color=color, + fields=fields, + ) + + +def send_health_warning(webhook_url: str, hours_continuous: float) -> bool: + return send( + webhook_url, + title="🌿 휴식 권고", + description=f"{hours_continuous:.1f}시간 연속 근무 중입니다.\n잠시 일어나서 스트레칭하세요.", + color=COLOR_PINK, + )