Compare commits
6
Commits
v2.2.3
...
9ebf4ad961
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebf4ad961 | ||
|
|
d3a4efc173 | ||
|
|
86cf438785 | ||
|
|
fc5184ae07 | ||
|
|
e4011d9fc3 | ||
|
|
14d88656fe |
@@ -4,6 +4,105 @@ 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.4.0] — 2026-04-30
|
||||||
|
|
||||||
|
### Added — Phase 2 (5종)
|
||||||
|
- **점심/저녁 실제 시간 입력** — 점심/저녁 버튼 우클릭 → 시작·종료 시각 입력 다이얼로그
|
||||||
|
- 자동 60분 대신 정확한 분 단위 기록 (`break_records.break_type='lunch'/'dinner'`)
|
||||||
|
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
|
||||||
|
- 비어있는 날짜 우클릭: "기록 추가" — 출/퇴근/점심/메모 입력
|
||||||
|
- 기록 있는 날짜 우클릭: "편집"/"삭제"
|
||||||
|
- **월간 목표 설정 + 진행률**
|
||||||
|
- 설정 → 월 연장근무 상한 (시간/분) + 일 평균 근무 목표 (시간)
|
||||||
|
- 통계 → 월간 탭에 진행률 게이지 (60%/100% 임계 시 색상 변경)
|
||||||
|
- 0=비활성 (비활성 시 위젯 자체 숨김)
|
||||||
|
- **CSV 가져오기** — 표준 포맷 `date,clock_in,clock_out,lunch_minutes,memo`
|
||||||
|
- 충돌 정책: 덮어쓰기/건너뛰기/취소
|
||||||
|
- **자동 Crash Report (Gitea Issues)**
|
||||||
|
- 전역 예외 후킹 → crash_log 저장 + 사용자에게 다이얼로그
|
||||||
|
- "복사" / "Gitea에 보고" (PAT 옵션) — issue 자동 생성
|
||||||
|
|
||||||
|
### Settings (신규 4개)
|
||||||
|
- `goal_overtime_max_monthly`, `goal_avg_hours_daily`
|
||||||
|
- `gitea_feedback_token`, `gitea_feedback_enabled`
|
||||||
|
|
||||||
|
## [2.3.3] — 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Discord 웹훅 항상 실패하던 문제** (Cloudflare error 1010 / 403 Forbidden)
|
||||||
|
- 원인: Python 기본 User-Agent(`Python-urllib/3.x`)를 Discord/Cloudflare가
|
||||||
|
봇으로 인식해 차단
|
||||||
|
- 수정: `utils/discord_webhook.py`에 브라우저 UA 헤더 추가
|
||||||
|
(`Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3`)
|
||||||
|
- 이제 온보딩 위저드의 "테스트 메시지 보내기" + 출퇴근/휴식 push 모두 정상 동작
|
||||||
|
|
||||||
|
## [2.3.2] — 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **도움말 다이얼로그가 빈 창으로 표시되던 치명적 버그**
|
||||||
|
- v2.3.1에서 "온보딩 다시 보기" 버튼 추가 시 `self.setLayout(main_layout)` 라인이
|
||||||
|
실수로 `_reopen_onboarding` 메서드 안으로 들여쓰기되어 init_ui가 setLayout 없이 종료
|
||||||
|
- F1 누르면 빈 화면만 나옴 (탭 6개 모두 미렌더링)
|
||||||
|
- setLayout 호출 위치 복원 → 정상 표시
|
||||||
|
|
||||||
|
## [2.3.1] — 2026-04-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **도움말 다이얼로그에 "온보딩 다시 보기" 버튼 추가**
|
||||||
|
- v2.3.0에서 `show_onboarding()` 메서드는 만들었지만 UI 진입점 누락
|
||||||
|
- 이제 도움말 (F1) → 좌측 하단 "🚀 온보딩 다시 보기" 버튼으로 위저드 재실행 가능
|
||||||
|
- 본인 검증/설정 변경 시 유용
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **`auto_overtime` 옵션 실제 동작** — 기존엔 UI만 있고 동작 안 했음
|
||||||
|
- **OFF**: 퇴근 시 적립 가능한 분이 있으면 "적립할까요?" 다이얼로그 표시 (Y/N)
|
||||||
|
- **ON** (기본): 자동 적립 (이전과 동일)
|
||||||
|
- **`overtime_unit` 실제 동작** — 30/15/60분 단위 적립 선택 반영
|
||||||
|
- 기존엔 무조건 30분 절삭. 이제 설정값 사용 (`time_calculator.calculate_overtime` `unit_minutes` 파라미터)
|
||||||
|
- **퇴근 알림 시점 사용자 설정** (`notification_before_minutes`)
|
||||||
|
- 기존 30분 하드코드 → 1~120분 SpinBox로 사용자 지정
|
||||||
|
- 설정 → 알림 그룹에 "퇴근 알림 시점" SpinBox
|
||||||
|
|
||||||
|
### Removed (죽은 옵션 정리)
|
||||||
|
- `auto_detect_boot` — 부팅 감지 비활성화 모드. 어디서도 안 쓰임
|
||||||
|
- `notification_enabled` — 마스터 스위치. 4개 개별 NOTIF_* 키로 충분
|
||||||
|
- `annual_leave_used` — `leave_balance`로 대체된 레거시 카운터
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- 죽은 옵션이 사용자에게 "동작하는 것처럼" 보이던 신뢰성 문제 일괄 해소
|
||||||
|
|
||||||
## [2.2.3] — 2026-04-30
|
## [2.2.3] — 2026-04-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+180
-10
@@ -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)
|
||||||
@@ -520,21 +628,33 @@ class Database:
|
|||||||
'work_minutes': '480',
|
'work_minutes': '480',
|
||||||
'lunch_duration_minutes': '60',
|
'lunch_duration_minutes': '60',
|
||||||
'dinner_duration_minutes': '60',
|
'dinner_duration_minutes': '60',
|
||||||
'auto_detect_boot': 'true',
|
|
||||||
'auto_lunch': 'false',
|
'auto_lunch': 'false',
|
||||||
|
'auto_overtime': 'true',
|
||||||
'theme': 'light',
|
'theme': 'light',
|
||||||
'notification_enabled': 'true',
|
|
||||||
'notification_before_minutes': '30',
|
'notification_before_minutes': '30',
|
||||||
'notification_clock_out': 'true',
|
'notification_clock_out': 'true',
|
||||||
'notification_lunch': 'true',
|
'notification_lunch': 'true',
|
||||||
'notification_overtime': 'true',
|
'notification_overtime': 'true',
|
||||||
'notification_health': 'true',
|
'notification_health': 'true',
|
||||||
'annual_leave_total': '15',
|
'annual_leave_total': '15',
|
||||||
'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화)
|
'annual_leave_days': '15', # annual_leave_total과 자동 동기화
|
||||||
'annual_leave_used': '0',
|
|
||||||
'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',
|
||||||
|
# v2.4.0
|
||||||
|
'goal_overtime_max_monthly': '0', # 0=비활성, >0=분 단위 상한
|
||||||
|
'goal_avg_hours_daily': '0', # 0=비활성
|
||||||
}
|
}
|
||||||
|
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
@@ -1148,21 +1268,71 @@ class Database:
|
|||||||
|
|
||||||
# ===== 외출 관련 메서드 =====
|
# ===== 외출 관련 메서드 =====
|
||||||
|
|
||||||
def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None) -> int:
|
def add_break_record(self, work_record_id: int, date: str, break_out: str,
|
||||||
"""외출 기록 추가"""
|
reason: str = None, break_type: str = 'break') -> int:
|
||||||
|
"""외출 기록 추가. break_type: 'break'(외출) / 'lunch' / 'dinner'."""
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO break_records (work_record_id, date, break_out, reason)
|
INSERT INTO break_records (work_record_id, date, break_out, reason, break_type)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
''', (work_record_id, date, break_out, reason))
|
''', (work_record_id, date, break_out, reason, break_type))
|
||||||
|
|
||||||
record_id = cursor.lastrowid
|
record_id = cursor.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return record_id
|
return record_id
|
||||||
|
|
||||||
|
def add_meal_record(self, date: str, start_time: str, end_time: str,
|
||||||
|
meal_type: str = 'lunch') -> int:
|
||||||
|
"""식사 시간 기록 (시작·종료 둘 다 알 때).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: 'YYYY-MM-DD'
|
||||||
|
start_time, end_time: 'HH:MM:SS'
|
||||||
|
meal_type: 'lunch' or 'dinner'
|
||||||
|
Returns:
|
||||||
|
새 break_record id
|
||||||
|
"""
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
# 분 계산
|
||||||
|
start_dt = _dt.strptime(start_time, '%H:%M:%S')
|
||||||
|
end_dt = _dt.strptime(end_time, '%H:%M:%S')
|
||||||
|
if end_dt < start_dt:
|
||||||
|
from datetime import timedelta as _td
|
||||||
|
end_dt += _td(days=1)
|
||||||
|
total_min = int((end_dt - start_dt).total_seconds() / 60)
|
||||||
|
|
||||||
|
rec = self.get_today_record() if date == _dt.now().date().isoformat() else None
|
||||||
|
wid = rec['id'] if rec else None
|
||||||
|
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO break_records (work_record_id, date, break_out, break_in,
|
||||||
|
total_minutes, break_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
''', (wid, date, start_time, end_time, total_min, meal_type))
|
||||||
|
rid = cursor.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return rid
|
||||||
|
|
||||||
|
def get_meal_minutes_today(self, meal_type: str = 'lunch') -> int:
|
||||||
|
"""오늘의 식사 시간 합계 (분). 수동 입력된 경우만."""
|
||||||
|
from datetime import date as _date
|
||||||
|
today = _date.today().isoformat()
|
||||||
|
conn = self.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COALESCE(SUM(total_minutes), 0) FROM break_records
|
||||||
|
WHERE date = ? AND break_type = ? AND total_minutes IS NOT NULL
|
||||||
|
''', (today, meal_type))
|
||||||
|
result = cursor.fetchone()[0] or 0
|
||||||
|
conn.close()
|
||||||
|
return int(result)
|
||||||
|
|
||||||
def update_break_return(self, break_id: int, break_in: str):
|
def update_break_return(self, break_id: int, break_in: str):
|
||||||
"""외출 복귀 시간 업데이트"""
|
"""외출 복귀 시간 업데이트"""
|
||||||
conn = self.get_connection()
|
conn = self.get_connection()
|
||||||
|
|||||||
+38
-2
@@ -65,8 +65,17 @@ class Notifier(QObject):
|
|||||||
return
|
return
|
||||||
time_diff = clock_out_time - current_time
|
time_diff = clock_out_time - current_time
|
||||||
|
|
||||||
# 30분 이내, 아직 알림 안 했으면
|
# 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
|
||||||
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
|
threshold_min = 30
|
||||||
|
if self.db is not None:
|
||||||
|
try:
|
||||||
|
threshold_min = int(self.db.get_setting('notification_before_minutes', '30') or '30')
|
||||||
|
threshold_min = max(1, min(120, threshold_min))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
threshold_min = 30
|
||||||
|
threshold_sec = threshold_min * 60
|
||||||
|
|
||||||
|
if 0 < time_diff.total_seconds() <= threshold_sec and not self.notified_30min:
|
||||||
minutes_left = int(time_diff.total_seconds() / 60)
|
minutes_left = int(time_diff.total_seconds() / 60)
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.clock_out_soon.title'),
|
tr('notif.clock_out_soon.title'),
|
||||||
@@ -155,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
|
||||||
|
|||||||
@@ -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)):,}원"
|
||||||
+27
-3
@@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
|
|||||||
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
|
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
|
||||||
|
|
||||||
# 자동화
|
# 자동화
|
||||||
AUTO_DETECT_BOOT = 'auto_detect_boot'
|
|
||||||
AUTO_LUNCH = 'auto_lunch'
|
AUTO_LUNCH = 'auto_lunch'
|
||||||
AUTO_OVERTIME = 'auto_overtime'
|
AUTO_OVERTIME = 'auto_overtime'
|
||||||
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
|
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
|
||||||
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
|
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
|
||||||
|
|
||||||
# 알림
|
# 알림
|
||||||
NOTIFICATION_ENABLED = 'notification_enabled'
|
|
||||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||||
NOTIF_LUNCH = 'notification_lunch'
|
NOTIF_LUNCH = 'notification_lunch'
|
||||||
@@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health'
|
|||||||
# 연차
|
# 연차
|
||||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||||
ANNUAL_LEAVE_USED = 'annual_leave_used'
|
|
||||||
LEAVE_BALANCE = 'leave_balance'
|
LEAVE_BALANCE = 'leave_balance'
|
||||||
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
|
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
|
||||||
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
|
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
|
||||||
@@ -47,6 +44,33 @@ DB_PATH_OVERRIDE = 'db_path_override'
|
|||||||
# 백업
|
# 백업
|
||||||
LAST_BACKUP_DATE = 'last_backup_date'
|
LAST_BACKUP_DATE = 'last_backup_date'
|
||||||
|
|
||||||
|
# Crash Report (Gitea Issues 통합 — 옵션)
|
||||||
|
GITEA_FEEDBACK_TOKEN = 'gitea_feedback_token' # PAT (저장소 issue 쓰기 권한)
|
||||||
|
GITEA_FEEDBACK_ENABLED = 'gitea_feedback_enabled'
|
||||||
|
|
||||||
|
# === 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
|
||||||
|
|
||||||
|
# 목표
|
||||||
|
GOAL_OVERTIME_MAX_MONTHLY = 'goal_overtime_max_monthly' # 월 연장근무 상한 (분)
|
||||||
|
GOAL_AVG_HOURS_DAILY = 'goal_avg_hours_daily' # 일평균 목표 (시간, float)
|
||||||
|
|
||||||
|
# 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'
|
||||||
|
|||||||
@@ -135,17 +135,19 @@ class TimeCalculator:
|
|||||||
|
|
||||||
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
|
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
|
||||||
include_lunch: bool = False, include_dinner: bool = False,
|
include_lunch: bool = False, include_dinner: bool = False,
|
||||||
break_minutes: int = 0) -> Tuple[int, int]:
|
break_minutes: int = 0,
|
||||||
|
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
연장근무 시간 계산 (실제 시간, 30분 단위 적립)
|
연장근무 시간 계산 (실제 시간, unit_minutes 단위 적립)
|
||||||
Args:
|
Args:
|
||||||
clock_in: 출근 시간
|
clock_in: 출근 시간
|
||||||
clock_out: 퇴근 시간
|
clock_out: 퇴근 시간
|
||||||
include_lunch: 점심시간 포함 여부
|
include_lunch: 점심시간 포함 여부
|
||||||
include_dinner: 저녁시간 포함 여부
|
include_dinner: 저녁시간 포함 여부
|
||||||
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
|
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
|
||||||
|
unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT.
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분)
|
Tuple[int, int]: (실제 연장근무 분, unit_minutes 단위 적립 분)
|
||||||
"""
|
"""
|
||||||
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes)
|
||||||
|
|
||||||
@@ -155,8 +157,8 @@ class TimeCalculator:
|
|||||||
overtime_duration = clock_out - expected_clock_out
|
overtime_duration = clock_out - expected_clock_out
|
||||||
overtime_minutes = int(overtime_duration.total_seconds() / 60)
|
overtime_minutes = int(overtime_duration.total_seconds() / 60)
|
||||||
|
|
||||||
# 30분 단위로 절삭
|
unit = unit_minutes if unit_minutes > 0 else 30
|
||||||
overtime_earned = (overtime_minutes // 30) * 30
|
overtime_earned = (overtime_minutes // unit) * unit
|
||||||
|
|
||||||
return overtime_minutes, overtime_earned
|
return overtime_minutes, overtime_earned
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.2.3'
|
__version__ = '2.4.0'
|
||||||
|
|||||||
@@ -128,6 +128,23 @@ def main():
|
|||||||
from utils.debug_log import dlog
|
from utils.debug_log import dlog
|
||||||
dlog(f"backup failed: {e}")
|
dlog(f"backup failed: {e}")
|
||||||
|
|
||||||
|
# 전역 예외 후킹 (crash report)
|
||||||
|
try:
|
||||||
|
from utils.crash_handler import install_global_handler
|
||||||
|
from core.version import __version__
|
||||||
|
install_global_handler(db, app_version=__version__)
|
||||||
|
except Exception as e:
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"crash handler install 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()
|
||||||
|
|||||||
+29
-1
@@ -8,7 +8,10 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from utils.updater_client import _parse_version, is_newer, RELEASES_API
|
from utils.updater_client import (
|
||||||
|
_parse_version, is_newer, RELEASES_API,
|
||||||
|
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestVersionParsing:
|
class TestVersionParsing:
|
||||||
@@ -59,6 +62,31 @@ class TestApiUrl:
|
|||||||
importlib.reload(updater_client)
|
importlib.reload(updater_client)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckForUpdate:
|
||||||
|
"""check_for_update returns (info, reason) tuple."""
|
||||||
|
|
||||||
|
def test_returns_tuple(self, monkeypatch):
|
||||||
|
# 네트워크 호출이 실패하도록 잘못된 URL
|
||||||
|
monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'http://127.0.0.1:1/nope')
|
||||||
|
import importlib
|
||||||
|
from utils import updater_client
|
||||||
|
importlib.reload(updater_client)
|
||||||
|
try:
|
||||||
|
result = updater_client.check_for_update('1.0.0', timeout=1)
|
||||||
|
assert isinstance(result, tuple) and len(result) == 2
|
||||||
|
info, reason = result
|
||||||
|
assert info is None
|
||||||
|
assert reason == updater_client.NETWORK_ERROR
|
||||||
|
finally:
|
||||||
|
monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False)
|
||||||
|
importlib.reload(updater_client)
|
||||||
|
|
||||||
|
def test_constants_distinct(self):
|
||||||
|
# 4개 상수가 모두 서로 다른 값
|
||||||
|
values = {UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET}
|
||||||
|
assert len(values) == 4
|
||||||
|
|
||||||
|
|
||||||
class TestUpdaterScript:
|
class TestUpdaterScript:
|
||||||
"""updater.py 자체 로직."""
|
"""updater.py 자체 로직."""
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class CalendarView(QDialog):
|
|||||||
self.calendar.setMinimumHeight(280)
|
self.calendar.setMinimumHeight(280)
|
||||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||||
self.calendar.clicked.connect(self.date_selected)
|
self.calendar.clicked.connect(self.date_selected)
|
||||||
|
# 우클릭 컨텍스트 메뉴 (과거 일자 수동 추가)
|
||||||
|
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.calendar.customContextMenuRequested.connect(self._show_date_context)
|
||||||
layout.addWidget(self.calendar, 1)
|
layout.addWidget(self.calendar, 1)
|
||||||
|
|
||||||
# 범례
|
# 범례
|
||||||
@@ -152,6 +155,107 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
self.calendar.setDateTextFormat(qdate, fmt)
|
self.calendar.setDateTextFormat(qdate, fmt)
|
||||||
|
|
||||||
|
def _show_date_context(self, pos):
|
||||||
|
"""캘린더 우클릭 메뉴 — 과거 일자 추가/편집/삭제."""
|
||||||
|
from PyQt5.QtWidgets import QMenu
|
||||||
|
qdate = self.calendar.selectedDate()
|
||||||
|
date_str = qdate.toString('yyyy-MM-dd')
|
||||||
|
existing = self.db.get_work_record(date_str)
|
||||||
|
|
||||||
|
menu = QMenu(self)
|
||||||
|
if existing:
|
||||||
|
edit_action = menu.addAction(f"✏️ {date_str} 편집")
|
||||||
|
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
|
||||||
|
else:
|
||||||
|
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
|
||||||
|
|
||||||
|
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||||
|
if action is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if existing and action.text().startswith("✏️"):
|
||||||
|
self._open_edit_dialog(date_str)
|
||||||
|
elif existing and action.text().startswith("🗑️"):
|
||||||
|
self._delete_record(date_str)
|
||||||
|
elif not existing and action.text().startswith("➕"):
|
||||||
|
self._add_past_record(date_str)
|
||||||
|
|
||||||
|
def _add_past_record(self, date_str: str):
|
||||||
|
"""과거 일자 수동 추가."""
|
||||||
|
from ui.past_record_dialog import PastRecordDialog
|
||||||
|
dialog = PastRecordDialog(self, date_str)
|
||||||
|
if dialog.exec_() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
data = dialog.get_data()
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
|
||||||
|
if data.get('clock_out'):
|
||||||
|
# 총 시간/연장근무 계산
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||||
|
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||||
|
from core.time_calculator import TimeCalculator
|
||||||
|
wm = self.db.get_work_minutes()
|
||||||
|
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
|
||||||
|
calc = TimeCalculator(work_minutes=wm, lunch_duration_minutes=lunch)
|
||||||
|
total = (co - ci).total_seconds() / 3600
|
||||||
|
ot_actual, ot_earned = calc.calculate_overtime(
|
||||||
|
ci, co,
|
||||||
|
include_lunch=data.get('lunch', False),
|
||||||
|
include_dinner=data.get('dinner', False),
|
||||||
|
)
|
||||||
|
self.db.update_clock_out(date_str, data['clock_out'], total, ot_actual, ot_earned)
|
||||||
|
if data.get('lunch'):
|
||||||
|
self.db.update_lunch_break(date_str, True)
|
||||||
|
if data.get('dinner'):
|
||||||
|
self.db.update_dinner_break(date_str, True)
|
||||||
|
if ot_earned > 0:
|
||||||
|
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||||
|
self._refresh_calendar()
|
||||||
|
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
|
||||||
|
|
||||||
|
def _open_edit_dialog(self, date_str: str):
|
||||||
|
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||||
|
from PyQt5.QtCore import QDate
|
||||||
|
y, m, d = date_str.split('-')
|
||||||
|
self.calendar.setSelectedDate(QDate(int(y), int(m), int(d)))
|
||||||
|
self.date_selected(self.calendar.selectedDate())
|
||||||
|
# 사용자가 화면 하단에 표시된 "✏️ 시간 수정" 버튼 클릭하면 편집
|
||||||
|
|
||||||
|
def _delete_record(self, date_str: str):
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "삭제 확인",
|
||||||
|
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
conn = self.db.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (date_str,))
|
||||||
|
cursor.execute("DELETE FROM break_records WHERE date = ?", (date_str,))
|
||||||
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||||
|
conn.commit()
|
||||||
|
self._refresh_calendar()
|
||||||
|
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
QMessageBox.critical(self, "오류", str(e))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _refresh_calendar(self):
|
||||||
|
"""캘린더 마킹 갱신."""
|
||||||
|
if hasattr(self, 'load_calendar_data'):
|
||||||
|
self.load_calendar_data()
|
||||||
|
elif hasattr(self, 'load_records'):
|
||||||
|
self.load_records()
|
||||||
|
|
||||||
def date_selected(self, qdate):
|
def date_selected(self, qdate):
|
||||||
"""날짜 선택 시"""
|
"""날짜 선택 시"""
|
||||||
selected_date = qdate.toPyDate()
|
selected_date = qdate.toPyDate()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
목표 진행률 위젯.
|
||||||
|
|
||||||
|
월 연장근무 상한 + 일평균 목표를 stats_view 또는 메인에 표시.
|
||||||
|
설정값이 0이면 비활성 (위젯 자체 hide).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, date
|
||||||
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
class GoalWidget(QWidget):
|
||||||
|
"""월간 목표 진행률 표시."""
|
||||||
|
|
||||||
|
def __init__(self, db, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.db = db
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(8, 6, 8, 6)
|
||||||
|
layout.setSpacing(4)
|
||||||
|
|
||||||
|
title = QLabel("🎯 이번 달 목표")
|
||||||
|
title.setStyleSheet("font-weight: bold;")
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
# 연장근무 상한
|
||||||
|
ot_row = QHBoxLayout()
|
||||||
|
self.ot_label = QLabel("연장근무:")
|
||||||
|
self.ot_label.setFixedWidth(100)
|
||||||
|
self.ot_bar = QProgressBar()
|
||||||
|
self.ot_bar.setTextVisible(True)
|
||||||
|
self.ot_bar.setFixedHeight(18)
|
||||||
|
ot_row.addWidget(self.ot_label)
|
||||||
|
ot_row.addWidget(self.ot_bar, 1)
|
||||||
|
layout.addLayout(ot_row)
|
||||||
|
|
||||||
|
# 일평균
|
||||||
|
avg_row = QHBoxLayout()
|
||||||
|
self.avg_label = QLabel("일평균:")
|
||||||
|
self.avg_label.setFixedWidth(100)
|
||||||
|
self.avg_bar = QProgressBar()
|
||||||
|
self.avg_bar.setTextVisible(True)
|
||||||
|
self.avg_bar.setFixedHeight(18)
|
||||||
|
avg_row.addWidget(self.avg_label)
|
||||||
|
avg_row.addWidget(self.avg_bar, 1)
|
||||||
|
layout.addLayout(avg_row)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""현재 설정값과 이번 달 통계로 진행률 갱신. 0=비활성 시 row 숨김."""
|
||||||
|
try:
|
||||||
|
ot_target = int(self.db.get_setting('goal_overtime_max_monthly', '0') or 0)
|
||||||
|
avg_target = float(self.db.get_setting('goal_avg_hours_daily', '0') or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
ot_target, avg_target = 0, 0.0
|
||||||
|
|
||||||
|
# 둘 다 비활성이면 위젯 자체 숨김
|
||||||
|
if ot_target <= 0 and avg_target <= 0:
|
||||||
|
self.setVisible(False)
|
||||||
|
return
|
||||||
|
self.setVisible(True)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
stats = self.db.get_monthly_stats(now.year, now.month)
|
||||||
|
ot_total = (stats.get('total_overtime_minutes') or 0)
|
||||||
|
total_h = stats.get('total_hours') or 0
|
||||||
|
work_days = stats.get('work_days') or 1
|
||||||
|
|
||||||
|
# 연장근무 상한 (낮을수록 좋음)
|
||||||
|
if ot_target > 0:
|
||||||
|
self.ot_label.setVisible(True)
|
||||||
|
self.ot_bar.setVisible(True)
|
||||||
|
self.ot_bar.setMaximum(ot_target)
|
||||||
|
self.ot_bar.setValue(min(ot_total, ot_target))
|
||||||
|
ratio = ot_total / ot_target if ot_target else 0
|
||||||
|
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||||
|
tg_h, tg_m = ot_target // 60, ot_target % 60
|
||||||
|
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
|
||||||
|
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
|
||||||
|
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||||
|
else:
|
||||||
|
self.ot_label.setVisible(False)
|
||||||
|
self.ot_bar.setVisible(False)
|
||||||
|
|
||||||
|
# 일평균 (목표 시간보다 적으면 좋음)
|
||||||
|
if avg_target > 0:
|
||||||
|
self.avg_label.setVisible(True)
|
||||||
|
self.avg_bar.setVisible(True)
|
||||||
|
avg = total_h / work_days if work_days else 0
|
||||||
|
self.avg_bar.setMaximum(int(avg_target * 100))
|
||||||
|
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
|
||||||
|
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
||||||
|
ratio = avg / avg_target if avg_target else 0
|
||||||
|
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
|
||||||
|
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||||
|
else:
|
||||||
|
self.avg_label.setVisible(False)
|
||||||
|
self.avg_bar.setVisible(False)
|
||||||
+14
-1
@@ -51,18 +51,31 @@ class HelpView(QDialog):
|
|||||||
|
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setContentsMargins(20, 10, 20, 20)
|
button_layout.setContentsMargins(20, 10, 20, 20)
|
||||||
|
|
||||||
|
# 온보딩 다시 보기 (왼쪽)
|
||||||
|
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||||
|
onboarding_button.setMinimumHeight(40)
|
||||||
|
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||||
|
button_layout.addWidget(onboarding_button)
|
||||||
|
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
|
|
||||||
close_button = QPushButton(tr('btn.close'))
|
close_button = QPushButton(tr('btn.close'))
|
||||||
close_button.setObjectName("btn_primary")
|
close_button.setObjectName("btn_primary")
|
||||||
close_button.setMinimumHeight(40)
|
close_button.setMinimumHeight(40)
|
||||||
close_button.setMinimumWidth(120)
|
close_button.setMinimumWidth(120)
|
||||||
close_button.clicked.connect(self.close)
|
close_button.clicked.connect(self.close)
|
||||||
button_layout.addWidget(close_button)
|
button_layout.addWidget(close_button)
|
||||||
button_layout.addStretch()
|
|
||||||
main_layout.addLayout(button_layout)
|
main_layout.addLayout(button_layout)
|
||||||
|
|
||||||
self.setLayout(main_layout)
|
self.setLayout(main_layout)
|
||||||
|
|
||||||
|
def _reopen_onboarding(self):
|
||||||
|
"""부모 윈도우의 show_onboarding 호출 후 도움말 닫음."""
|
||||||
|
self.close()
|
||||||
|
if self.parent() and hasattr(self.parent(), 'show_onboarding'):
|
||||||
|
self.parent().show_onboarding()
|
||||||
|
|
||||||
def _make_tab(self, html: str) -> QWidget:
|
def _make_tab(self, html: str) -> QWidget:
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|||||||
+196
-17
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||||
QHBoxLayout, QLabel, QPushButton, QProgressBar,
|
QHBoxLayout, QLabel, QPushButton, QProgressBar,
|
||||||
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
|
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
|
||||||
QShortcut)
|
QShortcut, QDialog)
|
||||||
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
|
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
|
||||||
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
|
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -240,10 +245,20 @@ class MainWindow(QMainWindow):
|
|||||||
self.lunch_button = QPushButton(tr('btn.lunch_add'))
|
self.lunch_button = QPushButton(tr('btn.lunch_add'))
|
||||||
self.lunch_button.setCheckable(True)
|
self.lunch_button.setCheckable(True)
|
||||||
self.lunch_button.clicked.connect(self.toggle_lunch_break)
|
self.lunch_button.clicked.connect(self.toggle_lunch_break)
|
||||||
|
self.lunch_button.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.lunch_button.customContextMenuRequested.connect(
|
||||||
|
lambda pos: self._show_meal_context('lunch', self.lunch_button, pos)
|
||||||
|
)
|
||||||
|
self.lunch_button.setToolTip("좌클릭: 토글 / 우클릭: 실제 시간 입력")
|
||||||
|
|
||||||
self.dinner_button = QPushButton(tr('btn.dinner_add'))
|
self.dinner_button = QPushButton(tr('btn.dinner_add'))
|
||||||
self.dinner_button.setCheckable(True)
|
self.dinner_button.setCheckable(True)
|
||||||
self.dinner_button.clicked.connect(self.toggle_dinner_break)
|
self.dinner_button.clicked.connect(self.toggle_dinner_break)
|
||||||
|
self.dinner_button.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.dinner_button.customContextMenuRequested.connect(
|
||||||
|
lambda pos: self._show_meal_context('dinner', self.dinner_button, pos)
|
||||||
|
)
|
||||||
|
self.dinner_button.setToolTip("좌클릭: 토글 / 우클릭: 실제 시간 입력")
|
||||||
|
|
||||||
meal_button_layout.addWidget(self.lunch_button)
|
meal_button_layout.addWidget(self.lunch_button)
|
||||||
meal_button_layout.addWidget(self.dinner_button)
|
meal_button_layout.addWidget(self.dinner_button)
|
||||||
@@ -356,7 +371,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)
|
||||||
@@ -1081,18 +1100,21 @@ class MainWindow(QMainWindow):
|
|||||||
# 오늘의 외출 시간 가져오기
|
# 오늘의 외출 시간 가져오기
|
||||||
break_minutes = self.db.get_total_break_minutes_today()
|
break_minutes = self.db.get_total_break_minutes_today()
|
||||||
|
|
||||||
|
# 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60.
|
||||||
|
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||||
|
if unit_minutes not in (15, 30, 60):
|
||||||
|
unit_minutes = 30
|
||||||
|
|
||||||
if is_non_working_day:
|
if is_non_working_day:
|
||||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
||||||
work_minutes = int(total_hours * 60)
|
work_minutes = int(total_hours * 60)
|
||||||
if self.lunch_break_enabled:
|
if self.lunch_break_enabled:
|
||||||
work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외
|
work_minutes -= self.time_calc.lunch_duration_minutes
|
||||||
if self.dinner_break_enabled:
|
if self.dinner_break_enabled:
|
||||||
work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외
|
work_minutes -= self.time_calc.dinner_duration_minutes
|
||||||
work_minutes -= break_minutes # 외출시간 제외
|
work_minutes -= break_minutes
|
||||||
# 음수 방지
|
|
||||||
work_minutes = max(0, work_minutes)
|
work_minutes = max(0, work_minutes)
|
||||||
# 30분 단위로 절삭
|
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
|
||||||
overtime_earned = (work_minutes // 30) * 30
|
|
||||||
overtime_actual = work_minutes
|
overtime_actual = work_minutes
|
||||||
else:
|
else:
|
||||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||||
@@ -1100,9 +1122,27 @@ class MainWindow(QMainWindow):
|
|||||||
self.clock_in_time, now,
|
self.clock_in_time, now,
|
||||||
include_lunch=self.lunch_break_enabled,
|
include_lunch=self.lunch_break_enabled,
|
||||||
include_dinner=self.dinner_break_enabled,
|
include_dinner=self.dinner_break_enabled,
|
||||||
break_minutes=break_minutes
|
break_minutes=break_minutes,
|
||||||
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
|
||||||
|
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
|
||||||
|
if not auto_overtime and overtime_earned > 0:
|
||||||
|
from utils.time_format import format_hours_minutes
|
||||||
|
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
|
||||||
|
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
|
||||||
|
ask = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"연장근무 적립 확인",
|
||||||
|
f"연장근무 {actual_str} 발생, {time_str} 적립 대상입니다.\n\n"
|
||||||
|
f"적립하시겠습니까?\n"
|
||||||
|
f"(아니오 선택 시 이번 퇴근분은 적립되지 않습니다)",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if ask != QMessageBox.Yes:
|
||||||
|
overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지)
|
||||||
|
|
||||||
# DB 업데이트
|
# DB 업데이트
|
||||||
today = datetime.now().date().isoformat()
|
today = datetime.now().date().isoformat()
|
||||||
clock_out_str = now.strftime("%H:%M:%S")
|
clock_out_str = now.strftime("%H:%M:%S")
|
||||||
@@ -1148,6 +1188,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):
|
||||||
"""퇴근 취소"""
|
"""퇴근 취소"""
|
||||||
# 확인 대화상자
|
# 확인 대화상자
|
||||||
@@ -1558,6 +1604,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)
|
||||||
@@ -1598,19 +1651,145 @@ class MainWindow(QMainWindow):
|
|||||||
dialog = HelpView(self)
|
dialog = HelpView(self)
|
||||||
dialog.exec_()
|
dialog.exec_()
|
||||||
|
|
||||||
|
def _show_meal_context(self, meal_type: str, button, pos):
|
||||||
|
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
|
||||||
|
from PyQt5.QtWidgets import QMenu
|
||||||
|
from ui.meal_time_dialog import MealTimeDialog
|
||||||
|
menu = QMenu(self)
|
||||||
|
title = "점심" if meal_type == 'lunch' else "저녁"
|
||||||
|
edit_action = menu.addAction(f"⏱ {title} 실제 시간 입력...")
|
||||||
|
global_pos = button.mapToGlobal(pos)
|
||||||
|
action = menu.exec_(global_pos)
|
||||||
|
if action != edit_action:
|
||||||
|
return
|
||||||
|
if not self.is_clocked_in:
|
||||||
|
QMessageBox.warning(self, "출근 필요", "출근 후에만 식사 시간을 기록할 수 있습니다.")
|
||||||
|
return
|
||||||
|
default_min = (self.time_calc.lunch_duration_minutes
|
||||||
|
if meal_type == 'lunch'
|
||||||
|
else self.time_calc.dinner_duration_minutes)
|
||||||
|
dialog = MealTimeDialog(self, meal_type=meal_type, default_minutes=default_min)
|
||||||
|
if dialog.exec_() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
start, end, minutes = dialog.get_times()
|
||||||
|
if minutes <= 0:
|
||||||
|
QMessageBox.warning(self, "입력 오류", "종료 시간이 시작보다 빠릅니다.")
|
||||||
|
return
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
self.db.add_meal_record(today, start, end, meal_type=meal_type)
|
||||||
|
# 자동 토글 ON
|
||||||
|
if meal_type == 'lunch':
|
||||||
|
self.lunch_break_enabled = True
|
||||||
|
self.lunch_button.setChecked(True)
|
||||||
|
self.update_lunch_status()
|
||||||
|
self.db.update_lunch_break(today, True)
|
||||||
|
self.auto_lunch_applied_today = True
|
||||||
|
else:
|
||||||
|
self.dinner_break_enabled = True
|
||||||
|
self.dinner_button.setChecked(True)
|
||||||
|
self.update_dinner_status()
|
||||||
|
self.db.update_dinner_break(today, True)
|
||||||
|
QMessageBox.information(self, "기록 완료",
|
||||||
|
f"{title} {minutes}분 기록되었습니다.\n({start} ~ {end})")
|
||||||
|
|
||||||
|
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__
|
||||||
from utils.updater_client import check_for_update, download_update, apply_update
|
from utils.updater_client import (
|
||||||
|
check_for_update, download_update, apply_update,
|
||||||
|
UP_TO_DATE, NETWORK_ERROR, NO_RELEASE, NO_ASSET,
|
||||||
|
)
|
||||||
|
|
||||||
info = check_for_update(__version__)
|
info, reason = check_for_update(__version__)
|
||||||
if info is None:
|
if info is None:
|
||||||
if not silent:
|
if silent:
|
||||||
QMessageBox.information(
|
return
|
||||||
self,
|
# 사용자가 명시적으로 트리거한 경우만 메시지 표시
|
||||||
"업데이트 확인",
|
messages = {
|
||||||
f"현재 최신 버전입니다 (v{__version__})."
|
UP_TO_DATE: ("업데이트 확인", f"현재 최신 버전입니다 (v{__version__})."),
|
||||||
)
|
NETWORK_ERROR: ("연결 실패",
|
||||||
|
"업데이트 서버에 연결할 수 없습니다.\n"
|
||||||
|
"네트워크 상태를 확인해 주세요."),
|
||||||
|
NO_RELEASE: ("릴리스 없음",
|
||||||
|
"업데이트 저장소에서 릴리스를 찾을 수 없습니다.\n"
|
||||||
|
"(저장소 비공개 또는 첫 릴리스 전)"),
|
||||||
|
NO_ASSET: ("자산 누락",
|
||||||
|
"새 버전은 있지만 다운로드 가능한 main.exe 자산이 없습니다.\n"
|
||||||
|
"관리자에게 문의하세요."),
|
||||||
|
}
|
||||||
|
title, body = messages.get(reason, ("업데이트 확인", "알 수 없는 응답입니다."))
|
||||||
|
QMessageBox.information(self, title, body)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
|
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
점심/저녁 실제 시간 입력 다이얼로그.
|
||||||
|
|
||||||
|
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
||||||
|
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QTimeEdit, QMessageBox)
|
||||||
|
from PyQt5.QtCore import QTime, Qt
|
||||||
|
|
||||||
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
|
class MealTimeDialog(QDialog):
|
||||||
|
"""점심/저녁 실제 시작·종료 시간 입력."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.meal_type = meal_type
|
||||||
|
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||||
|
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setFixedSize(360, 220)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setSpacing(10)
|
||||||
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
|
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||||
|
info.setWordWrap(True)
|
||||||
|
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# 시작
|
||||||
|
start_row = QHBoxLayout()
|
||||||
|
start_row.addWidget(QLabel("시작:"))
|
||||||
|
self.start_edit = QTimeEdit()
|
||||||
|
self.start_edit.setDisplayFormat("HH:mm")
|
||||||
|
# 기본값: 점심=12:00, 저녁=18:00
|
||||||
|
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
|
||||||
|
self.start_edit.setTime(default_start)
|
||||||
|
start_row.addWidget(self.start_edit)
|
||||||
|
start_row.addStretch()
|
||||||
|
layout.addLayout(start_row)
|
||||||
|
|
||||||
|
# 종료
|
||||||
|
end_row = QHBoxLayout()
|
||||||
|
end_row.addWidget(QLabel("종료:"))
|
||||||
|
self.end_edit = QTimeEdit()
|
||||||
|
self.end_edit.setDisplayFormat("HH:mm")
|
||||||
|
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
|
||||||
|
self.end_edit.setTime(default_end)
|
||||||
|
end_row.addWidget(self.end_edit)
|
||||||
|
end_row.addStretch()
|
||||||
|
layout.addLayout(end_row)
|
||||||
|
|
||||||
|
# 미리보기 라벨
|
||||||
|
self.preview = QLabel("")
|
||||||
|
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
|
||||||
|
layout.addWidget(self.preview)
|
||||||
|
self._update_preview()
|
||||||
|
self.start_edit.timeChanged.connect(self._update_preview)
|
||||||
|
self.end_edit.timeChanged.connect(self._update_preview)
|
||||||
|
|
||||||
|
# 버튼
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch()
|
||||||
|
ok_btn = QPushButton("저장")
|
||||||
|
ok_btn.setObjectName("btn_primary")
|
||||||
|
ok_btn.clicked.connect(self.accept)
|
||||||
|
cancel_btn = QPushButton("취소")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_row.addWidget(ok_btn)
|
||||||
|
btn_row.addWidget(cancel_btn)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
apply_dark_titlebar(self)
|
||||||
|
|
||||||
|
def _update_preview(self):
|
||||||
|
s = self.start_edit.time()
|
||||||
|
e = self.end_edit.time()
|
||||||
|
start_dt = datetime.combine(datetime.today(), s.toPyTime())
|
||||||
|
end_dt = datetime.combine(datetime.today(), e.toPyTime())
|
||||||
|
if end_dt < start_dt:
|
||||||
|
end_dt += timedelta(days=1)
|
||||||
|
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
||||||
|
if minutes <= 0:
|
||||||
|
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
|
||||||
|
self.preview.setStyleSheet("color: #f44336;")
|
||||||
|
else:
|
||||||
|
self.preview.setText(f"총 {minutes}분")
|
||||||
|
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||||
|
|
||||||
|
def get_times(self) -> tuple[str, str, int]:
|
||||||
|
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
||||||
|
s = self.start_edit.time().toPyTime()
|
||||||
|
e = self.end_edit.time().toPyTime()
|
||||||
|
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
||||||
|
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
||||||
|
s_dt = datetime.combine(datetime.today(), s)
|
||||||
|
e_dt = datetime.combine(datetime.today(), e)
|
||||||
|
if e_dt < s_dt:
|
||||||
|
e_dt += timedelta(days=1)
|
||||||
|
minutes = int((e_dt - s_dt).total_seconds() / 60)
|
||||||
|
return start_str, end_str, minutes
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
과거 일자 수동 추가 다이얼로그.
|
||||||
|
|
||||||
|
캘린더 우클릭 → "기록 추가"에서 호출. 출/퇴근 시각 + 점심/저녁 + 메모 입력.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QTimeEdit, QCheckBox, QLineEdit,
|
||||||
|
QMessageBox)
|
||||||
|
from PyQt5.QtCore import QTime, Qt
|
||||||
|
|
||||||
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
|
class PastRecordDialog(QDialog):
|
||||||
|
"""과거 일자 근무 기록 입력."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, date_str: str = ''):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.date_str = date_str
|
||||||
|
self.setWindowTitle(f"기록 추가 — {date_str}")
|
||||||
|
self.setModal(True)
|
||||||
|
self.setFixedSize(380, 320)
|
||||||
|
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setSpacing(8)
|
||||||
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
|
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
|
||||||
|
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||||
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# 출근
|
||||||
|
ci_row = QHBoxLayout()
|
||||||
|
ci_row.addWidget(QLabel("출근:"))
|
||||||
|
self.clock_in_edit = QTimeEdit()
|
||||||
|
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||||
|
self.clock_in_edit.setTime(QTime(9, 0))
|
||||||
|
ci_row.addWidget(self.clock_in_edit)
|
||||||
|
ci_row.addStretch()
|
||||||
|
layout.addLayout(ci_row)
|
||||||
|
|
||||||
|
# 퇴근
|
||||||
|
co_row = QHBoxLayout()
|
||||||
|
co_row.addWidget(QLabel("퇴근:"))
|
||||||
|
self.clock_out_check = QCheckBox("입력")
|
||||||
|
self.clock_out_check.setChecked(True)
|
||||||
|
self.clock_out_edit = QTimeEdit()
|
||||||
|
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||||
|
self.clock_out_edit.setTime(QTime(18, 0))
|
||||||
|
self.clock_out_check.toggled.connect(self.clock_out_edit.setEnabled)
|
||||||
|
co_row.addWidget(self.clock_out_check)
|
||||||
|
co_row.addWidget(self.clock_out_edit)
|
||||||
|
co_row.addStretch()
|
||||||
|
layout.addLayout(co_row)
|
||||||
|
|
||||||
|
# 점심/저녁
|
||||||
|
meal_row = QHBoxLayout()
|
||||||
|
self.lunch_check = QCheckBox("🍱 점심시간 포함")
|
||||||
|
self.lunch_check.setChecked(True)
|
||||||
|
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
|
||||||
|
meal_row.addWidget(self.lunch_check)
|
||||||
|
meal_row.addWidget(self.dinner_check)
|
||||||
|
meal_row.addStretch()
|
||||||
|
layout.addLayout(meal_row)
|
||||||
|
|
||||||
|
# 메모
|
||||||
|
layout.addWidget(QLabel("메모 (선택):"))
|
||||||
|
self.memo_edit = QLineEdit()
|
||||||
|
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
||||||
|
layout.addWidget(self.memo_edit)
|
||||||
|
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
# 버튼
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch()
|
||||||
|
ok_btn = QPushButton("저장")
|
||||||
|
ok_btn.setObjectName("btn_primary")
|
||||||
|
ok_btn.clicked.connect(self._validate_and_accept)
|
||||||
|
cancel_btn = QPushButton("취소")
|
||||||
|
cancel_btn.clicked.connect(self.reject)
|
||||||
|
btn_row.addWidget(ok_btn)
|
||||||
|
btn_row.addWidget(cancel_btn)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
apply_dark_titlebar(self)
|
||||||
|
|
||||||
|
def _validate_and_accept(self):
|
||||||
|
if self.clock_out_check.isChecked():
|
||||||
|
ci = self.clock_in_edit.time()
|
||||||
|
co = self.clock_out_edit.time()
|
||||||
|
if co <= ci:
|
||||||
|
QMessageBox.warning(self, "입력 오류",
|
||||||
|
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
||||||
|
return
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def get_data(self) -> dict:
|
||||||
|
ci = self.clock_in_edit.time().toPyTime()
|
||||||
|
data = {
|
||||||
|
'clock_in': f"{ci.hour:02d}:{ci.minute:02d}:00",
|
||||||
|
'lunch': self.lunch_check.isChecked(),
|
||||||
|
'dinner': self.dinner_check.isChecked(),
|
||||||
|
'memo': self.memo_edit.text().strip(),
|
||||||
|
}
|
||||||
|
if self.clock_out_check.isChecked():
|
||||||
|
co = self.clock_out_edit.time().toPyTime()
|
||||||
|
data['clock_out'] = f"{co.hour:02d}:{co.minute:02d}:00"
|
||||||
|
return data
|
||||||
@@ -18,9 +18,12 @@ from core.settings_keys import (
|
|||||||
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
||||||
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
||||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||||
|
NOTIFICATION_BEFORE_MINUTES,
|
||||||
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
||||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||||
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
||||||
|
GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY,
|
||||||
|
GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED,
|
||||||
)
|
)
|
||||||
from utils.csv_exporter import CSVExporter
|
from utils.csv_exporter import CSVExporter
|
||||||
from ui.leave_view import AddLeaveDialog
|
from ui.leave_view import AddLeaveDialog
|
||||||
@@ -85,6 +88,10 @@ class SettingsView(QDialog):
|
|||||||
leave_group = self.create_leave_group()
|
leave_group = self.create_leave_group()
|
||||||
layout.addWidget(leave_group)
|
layout.addWidget(leave_group)
|
||||||
|
|
||||||
|
# 목표 설정 그룹
|
||||||
|
goal_group = self.create_goal_group()
|
||||||
|
layout.addWidget(goal_group)
|
||||||
|
|
||||||
# 공휴일 설정
|
# 공휴일 설정
|
||||||
holiday_group = self.create_holiday_group()
|
holiday_group = self.create_holiday_group()
|
||||||
layout.addWidget(holiday_group)
|
layout.addWidget(holiday_group)
|
||||||
@@ -275,6 +282,22 @@ class SettingsView(QDialog):
|
|||||||
check_row2.addWidget(self.health_notification_check)
|
check_row2.addWidget(self.health_notification_check)
|
||||||
layout.addLayout(check_row2)
|
layout.addLayout(check_row2)
|
||||||
|
|
||||||
|
# 퇴근 N분 전 알림 시점 설정
|
||||||
|
before_row = QHBoxLayout()
|
||||||
|
before_label = QLabel("퇴근 알림 시점:")
|
||||||
|
before_label.setFixedWidth(110)
|
||||||
|
self.notif_before_spin = QSpinBox()
|
||||||
|
self.notif_before_spin.setRange(1, 120)
|
||||||
|
self.notif_before_spin.setSingleStep(5)
|
||||||
|
self.notif_before_spin.setValue(30)
|
||||||
|
self.notif_before_spin.setSuffix(" 분 전")
|
||||||
|
self.notif_before_spin.setFixedWidth(110)
|
||||||
|
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
||||||
|
before_row.addWidget(before_label)
|
||||||
|
before_row.addWidget(self.notif_before_spin)
|
||||||
|
before_row.addStretch()
|
||||||
|
layout.addLayout(before_row)
|
||||||
|
|
||||||
# 시간 형식 + 테마 한 줄에
|
# 시간 형식 + 테마 한 줄에
|
||||||
format_row = QHBoxLayout()
|
format_row = QHBoxLayout()
|
||||||
time_format_label = QLabel("시간 형식:")
|
time_format_label = QLabel("시간 형식:")
|
||||||
@@ -381,6 +404,51 @@ class SettingsView(QDialog):
|
|||||||
group.setLayout(layout)
|
group.setLayout(layout)
|
||||||
return group
|
return group
|
||||||
|
|
||||||
|
def create_goal_group(self) -> QGroupBox:
|
||||||
|
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||||
|
group = QGroupBox("🎯 월간 목표 (0=비활성)")
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setSpacing(6)
|
||||||
|
|
||||||
|
# 연장근무 상한
|
||||||
|
ot_row = QHBoxLayout()
|
||||||
|
ot_label = QLabel("월 연장근무 상한:")
|
||||||
|
ot_label.setFixedWidth(150)
|
||||||
|
self.goal_ot_h = QSpinBox()
|
||||||
|
self.goal_ot_h.setRange(0, 100)
|
||||||
|
self.goal_ot_h.setSuffix(" 시간")
|
||||||
|
self.goal_ot_h.setFixedWidth(100)
|
||||||
|
self.goal_ot_m = QSpinBox()
|
||||||
|
self.goal_ot_m.setRange(0, 59)
|
||||||
|
self.goal_ot_m.setSingleStep(30)
|
||||||
|
self.goal_ot_m.setSuffix(" 분")
|
||||||
|
self.goal_ot_m.setFixedWidth(90)
|
||||||
|
ot_row.addWidget(ot_label)
|
||||||
|
ot_row.addWidget(self.goal_ot_h)
|
||||||
|
ot_row.addWidget(self.goal_ot_m)
|
||||||
|
ot_row.addStretch()
|
||||||
|
layout.addLayout(ot_row)
|
||||||
|
|
||||||
|
# 일평균 목표
|
||||||
|
avg_row = QHBoxLayout()
|
||||||
|
avg_label = QLabel("일 평균 근무 목표:")
|
||||||
|
avg_label.setFixedWidth(150)
|
||||||
|
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
||||||
|
self.goal_avg.setRange(0, 24)
|
||||||
|
self.goal_avg.setSuffix(" 시간")
|
||||||
|
self.goal_avg.setFixedWidth(100)
|
||||||
|
avg_row.addWidget(avg_label)
|
||||||
|
avg_row.addWidget(self.goal_avg)
|
||||||
|
avg_row.addStretch()
|
||||||
|
layout.addLayout(avg_row)
|
||||||
|
|
||||||
|
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
|
||||||
|
note.setObjectName("note_text")
|
||||||
|
layout.addWidget(note)
|
||||||
|
|
||||||
|
group.setLayout(layout)
|
||||||
|
return group
|
||||||
|
|
||||||
def create_leave_group(self) -> QGroupBox:
|
def create_leave_group(self) -> QGroupBox:
|
||||||
"""휴가 설정 그룹"""
|
"""휴가 설정 그룹"""
|
||||||
group = QGroupBox(tr('group.leave'))
|
group = QGroupBox(tr('group.leave'))
|
||||||
@@ -673,6 +741,19 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
layout.addLayout(export_layout)
|
layout.addLayout(export_layout)
|
||||||
|
|
||||||
|
# CSV 가져오기
|
||||||
|
import_layout = QHBoxLayout()
|
||||||
|
import_btn = QPushButton("📥 CSV 가져오기")
|
||||||
|
import_btn.setObjectName("btn_small")
|
||||||
|
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
||||||
|
import_btn.clicked.connect(self._import_csv)
|
||||||
|
import_layout.addWidget(import_btn)
|
||||||
|
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
|
||||||
|
import_label.setObjectName("note_text")
|
||||||
|
import_layout.addWidget(import_label)
|
||||||
|
import_layout.addStretch()
|
||||||
|
layout.addLayout(import_layout)
|
||||||
|
|
||||||
# DB 경로 설정 (클라우드 동기화 가능)
|
# DB 경로 설정 (클라우드 동기화 가능)
|
||||||
db_path_layout = QHBoxLayout()
|
db_path_layout = QHBoxLayout()
|
||||||
db_path_label = QLabel("DB 경로:")
|
db_path_label = QLabel("DB 경로:")
|
||||||
@@ -694,6 +775,22 @@ class SettingsView(QDialog):
|
|||||||
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
||||||
layout.addWidget(self.auto_break_check)
|
layout.addWidget(self.auto_break_check)
|
||||||
|
|
||||||
|
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
||||||
|
feedback_layout = QHBoxLayout()
|
||||||
|
feedback_label = QLabel("Gitea 피드백:")
|
||||||
|
feedback_label.setFixedWidth(80)
|
||||||
|
self.gitea_token_edit = QLineEdit()
|
||||||
|
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
|
||||||
|
feedback_layout.addWidget(feedback_label)
|
||||||
|
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
||||||
|
layout.addLayout(feedback_layout)
|
||||||
|
|
||||||
|
self.gitea_feedback_enabled_check = QCheckBox(
|
||||||
|
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
||||||
|
)
|
||||||
|
layout.addWidget(self.gitea_feedback_enabled_check)
|
||||||
|
|
||||||
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
||||||
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
||||||
self.clock_in_unlock_check.setToolTip(
|
self.clock_in_unlock_check.setToolTip(
|
||||||
@@ -791,11 +888,32 @@ class SettingsView(QDialog):
|
|||||||
if hasattr(self, 'clock_in_unlock_check'):
|
if hasattr(self, 'clock_in_unlock_check'):
|
||||||
self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False))
|
self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False))
|
||||||
|
|
||||||
|
# 목표
|
||||||
|
if hasattr(self, 'goal_ot_h'):
|
||||||
|
ot_min = int(settings.get(GOAL_OVERTIME_MAX_MONTHLY, 0) or 0)
|
||||||
|
self.goal_ot_h.setValue(ot_min // 60)
|
||||||
|
self.goal_ot_m.setValue(ot_min % 60)
|
||||||
|
if hasattr(self, 'goal_avg'):
|
||||||
|
self.goal_avg.setValue(int(float(settings.get(GOAL_AVG_HOURS_DAILY, 0) or 0)))
|
||||||
|
|
||||||
|
# Gitea 피드백
|
||||||
|
if hasattr(self, 'gitea_token_edit'):
|
||||||
|
self.gitea_token_edit.setText(self.db.get_setting(GITEA_FEEDBACK_TOKEN, '') or '')
|
||||||
|
if hasattr(self, 'gitea_feedback_enabled_check'):
|
||||||
|
self.gitea_feedback_enabled_check.setChecked(
|
||||||
|
settings.get(GITEA_FEEDBACK_ENABLED, False)
|
||||||
|
)
|
||||||
|
|
||||||
# 알림
|
# 알림
|
||||||
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
|
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
|
||||||
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
||||||
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
||||||
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
||||||
|
if hasattr(self, 'notif_before_spin'):
|
||||||
|
try:
|
||||||
|
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
self.notif_before_spin.setValue(30)
|
||||||
|
|
||||||
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
||||||
time_format = settings.get(TIME_FORMAT, '24')
|
time_format = settings.get(TIME_FORMAT, '24')
|
||||||
@@ -840,6 +958,49 @@ class SettingsView(QDialog):
|
|||||||
# 남은 연차 계산
|
# 남은 연차 계산
|
||||||
self.update_remaining_leave()
|
self.update_remaining_leave()
|
||||||
|
|
||||||
|
def _import_csv(self):
|
||||||
|
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "CSV 가져오기",
|
||||||
|
os.path.expanduser("~"),
|
||||||
|
"CSV files (*.csv);;All files (*.*)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from utils.csv_importer import parse_csv, import_records
|
||||||
|
rows = parse_csv(path)
|
||||||
|
except (FileNotFoundError, ValueError) as e:
|
||||||
|
QMessageBox.critical(self, "파싱 실패", str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"충돌 처리",
|
||||||
|
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
||||||
|
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
||||||
|
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
||||||
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Cancel:
|
||||||
|
return
|
||||||
|
policy = 'overwrite' if reply == QMessageBox.Yes else 'skip'
|
||||||
|
|
||||||
|
try:
|
||||||
|
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "가져오기 실패", str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
QMessageBox.information(
|
||||||
|
self, "완료",
|
||||||
|
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
||||||
|
)
|
||||||
|
|
||||||
def _check_updates(self):
|
def _check_updates(self):
|
||||||
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
|
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
|
||||||
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
|
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
|
||||||
@@ -879,6 +1040,7 @@ class SettingsView(QDialog):
|
|||||||
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
|
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
|
||||||
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
||||||
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
||||||
|
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
||||||
TIME_FORMAT: self.time_format_combo.currentData(),
|
TIME_FORMAT: self.time_format_combo.currentData(),
|
||||||
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
||||||
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
||||||
@@ -888,6 +1050,14 @@ class SettingsView(QDialog):
|
|||||||
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
||||||
if hasattr(self, 'clock_in_unlock_check'):
|
if hasattr(self, 'clock_in_unlock_check'):
|
||||||
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
|
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
|
||||||
|
if hasattr(self, 'goal_ot_h'):
|
||||||
|
settings[GOAL_OVERTIME_MAX_MONTHLY] = self.goal_ot_h.value() * 60 + self.goal_ot_m.value()
|
||||||
|
if hasattr(self, 'goal_avg'):
|
||||||
|
settings[GOAL_AVG_HOURS_DAILY] = self.goal_avg.value()
|
||||||
|
if hasattr(self, 'gitea_token_edit'):
|
||||||
|
self.db.set_setting(GITEA_FEEDBACK_TOKEN, self.gitea_token_edit.text().strip())
|
||||||
|
if hasattr(self, 'gitea_feedback_enabled_check'):
|
||||||
|
settings[GITEA_FEEDBACK_ENABLED] = self.gitea_feedback_enabled_check.isChecked()
|
||||||
if hasattr(self, 'language_combo'):
|
if hasattr(self, 'language_combo'):
|
||||||
settings[LANGUAGE] = self.language_combo.currentData()
|
settings[LANGUAGE] = self.language_combo.currentData()
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 @@ 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.goal_widget import GoalWidget
|
||||||
|
self.goal_widget = GoalWidget(self.db)
|
||||||
|
layout.addWidget(self.goal_widget)
|
||||||
|
|
||||||
# 월간 차트
|
# 월간 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
@@ -206,9 +217,40 @@ 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', []))
|
||||||
|
|
||||||
|
# 목표 진행률
|
||||||
|
if hasattr(self, 'goal_widget'):
|
||||||
|
self.goal_widget.refresh()
|
||||||
|
|
||||||
# 패턴 분석
|
# 패턴 분석
|
||||||
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
전역 예외 후킹 + Gitea Issues 자동 보고.
|
||||||
|
|
||||||
|
sys.excepthook을 등록해 처리되지 않은 예외를 가로채:
|
||||||
|
1. crash_log 테이블에 저장
|
||||||
|
2. 사용자에게 다이얼로그로 알림 + "Gitea에 보고" / "복사" 옵션
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# 자체 호스팅 Gitea (updater_client와 동일)
|
||||||
|
GITEA_API = 'https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator'
|
||||||
|
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
|
||||||
|
|
||||||
|
|
||||||
|
def install_global_handler(db, app_version: str = 'unknown') -> None:
|
||||||
|
"""sys.excepthook 등록. db는 crash_log 저장용."""
|
||||||
|
original = sys.excepthook
|
||||||
|
|
||||||
|
def hook(exc_type, exc_value, exc_tb):
|
||||||
|
if exc_type is KeyboardInterrupt:
|
||||||
|
original(exc_type, exc_value, exc_tb)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||||
|
_log_crash(db, exc_type.__name__, str(exc_value), tb_str, app_version)
|
||||||
|
_show_dialog(db, exc_type.__name__, str(exc_value), tb_str, app_version)
|
||||||
|
except Exception:
|
||||||
|
pass # 후킹 자체 실패는 silent
|
||||||
|
original(exc_type, exc_value, exc_tb)
|
||||||
|
|
||||||
|
sys.excepthook = hook
|
||||||
|
|
||||||
|
|
||||||
|
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||||
|
"""crash_log 테이블에 기록."""
|
||||||
|
try:
|
||||||
|
conn = db.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# 테이블 자동 생성 (마이그레이션 누락 대비)
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
exception_type TEXT,
|
||||||
|
message TEXT,
|
||||||
|
traceback TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
reported BOOLEAN DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO crash_log (exception_type, message, traceback, app_version)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (exc_type, message, tb, version))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _show_dialog(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||||
|
"""크래시 알림 + Gitea 보고/복사 버튼."""
|
||||||
|
try:
|
||||||
|
from PyQt5.QtWidgets import (QApplication, QMessageBox, QDialog,
|
||||||
|
QVBoxLayout, QLabel, QTextEdit,
|
||||||
|
QHBoxLayout, QPushButton, QLineEdit)
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
dlg = QDialog()
|
||||||
|
dlg.setWindowTitle("⚠️ 오류 발생")
|
||||||
|
dlg.setMinimumSize(560, 420)
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
title = QLabel(f"<b>{exc_type}</b>: {message[:200]}")
|
||||||
|
title.setWordWrap(True)
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel("무엇을 하다가 발생했나요? (선택)"))
|
||||||
|
user_note = QLineEdit()
|
||||||
|
user_note.setPlaceholderText("예: 출근 버튼 누른 직후")
|
||||||
|
layout.addWidget(user_note)
|
||||||
|
|
||||||
|
layout.addWidget(QLabel("기술 정보 (자동 보고에 포함):"))
|
||||||
|
tb_view = QTextEdit()
|
||||||
|
tb_view.setReadOnly(True)
|
||||||
|
tb_view.setFont(__import__('PyQt5.QtGui', fromlist=['QFont']).QFont('Consolas', 9))
|
||||||
|
tb_view.setText(tb[-3000:]) # 너무 긴 traceback 제한
|
||||||
|
layout.addWidget(tb_view, 1)
|
||||||
|
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
cancel_btn = QPushButton("닫기")
|
||||||
|
copy_btn = QPushButton("📋 복사")
|
||||||
|
report_btn = QPushButton("📤 Gitea에 보고")
|
||||||
|
|
||||||
|
has_token = bool(db.get_setting('gitea_feedback_token', '') or '')
|
||||||
|
enabled = db.get_setting('gitea_feedback_enabled', 'false').lower() == 'true'
|
||||||
|
if not (has_token and enabled):
|
||||||
|
report_btn.setEnabled(False)
|
||||||
|
report_btn.setToolTip("설정 → 데이터 관리 → Gitea 피드백 토큰 입력 후 활성화 필요")
|
||||||
|
|
||||||
|
def do_copy():
|
||||||
|
clipboard = QApplication.clipboard()
|
||||||
|
text = (
|
||||||
|
f"## {exc_type}\n\n{message}\n\n"
|
||||||
|
f"**Version**: {version}\n**Note**: {user_note.text()}\n\n"
|
||||||
|
f"```\n{tb}\n```"
|
||||||
|
)
|
||||||
|
clipboard.setText(text)
|
||||||
|
copy_btn.setText("✓ 복사됨")
|
||||||
|
|
||||||
|
def do_report():
|
||||||
|
token = db.get_setting('gitea_feedback_token', '') or ''
|
||||||
|
if not token:
|
||||||
|
QMessageBox.warning(dlg, "토큰 없음", "Gitea PAT를 설정에서 먼저 입력하세요.")
|
||||||
|
return
|
||||||
|
title_str = f"[Auto] {exc_type}: {message[:80]}"
|
||||||
|
body = (
|
||||||
|
f"**Version**: `{version}`\n"
|
||||||
|
f"**Time**: {datetime.now().isoformat(timespec='seconds')}\n"
|
||||||
|
f"**User note**: {user_note.text() or '(none)'}\n\n"
|
||||||
|
f"### Traceback\n```\n{tb[-3000:]}\n```"
|
||||||
|
)
|
||||||
|
ok = _send_to_gitea(token, title_str, body)
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(dlg, "보고 완료", "Gitea Issues에 등록되었습니다.")
|
||||||
|
report_btn.setText("✓ 보고됨")
|
||||||
|
report_btn.setEnabled(False)
|
||||||
|
else:
|
||||||
|
QMessageBox.warning(dlg, "보고 실패", "네트워크 또는 토큰 권한 문제일 수 있습니다.")
|
||||||
|
|
||||||
|
cancel_btn.clicked.connect(dlg.reject)
|
||||||
|
copy_btn.clicked.connect(do_copy)
|
||||||
|
report_btn.clicked.connect(do_report)
|
||||||
|
btn_row.addWidget(cancel_btn)
|
||||||
|
btn_row.addStretch()
|
||||||
|
btn_row.addWidget(copy_btn)
|
||||||
|
btn_row.addWidget(report_btn)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
dlg.setLayout(layout)
|
||||||
|
dlg.exec_()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_to_gitea(token: str, title: str, body: str) -> bool:
|
||||||
|
"""Gitea Issues API로 issue 생성."""
|
||||||
|
payload = json.dumps({'title': title, 'body': body}).encode('utf-8')
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GITEA_API}/issues",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
'Authorization': f'token {token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return 200 <= resp.status < 300
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
CSV 가져오기 — 우리 표준 포맷.
|
||||||
|
|
||||||
|
표준 포맷:
|
||||||
|
date,clock_in,clock_out,lunch_minutes,memo
|
||||||
|
2026-04-01,09:00:00,18:30:00,60,"메모"
|
||||||
|
|
||||||
|
- 헤더 첫 줄 필수
|
||||||
|
- date: YYYY-MM-DD
|
||||||
|
- clock_in/out: HH:MM:SS 또는 HH:MM
|
||||||
|
- lunch_minutes: 정수 (0이면 점심 미포함)
|
||||||
|
- memo: 선택 (따옴표 가능)
|
||||||
|
|
||||||
|
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Iterator, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(path: str) -> List[Dict]:
|
||||||
|
"""CSV 파일을 dict 리스트로 파싱. 검증 실패 시 ValueError."""
|
||||||
|
rows = []
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
raise FileNotFoundError(f"파일 없음: {path}")
|
||||||
|
|
||||||
|
with open(p, 'r', encoding='utf-8-sig', newline='') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
required = {'date', 'clock_in'}
|
||||||
|
if not required.issubset(reader.fieldnames or []):
|
||||||
|
raise ValueError(
|
||||||
|
f"헤더에 필수 필드 누락: {required - set(reader.fieldnames or [])}\n"
|
||||||
|
f"필수 헤더: date,clock_in,clock_out,lunch_minutes,memo"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2): # 데이터 시작 줄 번호 (1=헤더)
|
||||||
|
try:
|
||||||
|
clean = _normalize_row(row)
|
||||||
|
rows.append(clean)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"줄 {i}: {e}")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_row(row: Dict) -> Dict:
|
||||||
|
"""단일 행 검증 + 정규화."""
|
||||||
|
date_str = (row.get('date') or '').strip()
|
||||||
|
if not date_str:
|
||||||
|
raise ValueError("date 비어있음")
|
||||||
|
try:
|
||||||
|
datetime.strptime(date_str, '%Y-%m-%d')
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"date 형식 오류: '{date_str}' (YYYY-MM-DD 필요)")
|
||||||
|
|
||||||
|
ci = _normalize_time(row.get('clock_in', '').strip(), 'clock_in')
|
||||||
|
co_raw = (row.get('clock_out') or '').strip()
|
||||||
|
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
|
||||||
|
|
||||||
|
lunch = 0
|
||||||
|
lm = (row.get('lunch_minutes') or '').strip()
|
||||||
|
if lm:
|
||||||
|
try:
|
||||||
|
lunch = int(lm)
|
||||||
|
if lunch < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'")
|
||||||
|
|
||||||
|
memo = (row.get('memo') or '').strip()
|
||||||
|
return {
|
||||||
|
'date': date_str,
|
||||||
|
'clock_in': ci,
|
||||||
|
'clock_out': co,
|
||||||
|
'lunch_minutes': lunch,
|
||||||
|
'memo': memo,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_time(s: str, field_name: str) -> str:
|
||||||
|
"""'HH:MM' 또는 'HH:MM:SS' → 'HH:MM:SS'."""
|
||||||
|
if not s:
|
||||||
|
raise ValueError(f"{field_name} 비어있음")
|
||||||
|
parts = s.split(':')
|
||||||
|
if len(parts) == 2:
|
||||||
|
s = f"{s}:00"
|
||||||
|
elif len(parts) != 3:
|
||||||
|
raise ValueError(f"{field_name} 형식 오류: '{s}' (HH:MM[:SS] 필요)")
|
||||||
|
try:
|
||||||
|
datetime.strptime(s, '%H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"{field_name} 시간 파싱 실패: '{s}'")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int, int, int]:
|
||||||
|
"""파싱된 rows를 DB에 일괄 입력.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database 인스턴스
|
||||||
|
rows: parse_csv 결과
|
||||||
|
on_conflict: 'skip' | 'overwrite'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(added, updated, skipped) 수
|
||||||
|
"""
|
||||||
|
if on_conflict not in ('skip', 'overwrite'):
|
||||||
|
raise ValueError("on_conflict는 'skip' 또는 'overwrite'")
|
||||||
|
|
||||||
|
added = updated = skipped = 0
|
||||||
|
|
||||||
|
from core.time_calculator import TimeCalculator
|
||||||
|
work_minutes = db.get_work_minutes()
|
||||||
|
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
existing = db.get_work_record(row['date'])
|
||||||
|
|
||||||
|
if existing and on_conflict == 'skip':
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if existing and on_conflict == 'overwrite':
|
||||||
|
# 기존 레코드 삭제 후 재추가 (단순화)
|
||||||
|
conn = db.get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
||||||
|
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||||
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
wid = db.add_work_record(row['date'], row['clock_in'], is_manual=True)
|
||||||
|
if row.get('clock_out'):
|
||||||
|
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||||
|
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||||
|
calc = TimeCalculator(work_minutes=work_minutes,
|
||||||
|
lunch_duration_minutes=row.get('lunch_minutes') or lunch_default)
|
||||||
|
include_lunch = (row.get('lunch_minutes') or 0) > 0
|
||||||
|
total = (co_dt - ci_dt).total_seconds() / 3600
|
||||||
|
ot_actual, ot_earned = calc.calculate_overtime(
|
||||||
|
ci_dt, co_dt, include_lunch=include_lunch
|
||||||
|
)
|
||||||
|
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
|
||||||
|
if include_lunch:
|
||||||
|
db.update_lunch_break(row['date'], True)
|
||||||
|
if ot_earned > 0:
|
||||||
|
db.add_overtime_earned(wid, ot_earned, row['date'])
|
||||||
|
|
||||||
|
return added, updated, skipped
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
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/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
||||||
|
# 일반 브라우저 UA로 위장해야 통과.
|
||||||
|
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
|
||||||
|
|
||||||
|
# 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',
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
)
|
||||||
+28
-10
@@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool:
|
|||||||
return _parse_version(remote) > _parse_version(local)
|
return _parse_version(remote) > _parse_version(local)
|
||||||
|
|
||||||
|
|
||||||
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]:
|
# 업데이트 체크 결과 상수 (info=None 일 때 reason 식별)
|
||||||
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None.
|
UP_TO_DATE = 'up_to_date'
|
||||||
|
NETWORK_ERROR = 'network_error'
|
||||||
|
NO_RELEASE = 'no_release' # latest 응답 자체가 없음 (404 등)
|
||||||
|
NO_ASSET = 'no_asset' # release는 있으나 main.exe 자산 없음
|
||||||
|
|
||||||
네트워크 오류 시 None 반환 (앱 시작을 막지 않음).
|
|
||||||
|
def check_for_update(current_version: str, timeout: int = 5):
|
||||||
|
"""Releases API 조회.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(ReleaseInfo, None) — 새 버전 있음
|
||||||
|
(None, UP_TO_DATE) — 이미 최신
|
||||||
|
(None, NETWORK_ERROR) — 네트워크/타임아웃 실패
|
||||||
|
(None, NO_RELEASE) — 저장소 비공개/리소스 없음 (404)
|
||||||
|
(None, NO_ASSET) — 새 버전이지만 main.exe 자산 미첨부
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
|
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
|
except urllib.error.HTTPError as e:
|
||||||
return None
|
if e.code == 404:
|
||||||
|
return None, NO_RELEASE
|
||||||
|
return None, NETWORK_ERROR
|
||||||
|
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError):
|
||||||
|
return None, NETWORK_ERROR
|
||||||
|
|
||||||
tag = data.get('tag_name', '')
|
tag = data.get('tag_name', '')
|
||||||
if not tag or not is_newer(tag, current_version):
|
if not tag:
|
||||||
return None
|
return None, NO_RELEASE
|
||||||
|
if not is_newer(tag, current_version):
|
||||||
|
return None, UP_TO_DATE
|
||||||
|
|
||||||
asset_url = None
|
asset_url = None
|
||||||
for asset in data.get('assets', []):
|
for asset in data.get('assets', []):
|
||||||
if asset.get('name') == ASSET_NAME:
|
if asset.get('name') == ASSET_NAME:
|
||||||
asset_url = asset.get('browser_download_url')
|
asset_url = asset.get('browser_download_url')
|
||||||
break
|
break
|
||||||
|
|
||||||
if not asset_url:
|
if not asset_url:
|
||||||
return None
|
return None, NO_ASSET
|
||||||
|
|
||||||
return ReleaseInfo(
|
info = ReleaseInfo(
|
||||||
version=tag,
|
version=tag,
|
||||||
asset_url=asset_url,
|
asset_url=asset_url,
|
||||||
notes=data.get('body', ''),
|
notes=data.get('body', ''),
|
||||||
published_at=data.get('published_at', ''),
|
published_at=data.get('published_at', ''),
|
||||||
)
|
)
|
||||||
|
return info, None
|
||||||
|
|
||||||
|
|
||||||
def download_update(asset_url: str, dest_dir: Optional[Path] = None,
|
def download_update(asset_url: str, dest_dir: Optional[Path] = None,
|
||||||
|
|||||||
Reference in New Issue
Block a user