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:
parent
14d88656fe
commit
e4011d9fc3
30
CHANGELOG.md
30
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/).
|
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
|
## [2.2.4] — 2026-04-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
119
core/database.py
119
core/database.py
@ -161,6 +161,9 @@ class Database:
|
|||||||
self.migrate_cleanup_balance_adjustments()
|
self.migrate_cleanup_balance_adjustments()
|
||||||
self.migrate_work_hours_to_minutes()
|
self.migrate_work_hours_to_minutes()
|
||||||
self.migrate_annual_leave_keys()
|
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()
|
self.init_default_settings()
|
||||||
@ -467,6 +470,111 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def get_setting_int(self, key: str, default: int = 0) -> int:
|
||||||
"""설정을 int로 안전하게 조회 (변환 실패 시 default)."""
|
"""설정을 int로 안전하게 조회 (변환 실패 시 default)."""
|
||||||
raw = self.get_setting(key, None)
|
raw = self.get_setting(key, None)
|
||||||
@ -533,6 +641,17 @@ class Database:
|
|||||||
'workday_boundary_hour': '6',
|
'workday_boundary_hour': '6',
|
||||||
'overtime_unit': '30',
|
'overtime_unit': '30',
|
||||||
'time_format': '24',
|
'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()
|
conn = self.get_connection()
|
||||||
|
|||||||
@ -164,6 +164,33 @@ class Notifier(QObject):
|
|||||||
)
|
)
|
||||||
self.notified_weekly = True
|
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):
|
def reset_notifications(self):
|
||||||
"""알림 상태 리셋 (날짜 변경 시)"""
|
"""알림 상태 리셋 (날짜 변경 시)"""
|
||||||
self.notified_30min = False
|
self.notified_30min = False
|
||||||
|
|||||||
50
core/salary.py
Normal file
50
core/salary.py
Normal file
@ -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)):,}원"
|
||||||
@ -44,6 +44,25 @@ DB_PATH_OVERRIDE = 'db_path_override'
|
|||||||
# 백업
|
# 백업
|
||||||
LAST_BACKUP_DATE = 'last_backup_date'
|
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
|
# 마이그레이션 sentinel
|
||||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||||
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.2.4'
|
__version__ = '2.3.0'
|
||||||
|
|||||||
8
main.py
8
main.py
@ -128,6 +128,14 @@ def main():
|
|||||||
from utils.debug_log import dlog
|
from utils.debug_log import dlog
|
||||||
dlog(f"backup failed: {e}")
|
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:
|
try:
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
|
|||||||
@ -26,15 +26,39 @@ class NotificationOrchestrator:
|
|||||||
if remaining_seconds < 0:
|
if remaining_seconds < 0:
|
||||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
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:
|
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||||
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()
|
consecutive = self.db.get_consecutive_overtime_days()
|
||||||
if consecutive >= 3:
|
if consecutive >= 3:
|
||||||
n.notify_health_warning(consecutive)
|
n.notify_health_warning(consecutive)
|
||||||
|
self._discord_health(consecutive, break_minutes, now)
|
||||||
|
|
||||||
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
||||||
if weekly_hours > 52:
|
if weekly_hours > 52:
|
||||||
n.notify_weekly_hours(weekly_hours)
|
n.notify_weekly_hours(weekly_hours)
|
||||||
balance_minutes = self.db.get_total_overtime_balance()
|
balance_minutes = self.db.get_total_overtime_balance()
|
||||||
if balance_minutes >= 1200:
|
if balance_minutes >= 1200:
|
||||||
n.notify_overtime_threshold(balance_minutes / 60.0)
|
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
|
||||||
|
|||||||
@ -219,6 +219,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.date_label.setAlignment(Qt.AlignCenter)
|
self.date_label.setAlignment(Qt.AlignCenter)
|
||||||
main_layout.addWidget(self.date_label)
|
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. 출근 정보 그룹
|
# 2. 출근 정보 그룹
|
||||||
clock_in_group = self.create_clock_in_group()
|
clock_in_group = self.create_clock_in_group()
|
||||||
main_layout.addWidget(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 = QLabel("--:--:--")
|
||||||
self.clock_in_value.setObjectName("time_value")
|
self.clock_in_value.setObjectName("time_value")
|
||||||
self.clock_in_value.setMinimumWidth(90)
|
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.setObjectName("btn_small")
|
||||||
self.edit_clock_in_button.setFixedWidth(70)
|
self.edit_clock_in_button.setFixedWidth(70)
|
||||||
self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
|
self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
|
||||||
@ -1169,6 +1178,12 @@ class MainWindow(QMainWindow):
|
|||||||
# 잔액 업데이트
|
# 잔액 업데이트
|
||||||
self.update_overtime_balance()
|
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):
|
def cancel_clock_out(self):
|
||||||
"""퇴근 취소"""
|
"""퇴근 취소"""
|
||||||
# 확인 대화상자
|
# 확인 대화상자
|
||||||
@ -1579,6 +1594,13 @@ class MainWindow(QMainWindow):
|
|||||||
f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}"
|
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):
|
def show_stats(self):
|
||||||
"""통계 창 표시"""
|
"""통계 창 표시"""
|
||||||
dialog = StatsView(self, self.db)
|
dialog = StatsView(self, self.db)
|
||||||
@ -1619,6 +1641,77 @@ class MainWindow(QMainWindow):
|
|||||||
dialog = HelpView(self)
|
dialog = HelpView(self)
|
||||||
dialog.exec_()
|
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):
|
def check_for_updates(self, silent: bool = False):
|
||||||
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
||||||
from core.version import __version__
|
from core.version import __version__
|
||||||
|
|||||||
322
ui/onboarding_view.py
Normal file
322
ui/onboarding_view.py
Normal file
@ -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.setSpacing(6)
|
||||||
layout.setContentsMargins(4, 4, 4, 4)
|
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_group = QGroupBox(tr('stats.monthly_summary'))
|
||||||
summary_layout = QGridLayout()
|
summary_layout = QGridLayout()
|
||||||
summary_layout.setSpacing(4)
|
summary_layout.setSpacing(4)
|
||||||
@ -124,6 +129,7 @@ class StatsView(QDialog):
|
|||||||
|
|
||||||
summary_group.setLayout(summary_layout)
|
summary_group.setLayout(summary_layout)
|
||||||
layout.addWidget(summary_group)
|
layout.addWidget(summary_group)
|
||||||
|
layout.addWidget(self.salary_label)
|
||||||
|
|
||||||
# 월간 차트
|
# 월간 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
@ -206,9 +212,36 @@ class StatsView(QDialog):
|
|||||||
if hasattr(self, 'monthly_chart'):
|
if hasattr(self, 'monthly_chart'):
|
||||||
draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', []))
|
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', []))
|
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):
|
def analyze_patterns(self, records):
|
||||||
"""패턴 분석"""
|
"""패턴 분석"""
|
||||||
if not records:
|
if not records:
|
||||||
|
|||||||
89
ui/today_summary.py
Normal file
89
ui/today_summary.py
Normal file
@ -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)
|
||||||
108
utils/discord_webhook.py
Normal file
108
utils/discord_webhook.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
Loading…
x
Reference in New Issue
Block a user