Compare commits

..
9 Commits
Author SHA1 Message Date
KINDNICKandClaude Opus 4.7 6a17876af1 v2.6.0: Phase 3 + Phase 4 — accessibility + code sign infra
Phase 3 (also v2.5.0 in CHANGELOG):
- Weekly auto report on Monday
- Matplotlib chart hover annotation
- Clock-in time distribution histogram
- Leave usage calendar with color coding

Phase 4 (v2.6.0):
- Font scale 100/125/150% (instant apply)
- High-contrast mode (black bg + yellow text)
- Authenticode signing infra in release.ps1 (env-gated, optional)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:54:25 +09:00
KINDNICKandClaude Opus 4.7 606da976a0 v2.5.0: Phase 3 — weekly report, chart hover, clock-in distribution, leave calendar
- Weekly auto report on Monday (system alert + Discord push, dedupe)
- Matplotlib chart hover annotation for daily hours
- Clock-in time distribution histogram (30-min bins) with avg line
- Leave usage calendar with color-coded full/half/quarter days

Fixed: leave_view setLayout indentation regression

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:51:47 +09:00
KINDNICKandClaude Opus 4.7 9ebf4ad961 v2.4.0: Phase 2 — meal time, past records, goals, CSV import, crash report
- Meal time dialog (right-click lunch/dinner button to enter actual times)
- Calendar right-click context: add/edit/delete past records
- Monthly goal settings + progress widget (overtime cap, avg daily)
- CSV import (our standard format) with conflict policy
- Global crash handler with Gitea Issues auto-report

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:38:38 +09:00
KINDNICKandClaude Opus 4.7 d3a4efc173 v2.3.3: fix Discord webhook always failing (Cloudflare blocks Python UA)
Cloudflare protects Discord webhook endpoints and rejects requests
with the default Python urllib User-Agent ('Python-urllib/3.x') with
HTTP 403 + 'error code 1010'. Add a browser-like User-Agent header
to the request.

The onboarding wizard's 'Send Test Message' button (and clock-in/out
push messages) now succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:23:51 +09:00
KINDNICKandClaude Opus 4.7 86cf438785 v2.3.2: fix HelpView showing blank window (regression in v2.3.1)
In v2.3.1 the new 'Re-run Onboarding' button was added but the
self.setLayout(main_layout) line was accidentally indented into the
new _reopen_onboarding method body, so init_ui returned without
applying the layout. Result: F1 opened an empty dialog with no tabs.

Move setLayout back to the end of init_ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:12:51 +09:00
KINDNICKandClaude Opus 4.7 fc5184ae07 v2.3.1: add 'Re-run Onboarding' button to Help dialog
v2.3.0 had show_onboarding() method but no UI entry point. Existing
users couldn't reach the wizard since auto-migration marks them done.
Now Help dialog (F1) has a button to re-trigger the wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:07:57 +09:00
KINDNICKandClaude Opus 4.7 e4011d9fc3 v2.3.0: Phase 1 + E1 — onboarding wizard, salary, inline edit, today card, health break, Discord webhook
Added (6 user-friendly features):
- Onboarding wizard (5 steps, forced on first launch, re-runnable from Help menu)
- Salary estimation (optional, hourly wage + overtime multiplier)
- Inline clock-in/out time editing (click label to edit)
- Today summary card (post-clockout, auto-hide on next clock-in)
- Health break reminder (continuous N-hour at desk warning)
- Discord webhook notifications (clock-in/out/health, mobile push, no server)

Database:
- break_records.break_type column ('break'/'lunch'/'dinner')
- notification_log table (dedupe + analytics)
- Auto-complete onboarding for existing users (work_records exists)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:01:48 +09:00
KINDNICKandClaude Opus 4.7 14d88656fe v2.2.4: implement auto_overtime + overtime_unit + notif_before_minutes; remove 3 dead settings
Implemented (previously UI-only with no business effect):
- auto_overtime: when OFF, prompt user on clock-out before banking
- overtime_unit: 15/30/60-min truncation choice now actually applied
- notification_before_minutes: 30-min hardcode -> user-configurable 1~120

Removed dead keys (no readers in business logic):
- auto_detect_boot, notification_enabled, annual_leave_used

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:29:44 +09:00
KINDNICKandClaude Opus 4.7 b91b229731 v2.2.3: stage updater.exe outside dist/ to survive main.spec --clean
main.spec's --clean flag wipes dist/ at the start of main.exe build,
which deleted dist/updater.exe before it could be embedded as data
(v2.2.2 main.exe shipped without embedded updater).

Fix: release.ps1 copies dist/updater.exe to build/staging/ after
the updater build, and main.spec datas references that staged path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:15:51 +09:00
30 changed files with 2688 additions and 62 deletions
+139
View File
@@ -4,6 +4,145 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.6.0] — 2026-04-30
### Added — Phase 4 (3종)
- **글꼴 크기 조절** (100% / 125% / 150%) — 설정에서 즉시 반영
- **고대비 모드** — 검정 배경 + 노란 텍스트 (시각약자/야간)
- **코드 서명 인프라** — `release.ps1`에 Authenticode 서명 단계 추가 (옵션)
- `$env:CODE_SIGN_CERT` (`.pfx` 경로) + `$env:CODE_SIGN_PASS` 환경변수 설정 시 자동 서명
- signtool.exe 없거나 cert 미설정 시 자동 스킵
- 코드 서명 인증서 확보 후 활성화하면 SmartScreen 경고 제거 가능
### Settings (신규)
- `font_scale`, `high_contrast`
## [2.5.0] — 2026-04-30
### Added — Phase 3 (4종)
- **주간 자동 리포트** — 월요일 첫 출근 시 (또는 첫 5분 tick) 지난주 요약 발송
- 시스템 알림 + Discord push (옵션) 동시
- `notification_log`로 중복 발송 방지
- 항목: 총 근무·일평균·연장근무·가장 긴 날
- **matplotlib 차트 호버 디테일** — 막대 위에 마우스 올리면 정확한 수치 툴팁
- 일별 근무 시간 차트(주간 탭)에 적용
- **출근 시각 분포 차트** — 패턴 분석 탭에 30분 단위 히스토그램
- 평균 출근 시각 빨간 점선으로 표시
- **휴가 캘린더 시각화** — 연차 관리 → "📅 캘린더 보기"
- 사용 일자에 종일/반차/반반차별 색상 표시
- 날짜 클릭 → 사용 내역 표시
### Fixed
- `leave_view.py` setLayout 들여쓰기 회귀 수정
## [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
### Fixed
- **`updater.exe` 임베딩 빌드 안정화**
- v2.2.2에서 `main.spec --clean`이 빌드 시작 시 `dist/updater.exe`를 지우면서
임베딩이 누락되던 문제 수정
- `release.ps1`이 `dist/updater.exe`를 `build/staging/`로 복사 후 main.spec이 그걸 참조
- main.exe 사이즈가 다시 ~78MB (updater 내장) 로 정상화
## [2.2.2] — 2026-04-30
### Added
+183 -10
View File
@@ -161,6 +161,9 @@ class Database:
self.migrate_cleanup_balance_adjustments()
self.migrate_work_hours_to_minutes()
self.migrate_annual_leave_keys()
self.migrate_v23_break_type()
self.migrate_v23_notification_log()
self.migrate_v23_onboarding_for_existing()
# 기본 설정 초기화
self.init_default_settings()
@@ -467,6 +470,111 @@ class Database:
finally:
conn.close()
def migrate_v23_break_type(self):
"""break_records에 break_type 컬럼 추가 (v2.3.0).
값: 'break'(기본 외출) / 'lunch' / 'dinner'.
기존 점심 1시간 자동 적용 모드와 무관 — 실제 시간 입력용.
"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(break_records)")
cols = [row[1] for row in cursor.fetchall()]
if 'break_type' not in cols:
cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'")
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr)
finally:
conn.close()
def migrate_v23_notification_log(self):
"""알림 발송 이력 테이블 (v2.3.0). 중복 발송 방지 + 통계."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN DEFAULT 1
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_notif_log_event
ON notification_log(event_type, sent_at)
''')
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"notification_log 생성 경고: {e}", file=sys.stderr)
finally:
conn.close()
def log_notification(self, channel: str, event_type: str,
payload: str = None, success: bool = True) -> None:
"""알림 발송 이력 기록 (중복 방지 가드용)."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"INSERT INTO notification_log (channel, event_type, payload, success) VALUES (?, ?, ?, ?)",
(channel, event_type, payload, success)
)
conn.commit()
finally:
conn.close()
def has_notification_today(self, channel: str, event_type: str) -> bool:
"""오늘 같은 (channel, event_type) 발송 이력 존재 여부."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT COUNT(*) FROM notification_log "
"WHERE channel = ? AND event_type = ? AND DATE(sent_at) = DATE('now', 'localtime')",
(channel, event_type)
)
return cursor.fetchone()[0] > 0
finally:
conn.close()
def migrate_v23_onboarding_for_existing(self):
"""기존 사용자(이미 work_records 데이터 있음)는 온보딩 자동 완료 처리.
v2.3.0 도입 시 한 번만 실행. 신규 DB(데이터 0)는 영향 없음 → 첫 실행 시 위저드.
"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 이미 완료/스킵 마크 있으면 패스
cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'")
row = cursor.fetchone()
if row and row[0] == 'true':
return
# 기존 work_records 데이터가 1건 이상 있으면 자동 완료
cursor.execute("SELECT COUNT(*) FROM work_records")
count = cursor.fetchone()[0]
if count > 0:
cursor.execute('''
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP)
''')
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr)
finally:
conn.close()
def get_setting_int(self, key: str, default: int = 0) -> int:
"""설정을 int로 안전하게 조회 (변환 실패 시 default)."""
raw = self.get_setting(key, None)
@@ -520,21 +628,36 @@ class Database:
'work_minutes': '480',
'lunch_duration_minutes': '60',
'dinner_duration_minutes': '60',
'auto_detect_boot': 'true',
'auto_lunch': 'false',
'auto_overtime': 'true',
'theme': 'light',
'notification_enabled': 'true',
'notification_before_minutes': '30',
'notification_clock_out': 'true',
'notification_lunch': 'true',
'notification_overtime': 'true',
'notification_health': 'true',
'annual_leave_total': '15',
'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화)
'annual_leave_used': '0',
'annual_leave_days': '15', # annual_leave_total과 자동 동기화
'workday_boundary_hour': '6',
'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',
'goal_avg_hours_daily': '0',
# v2.6.0
'font_scale': '1.0',
'high_contrast': 'false',
}
conn = self.get_connection()
@@ -1148,21 +1271,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()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO break_records (work_record_id, date, break_out, reason)
VALUES (?, ?, ?, ?)
''', (work_record_id, date, break_out, reason))
INSERT INTO break_records (work_record_id, date, break_out, reason, break_type)
VALUES (?, ?, ?, ?, ?)
''', (work_record_id, date, break_out, reason, break_type))
record_id = cursor.lastrowid
conn.commit()
conn.close()
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):
"""외출 복귀 시간 업데이트"""
conn = self.get_connection()
+38 -2
View File
@@ -65,8 +65,17 @@ class Notifier(QObject):
return
time_diff = clock_out_time - current_time
# 30분 이내, 아직 알림 안 했으면
if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min:
# 사용자 설정 N분 이내 알림 (기본 30, 설정에서 1~120 범위)
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)
self.notification_signal.emit(
tr('notif.clock_out_soon.title'),
@@ -155,6 +164,33 @@ class Notifier(QObject):
)
self.notified_weekly = True
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
"""장시간 연속 근무 휴식 알림.
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
"""
if current_time is None:
current_time = datetime.now()
if self.db is None:
return
if not self._enabled('health_break_enabled'):
return
if self.db.has_notification_today('system', 'health_break'):
return
try:
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
except (ValueError, TypeError):
threshold_hours = 4
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
if elapsed_sec >= threshold_hours * 3600:
from core.i18n import tr
self.notification_signal.emit(
tr('notif.health_break.title') if False else "🌿 휴식 권고",
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
)
self.db.log_notification('system', 'health_break')
def reset_notifications(self):
"""알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False
+50
View File
@@ -0,0 +1,50 @@
"""
급여 추정 (옵션).
포괄임금제 회사면 사용 안 함. 시급 + 연장수당 가산률만 받아서 단순 계산.
"""
from __future__ import annotations
from typing import List, Dict
def estimate_pay(records: List[Dict], hourly_wage: float,
overtime_rate: float = 1.5) -> Dict[str, float]:
"""근무 기록 리스트로부터 추정 급여 계산.
Args:
records: [{'total_hours': float, 'overtime_minutes': int}, ...]
hourly_wage: 시급(원)
overtime_rate: 연장수당 가산률 (기본 1.5배 - 한국 노동법)
Returns:
{'base': 기본급, 'overtime': 연장수당, 'total': 합계}
"""
if hourly_wage <= 0:
return {'base': 0.0, 'overtime': 0.0, 'total': 0.0}
base_hours = 0.0
overtime_hours = 0.0
for r in records:
total = float(r.get('total_hours') or 0)
ot_min = int(r.get('overtime_minutes') or 0)
ot_hours = ot_min / 60.0
# 정규 근무 = 총 - 연장
regular = max(0.0, total - ot_hours)
base_hours += regular
overtime_hours += ot_hours
base = base_hours * hourly_wage
overtime = overtime_hours * hourly_wage * overtime_rate
return {
'base': base,
'overtime': overtime,
'total': base + overtime,
'base_hours': base_hours,
'overtime_hours': overtime_hours,
}
def format_won(amount: float) -> str:
"""원화 포맷팅. 1234567 → '1,234,567원'."""
return f"{int(round(amount)):,}원"
+29 -3
View File
@@ -13,14 +13,12 @@ DINNER_DURATION_MINUTES = 'dinner_duration_minutes'
WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour'
# 자동화
AUTO_DETECT_BOOT = 'auto_detect_boot'
AUTO_LUNCH = 'auto_lunch'
AUTO_OVERTIME = 'auto_overtime'
AUTO_BREAK_ON_LOCK = 'auto_break_on_lock'
CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용)
# 알림
NOTIFICATION_ENABLED = 'notification_enabled'
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
NOTIF_CLOCK_OUT = 'notification_clock_out'
NOTIF_LUNCH = 'notification_lunch'
@@ -30,7 +28,6 @@ NOTIF_HEALTH = 'notification_health'
# 연차
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
ANNUAL_LEAVE_USED = 'annual_leave_used'
LEAVE_BALANCE = 'leave_balance'
INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes'
INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours'
@@ -40,6 +37,8 @@ THEME = 'theme'
TIME_FORMAT = 'time_format'
LANGUAGE = 'language'
OVERTIME_UNIT = 'overtime_unit'
FONT_SCALE = 'font_scale' # '1.0' / '1.25' / '1.5'
HIGH_CONTRAST = 'high_contrast'
# 통합/외부
DB_PATH_OVERRIDE = 'db_path_override'
@@ -47,6 +46,33 @@ DB_PATH_OVERRIDE = 'db_path_override'
# 백업
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
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
+7 -5
View File
@@ -135,17 +135,19 @@ class TimeCalculator:
def calculate_overtime(self, clock_in: datetime, clock_out: datetime,
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:
clock_in: 출근 시간
clock_out: 퇴근 시간
include_lunch: 점심시간 포함 여부
include_dinner: 저녁시간 포함 여부
break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외
unit_minutes: 적립 단위 (분, 기본 30). 사용자 설정 OVERTIME_UNIT.
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)
@@ -155,8 +157,8 @@ class TimeCalculator:
overtime_duration = clock_out - expected_clock_out
overtime_minutes = int(overtime_duration.total_seconds() / 60)
# 30분 단위로 절삭
overtime_earned = (overtime_minutes // 30) * 30
unit = unit_minutes if unit_minutes > 0 else 30
overtime_earned = (overtime_minutes // unit) * unit
return overtime_minutes, overtime_earned
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것.
"""
__version__ = '2.2.2'
__version__ = '2.6.0'
+17
View File
@@ -128,6 +128,23 @@ def main():
from utils.debug_log import dlog
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:
window = MainWindow()
+8 -2
View File
@@ -4,9 +4,15 @@
# the embedded updater is auto-extracted next to main.exe on first launch.
import os
# release.ps1 stages updater.exe at build/staging/updater.exe
# (avoids PyInstaller --clean wiping dist/updater.exe before main build).
_extra_datas = []
if os.path.exists('dist/updater.exe'):
_extra_datas.append(('dist/updater.exe', '.'))
_staged = 'build/staging/updater.exe'
_fallback = 'dist/updater.exe'
if os.path.exists(_staged):
_extra_datas.append((_staged, '.'))
elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.'))
a = Analysis(
['main.py'],
+33
View File
@@ -120,15 +120,48 @@ $rc = Invoke-Native python -m PyInstaller --clean updater.spec
if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/updater.exe')) { Fail "dist/updater.exe missing" }
# Stage updater.exe outside dist/ so main.spec --clean does not wipe it
$stagingDir = 'build/staging'
if (-not (Test-Path $stagingDir)) { New-Item -ItemType Directory -Path $stagingDir | Out-Null }
Copy-Item 'dist/updater.exe' "$stagingDir/updater.exe" -Force
Info "Staged updater.exe -> $stagingDir/updater.exe"
Info "Building main.exe (with embedded updater)..."
$rc = Invoke-Native python -m PyInstaller --clean main.spec
if ($rc -ne 0) { Fail "main.exe build failed (exit $rc)" }
if (-not (Test-Path 'dist/main.exe')) { Fail "dist/main.exe missing" }
# Restore updater.exe to dist/ for separate Release asset upload
if (-not (Test-Path 'dist/updater.exe')) {
Copy-Item "$stagingDir/updater.exe" 'dist/updater.exe' -Force
Info "Restored updater.exe to dist/"
}
$mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB)
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
# Optional: Authenticode signing if cert available (env vars)
# Set CODE_SIGN_CERT (path to .pfx) and CODE_SIGN_PASS to enable
if ($env:CODE_SIGN_CERT -and (Test-Path $env:CODE_SIGN_CERT)) {
Info "Code signing exes (Authenticode)..."
$signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue
if (-not $signtool) {
Info " signtool.exe not found in PATH — skipping signature"
} else {
$tsUrl = if ($env:CODE_SIGN_TIMESTAMP) { $env:CODE_SIGN_TIMESTAMP } else { 'http://timestamp.digicert.com' }
foreach ($exe in 'dist/main.exe', 'dist/updater.exe') {
$args = @('sign', '/f', $env:CODE_SIGN_CERT)
if ($env:CODE_SIGN_PASS) { $args += '/p'; $args += $env:CODE_SIGN_PASS }
$args += '/tr'; $args += $tsUrl; $args += '/td'; $args += 'sha256'
$args += '/fd'; $args += 'sha256'; $args += $exe
$rc = Invoke-Native signtool $args
if ($rc -ne 0) { Info " WARN: sign failed for $exe (exit $rc)" }
else { OkMsg " signed $exe" }
}
}
}
# ====== 4. ZIP ======
Step "4/7 ZIP packaging"
$zipPath = "dist/ClockOutCalculator-$Version.zip"
+29 -1
View File
@@ -8,7 +8,10 @@ import pytest
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:
@@ -59,6 +62,31 @@ class TestApiUrl:
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:
"""updater.py 자체 로직."""
+82
View File
@@ -0,0 +1,82 @@
"""
접근성 — 글꼴 크기 / 고대비 모드 적용.
QApplication 글로벌 폰트 + 추가 QSS 오버레이.
설정 변경 즉시 반영 (재시작 불필요).
"""
from __future__ import annotations
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QFont
# 고대비 QSS — 검정 배경 + 노란 텍스트 + 굵은 테두리
HIGH_CONTRAST_QSS = """
* {
background-color: #000000;
color: #FFEB3B;
border-color: #FFEB3B;
}
QPushButton, QLineEdit, QSpinBox, QComboBox, QTextEdit, QTableWidget, QGroupBox {
border: 2px solid #FFEB3B;
background-color: #000000;
color: #FFEB3B;
}
QPushButton:hover { background-color: #333333; }
QPushButton:pressed { background-color: #FFEB3B; color: #000000; }
QPushButton:disabled { color: #888; border-color: #888; }
QGroupBox::title { color: #FFEB3B; padding: 0 4px; }
QProgressBar { border: 2px solid #FFEB3B; }
QProgressBar::chunk { background-color: #FFEB3B; }
QToolTip { background-color: #000; color: #FFEB3B; border: 2px solid #FFEB3B; }
"""
def apply_font_scale(scale: float) -> None:
"""전역 글꼴 크기 배율 적용 (1.0 = 기본, 1.25 = 125%, 1.5 = 150%)."""
app = QApplication.instance()
if app is None:
return
base = app.font()
if base.pointSize() > 0:
# 기존 배율 무시하고 새 배율로 (기본 9pt 가정)
base_pt = 9
base.setPointSize(int(round(base_pt * scale)))
else:
base_px = 12
base.setPixelSize(int(round(base_px * scale)))
app.setFont(base)
def apply_high_contrast(enabled: bool, base_qss: str = "") -> None:
"""고대비 모드 ON/OFF. base_qss는 평소 테마 QSS (OFF 시 복원용)."""
app = QApplication.instance()
if app is None:
return
if enabled:
app.setStyleSheet(base_qss + "\n" + HIGH_CONTRAST_QSS)
else:
app.setStyleSheet(base_qss)
def apply_from_settings(db) -> None:
"""db에서 font_scale + high_contrast 읽어 적용."""
try:
scale = float(db.get_setting('font_scale', '1.0') or 1.0)
except (ValueError, TypeError):
scale = 1.0
scale = max(0.8, min(2.0, scale))
apply_font_scale(scale)
enabled = db.get_setting('high_contrast', 'false').lower() == 'true'
# base_qss는 main_window에서 apply_theme() 호출 직후 적용되므로,
# 여기서는 현재 styleSheet 그대로 두고 high_contrast만 추가.
app = QApplication.instance()
if app is None:
return
current = app.styleSheet() or ""
# 기존에 추가된 HIGH_CONTRAST_QSS 제거
base = current.replace(HIGH_CONTRAST_QSS, "").rstrip() + "\n"
if enabled:
app.setStyleSheet(base + HIGH_CONTRAST_QSS)
else:
app.setStyleSheet(base.rstrip())
+104
View File
@@ -47,6 +47,9 @@ class CalendarView(QDialog):
self.calendar.setMinimumHeight(280)
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
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)
# 범례
@@ -152,6 +155,107 @@ class CalendarView(QDialog):
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):
"""날짜 선택 시"""
selected_date = qdate.toPyDate()
+87 -8
View File
@@ -50,12 +50,7 @@ def make_chart_widget(parent=None) -> QWidget:
def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
"""일별 근무시간 막대 그래프.
Args:
widget: make_chart_widget()로 만든 위젯
records: [{date, total_hours, overtime_minutes}, ...]
"""
"""일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁)."""
if not getattr(widget, '_figure', None):
return
fig = widget._figure
@@ -67,17 +62,101 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
return
dates = [r['date'][5:] for r in records] # MM-DD만
full_dates = [r['date'] for r in records]
hours = [r.get('total_hours', 0) or 0 for r in records]
overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records]
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
ax = fig.add_subplot(111)
ax.bar(dates, base, label='정상', color='#4a90e2')
ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
ax.set_ylabel('시간')
ax.legend(loc='upper left', fontsize=8)
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
ax.grid(axis='y', alpha=0.3)
# 호버 annotation 설정
annot = ax.annotate(
"", xy=(0, 0), xytext=(15, 15),
textcoords="offset points",
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
color="white", fontsize=9,
arrowprops=dict(arrowstyle="->", color="#888"),
)
annot.set_visible(False)
def on_hover(event):
if event.inaxes != ax:
if annot.get_visible():
annot.set_visible(False)
widget._canvas.draw_idle()
return
for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')):
for i, bar in enumerate(bars):
if bar.contains(event)[0]:
h = hours[i]; ot = overtimes[i]
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
if ot > 0:
text += f"\n연장 +{ot:.1f}h"
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
annot.set_text(text)
annot.set_visible(True)
widget._canvas.draw_idle()
return
if annot.get_visible():
annot.set_visible(False)
widget._canvas.draw_idle()
widget._canvas.mpl_connect("motion_notify_event", on_hover)
widget._canvas.draw()
def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
"""출근 시각 분포 히스토그램 (30분 빈)."""
if not getattr(widget, '_figure', None):
return
fig = widget._figure
fig.clear()
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
widget._canvas.draw()
return
# 출근 시각을 분 단위로 (00:00=0)
minutes_list = []
for r in records:
ci = r.get('clock_in')
if not ci:
continue
parts = ci.split(':')
if len(parts) >= 2:
try:
minutes_list.append(int(parts[0]) * 60 + int(parts[1]))
except ValueError:
pass
if not minutes_list:
return
# 30분 빈
bin_size = 30
min_m = (min(minutes_list) // bin_size) * bin_size
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
bins = list(range(min_m, max_m + bin_size, bin_size))
ax = fig.add_subplot(111)
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
avg = sum(minutes_list) / len(minutes_list)
ax.axvline(avg, color='#ff6b6b', linestyle='--',
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
ax.set_xticks([m for m in bins if m % 60 == 0])
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
rotation=45, fontsize=8)
ax.set_ylabel('일수')
ax.set_title('출근 시각 분포')
ax.legend(loc='upper right', fontsize=8)
ax.grid(axis='y', alpha=0.3)
widget._canvas.draw()
+80 -1
View File
@@ -17,6 +17,58 @@ class NotificationOrchestrator:
self.notifier = window.notifier
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
def maybe_send_weekly_report(self, now: datetime) -> None:
"""월요일 첫 update_display 호출 시 지난주 요약 발송 (시스템 + Discord).
notification_log로 중복 가드. 월요일이 아니거나 이미 보냈으면 no-op.
"""
if now.weekday() != 0: # 0=월요일
return
if self.db.has_notification_today('system', 'weekly_report'):
return
# 지난주 데이터 (월~일)
from datetime import timedelta as _td
last_mon = now.date() - _td(days=7)
last_sun = now.date() - _td(days=1)
records = self.db.get_work_records_by_range(last_mon.isoformat(), last_sun.isoformat())
closed = [r for r in records if r.get('clock_out')]
if not closed:
return # 지난주 기록 없음
total_h = sum((r.get('total_hours') or 0) for r in closed)
ot_total = sum((r.get('overtime_minutes') or 0) for r in closed)
ot_h, ot_m = ot_total // 60, ot_total % 60
avg_h = total_h / len(closed) if closed else 0
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
title = "📊 지난주 요약"
body = (f"기간: {last_mon} ~ {last_sun}\n"
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
f"일 평균: {avg_h:.1f}시간\n"
f"연장근무: {ot_h}시간 {ot_m}분\n"
f"가장 긴 날: {longest_str}")
self.notifier.notification_signal.emit(title, body)
self.db.log_notification('system', 'weekly_report')
# Discord 도 옵션 활성 시 push
if self.db.get_setting('discord_notif_clock_out', 'true').lower() == 'true':
url = self.db.get_setting('discord_webhook_url', '') or ''
if url:
try:
from utils.discord_webhook import send, COLOR_BLUE
fields = [
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
{"name": "가장 긴 날", "value": longest_str, "inline": False},
]
ok = send(url, "📊 지난주 요약",
f"기간: {last_mon} ~ {last_sun}",
color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception:
pass
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
@@ -26,15 +78,42 @@ class NotificationOrchestrator:
if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute
# 월요일 첫 출근 시 지난주 리포트
self.maybe_send_weekly_report(now)
# 휴식 권고 (장시간 연속 근무)
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)
consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3:
n.notify_health_warning(consecutive)
self._discord_health(consecutive, break_minutes, now)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52:
n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200:
n.notify_overtime_threshold(balance_minutes / 60.0)
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
"""건강 경고 Discord push (옵션)."""
if self.db.has_notification_today('discord', 'health'):
return
if self.db.get_setting('discord_notif_health', 'true').lower() != 'true':
return
url = self.db.get_setting('discord_webhook_url', '') or ''
if not url:
return
try:
from utils import discord_webhook
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
ok = discord_webhook.send_health_warning(url, elapsed)
self.db.log_notification('discord', 'health', success=ok)
except Exception:
pass
+100
View File
@@ -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
View File
@@ -51,18 +51,31 @@ class HelpView(QDialog):
button_layout = QHBoxLayout()
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()
close_button = QPushButton(tr('btn.close'))
close_button.setObjectName("btn_primary")
close_button.setMinimumHeight(40)
close_button.setMinimumWidth(120)
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
button_layout.addStretch()
main_layout.addLayout(button_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:
container = QWidget()
layout = QVBoxLayout()
+112
View File
@@ -0,0 +1,112 @@
"""
연차 사용 캘린더 시각화.
QCalendarWidget에 사용 연차일을 색칠로 표시.
- 1.0일: 진한 색
- 0.5일(반차): 중간 색
- 0.25일(반반차): 옅은 색
"""
from __future__ import annotations
from datetime import datetime
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from ui.styles import apply_dark_titlebar
class LeaveCalendarView(QDialog):
"""연차 캘린더 시각화."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("📅 연차 캘린더")
self.setModal(True)
self.setMinimumSize(540, 480)
self._build_ui()
self._mark_dates()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 헤더: 잔여 + 범례
header = QHBoxLayout()
balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
layout.addLayout(header)
# 범례
legend = QHBoxLayout()
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
# 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
layout.addWidget(self.calendar, 1)
# 선택 일자 정보
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
layout.addWidget(self.detail_label)
# 닫기 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
def _mark_dates(self):
"""연차 사용 일자에 색상 표시."""
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
d = datetime.strptime(r['date'], '%Y-%m-%d').date()
except (ValueError, TypeError):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))
self.calendar.setDateTextFormat(qd, fmt)
def _on_date_click(self, qdate):
date_str = qdate.toString('yyyy-MM-dd')
records = self.db.get_all_leave_records(limit=365)
match = [r for r in records if r['date'] == date_str]
if not match:
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
return
parts = []
for r in match:
t = r.get('leave_type', 'annual')
d = float(r.get('days') or 0)
memo = r.get('memo') or ''
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
+10
View File
@@ -80,6 +80,11 @@ class LeaveView(QDialog):
add_leave_button = QPushButton("➕ 연차 사용 추가")
add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button)
cal_button = QPushButton("📅 캘린더 보기")
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
close_button = QPushButton("닫기")
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
@@ -87,6 +92,11 @@ class LeaveView(QDialog):
self.setLayout(layout)
def _show_calendar(self):
from ui.leave_calendar_view import LeaveCalendarView
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
+210 -17
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QProgressBar,
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
QShortcut)
QShortcut, QDialog)
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
@@ -59,6 +59,13 @@ class MainWindow(QMainWindow):
from core.i18n import set_language
set_language(self.db.get_setting(LANGUAGE, 'ko') or 'ko')
# 접근성 — 글꼴 크기 + 고대비
try:
from ui.accessibility import apply_from_settings as _apply_a11y
_apply_a11y(self.db)
except Exception:
pass
# TimeCalculator 초기화 (설정값 반영)
settings = self.db.get_settings()
@@ -219,6 +226,11 @@ class MainWindow(QMainWindow):
self.date_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.date_label)
# 1.5 오늘 요약 카드 (퇴근 후 표시, 평소엔 숨김)
from ui.today_summary import TodaySummaryCard
self.today_summary_card = TodaySummaryCard()
main_layout.addWidget(self.today_summary_card)
# 2. 출근 정보 그룹
clock_in_group = self.create_clock_in_group()
main_layout.addWidget(clock_in_group)
@@ -240,10 +252,20 @@ class MainWindow(QMainWindow):
self.lunch_button = QPushButton(tr('btn.lunch_add'))
self.lunch_button.setCheckable(True)
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.setCheckable(True)
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.dinner_button)
@@ -356,7 +378,11 @@ class MainWindow(QMainWindow):
self.clock_in_value = QLabel("--:--:--")
self.clock_in_value.setObjectName("time_value")
self.clock_in_value.setMinimumWidth(90)
self.edit_clock_in_button = QPushButton("수정")
# 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
self.clock_in_value.setCursor(Qt.PointingHandCursor)
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
self.edit_clock_in_button = QPushButton("✏️ 수정")
self.edit_clock_in_button.setObjectName("btn_small")
self.edit_clock_in_button.setFixedWidth(70)
self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
@@ -1081,18 +1107,21 @@ class MainWindow(QMainWindow):
# 오늘의 외출 시간 가져오기
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:
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
work_minutes = int(total_hours * 60)
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:
work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외
work_minutes -= break_minutes # 외출시간 제외
# 음수 방지
work_minutes -= self.time_calc.dinner_duration_minutes
work_minutes -= break_minutes
work_minutes = max(0, work_minutes)
# 30분 단위로 절삭
overtime_earned = (work_minutes // 30) * 30
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
overtime_actual = work_minutes
else:
# 평일: 정상 연장근무 계산 (외출 시간 포함)
@@ -1100,9 +1129,27 @@ class MainWindow(QMainWindow):
self.clock_in_time, now,
include_lunch=self.lunch_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 업데이트
today = datetime.now().date().isoformat()
clock_out_str = now.strftime("%H:%M:%S")
@@ -1148,6 +1195,12 @@ class MainWindow(QMainWindow):
# 잔액 업데이트
self.update_overtime_balance()
# Discord 웹훅 push (옵션)
self._discord_push_clock_out(now, total_hours, overtime_actual, overtime_earned)
# 오늘 요약 카드 표시
self._show_today_summary(total_hours, overtime_actual, overtime_earned, break_minutes)
def cancel_clock_out(self):
"""퇴근 취소"""
# 확인 대화상자
@@ -1558,6 +1611,13 @@ class MainWindow(QMainWindow):
f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}"
)
# Discord 웹훅 (옵션)
self._discord_push_clock_in(selected_time)
# 오늘 요약 카드 숨김 (새 출근 시작)
if hasattr(self, 'today_summary_card'):
self.today_summary_card.hide()
def show_stats(self):
"""통계 창 표시"""
dialog = StatsView(self, self.db)
@@ -1598,19 +1658,145 @@ class MainWindow(QMainWindow):
dialog = HelpView(self)
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):
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
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 not silent:
QMessageBox.information(
self,
"업데이트 확인",
f"현재 최신 버전입니다 (v{__version__})."
)
if silent:
return
# 사용자가 명시적으로 트리거한 경우만 메시지 표시
messages = {
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
# 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만
@@ -1825,6 +2011,13 @@ class MainWindow(QMainWindow):
# auto_lunch 캐시 무효화 (설정에서 토글 가능하므로)
self._auto_lunch.invalidate()
# 접근성 재적용 (글꼴 / 고대비)
try:
from ui.accessibility import apply_from_settings as _apply_a11y
_apply_a11y(self.db)
except Exception:
pass
# UI 업데이트
self.update_overtime_balance()
self.update_leave_balance()
+108
View File
@@ -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
+322
View File
@@ -0,0 +1,322 @@
"""
첫 실행 온보딩 위저드.
5단계: 환영 → 근무패턴 → 출근감지 → 연차/시급 → Discord(옵션) → 완료
첫 실행 시 강제 표시. 완료 시 ONBOARDING_COMPLETED=true 저장.
재방문은 메인 메뉴 → "온보딩 다시 보기"로 가능.
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
QLabel, QRadioButton, QButtonGroup, QSpinBox,
QCheckBox, QLineEdit, QComboBox, QPushButton,
QMessageBox, QGroupBox)
from PyQt5.QtCore import Qt
from ui.styles import apply_dark_titlebar
# (label, work_minutes, lunch_minutes)
WORK_PRESETS = [
("표준 8시간 (점심 60분)", 480, 60),
("단축근무 7시간 30분 (점심 30분)", 450, 30),
("단축근무 7시간 (점심 60분)", 420, 60),
("단축근무 6시간 (점심 30분)", 360, 30),
("반일 4시간 (점심 0분)", 240, 0),
]
class WelcomePage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("👋 환영합니다!")
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
layout = QVBoxLayout()
intro = QLabel(
"이 앱은:\n"
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
"• 30분 단위 연장근무 적립\n"
"• 연차·반차·외출 시간 추적\n"
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
"[다음] 버튼을 눌러 시작하세요."
)
intro.setWordWrap(True)
layout.addWidget(intro)
self.setLayout(layout)
class WorkPatternPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🕘 근무 패턴")
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
layout = QVBoxLayout()
self.button_group = QButtonGroup(self)
for i, (label, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(label)
self.button_group.addButton(rb, i)
layout.addWidget(rb)
if i == 0:
rb.setChecked(True)
# 사용자 정의
custom_box = QGroupBox("사용자 정의")
custom_layout = QHBoxLayout()
self.custom_radio = QRadioButton("직접 입력:")
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
self.hours_spin = QSpinBox()
self.hours_spin.setRange(0, 12)
self.hours_spin.setValue(8)
self.hours_spin.setSuffix(" 시간")
self.minutes_spin = QSpinBox()
self.minutes_spin.setRange(0, 59)
self.minutes_spin.setSingleStep(15)
self.minutes_spin.setSuffix(" 분")
self.lunch_spin = QSpinBox()
self.lunch_spin.setRange(0, 120)
self.lunch_spin.setSingleStep(5)
self.lunch_spin.setValue(60)
self.lunch_spin.setPrefix("점심 ")
self.lunch_spin.setSuffix(" 분")
custom_layout.addWidget(self.custom_radio)
custom_layout.addWidget(self.hours_spin)
custom_layout.addWidget(self.minutes_spin)
custom_layout.addWidget(self.lunch_spin)
custom_layout.addStretch()
custom_box.setLayout(custom_layout)
layout.addWidget(custom_box)
self.setLayout(layout)
def selected_minutes(self):
idx = self.button_group.checkedId()
if 0 <= idx < len(WORK_PRESETS):
_, wm, lm = WORK_PRESETS[idx]
return wm, lm
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
class ClockInDetectionPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("⏰ 출근 시간 감지 방식")
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
layout = QVBoxLayout()
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
self.option_boot.setChecked(True)
for opt in (self.option_boot, self.option_unlock, self.option_manual):
layout.addWidget(opt)
info = QLabel(
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
self.setLayout(layout)
def detection_mode(self) -> str:
if self.option_unlock.isChecked():
return 'unlock'
if self.option_manual.isChecked():
return 'manual'
return 'boot'
class LeaveSalaryPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
layout = QVBoxLayout()
# 연차
leave_box = QGroupBox("연간 연차")
leave_layout = QHBoxLayout()
self.leave_spin = QSpinBox()
self.leave_spin.setRange(0, 30)
self.leave_spin.setValue(15)
self.leave_spin.setSuffix(" 일")
leave_layout.addWidget(QLabel("내 연차:"))
leave_layout.addWidget(self.leave_spin)
leave_layout.addStretch()
leave_box.setLayout(leave_layout)
layout.addWidget(leave_box)
# 급여 (옵션)
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
salary_layout = QVBoxLayout()
self.salary_enabled = QCheckBox("급여 추정 활성화")
salary_layout.addWidget(self.salary_enabled)
wage_row = QHBoxLayout()
wage_row.addWidget(QLabel("시급:"))
self.wage_spin = QSpinBox()
self.wage_spin.setRange(0, 1000000)
self.wage_spin.setSingleStep(1000)
self.wage_spin.setSuffix(" 원/시간")
self.wage_spin.setValue(0)
self.wage_spin.setEnabled(False)
wage_row.addWidget(self.wage_spin)
wage_row.addStretch()
salary_layout.addLayout(wage_row)
rate_row = QHBoxLayout()
rate_row.addWidget(QLabel("연장수당 가산률:"))
self.rate_combo = QComboBox()
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
self.rate_combo.setCurrentIndex(1)
self.rate_combo.setEnabled(False)
rate_row.addWidget(self.rate_combo)
rate_row.addStretch()
salary_layout.addLayout(rate_row)
self.salary_enabled.toggled.connect(self.wage_spin.setEnabled)
self.salary_enabled.toggled.connect(self.rate_combo.setEnabled)
salary_box.setLayout(salary_layout)
layout.addWidget(salary_box)
layout.addStretch()
self.setLayout(layout)
class DiscordPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("💬 Discord 알림 (선택)")
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
layout = QVBoxLayout()
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
layout.addWidget(self.enable_check)
self.url_edit = QLineEdit()
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
self.url_edit.setEnabled(False)
layout.addWidget(self.url_edit)
guide = QLabel(
"셋업 방법:\n"
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
guide.setStyleSheet("color: #888; padding: 6px;")
guide.setWordWrap(True)
layout.addWidget(guide)
test_row = QHBoxLayout()
self.test_btn = QPushButton("테스트 메시지 보내기")
self.test_btn.setEnabled(False)
self.test_btn.clicked.connect(self._test_webhook)
test_row.addWidget(self.test_btn)
test_row.addStretch()
layout.addLayout(test_row)
self.enable_check.toggled.connect(self.url_edit.setEnabled)
self.enable_check.toggled.connect(self.test_btn.setEnabled)
layout.addStretch()
self.setLayout(layout)
def _test_webhook(self):
url = self.url_edit.text().strip()
if not url:
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
return
from utils import discord_webhook
ok = discord_webhook.send_test(url)
if ok:
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
else:
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
class FinishPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🎉 준비 완료!")
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
layout = QVBoxLayout()
msg = QLabel(
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
"🕐 단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
" • Ctrl+, — 설정"
)
msg.setWordWrap(True)
layout.addWidget(msg)
self.setLayout(layout)
class OnboardingWizard(QWizard):
"""5단계 첫 실행 위저드. accept() 시 모든 설정 저장 + ONBOARDING_COMPLETED=true."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("Clock-out Calculator — 시작 설정")
self.setMinimumSize(600, 500)
self.setWizardStyle(QWizard.ModernStyle)
self.setOption(QWizard.NoBackButtonOnStartPage, True)
self.welcome_page = WelcomePage()
self.work_page = WorkPatternPage()
self.detect_page = ClockInDetectionPage()
self.leave_page = LeaveSalaryPage()
self.discord_page = DiscordPage()
self.finish_page = FinishPage()
for page in (self.welcome_page, self.work_page, self.detect_page,
self.leave_page, self.discord_page, self.finish_page):
self.addPage(page)
apply_dark_titlebar(self)
def accept(self):
# 1. 근무 패턴
wm, lm = self.work_page.selected_minutes()
if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
return
settings = {
'work_minutes': wm,
'lunch_duration_minutes': lm,
'annual_leave_days': self.leave_page.leave_spin.value(),
'annual_leave_total': self.leave_page.leave_spin.value(),
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
'hourly_wage': self.leave_page.wage_spin.value(),
'overtime_rate': self.leave_page.rate_combo.currentData(),
'onboarding_completed': True,
}
# 2. 출근 감지 방식
mode = self.detect_page.detection_mode()
settings['clock_in_on_unlock'] = (mode == 'unlock')
# 3. Discord 웹훅 (옵션)
if self.discord_page.enable_check.isChecked():
settings['discord_webhook_url'] = self.discord_page.url_edit.text().strip()
self.db.save_settings(settings)
super().accept()
def maybe_show_onboarding(db, parent=None) -> bool:
"""ONBOARDING_COMPLETED=false 일 때만 위저드 표시. 사용자가 끝까지 완료하면 True."""
if db.get_setting('onboarding_completed', 'false').lower() == 'true':
return False # 이미 완료
wizard = OnboardingWizard(db, parent)
return wizard.exec_() == QWizard.Accepted
+111
View File
@@ -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
+200
View File
@@ -18,9 +18,13 @@ from core.settings_keys import (
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
NOTIFICATION_BEFORE_MINUTES,
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY,
GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED,
FONT_SCALE, HIGH_CONTRAST,
)
from utils.csv_exporter import CSVExporter
from ui.leave_view import AddLeaveDialog
@@ -85,6 +89,10 @@ class SettingsView(QDialog):
leave_group = self.create_leave_group()
layout.addWidget(leave_group)
# 목표 설정 그룹
goal_group = self.create_goal_group()
layout.addWidget(goal_group)
# 공휴일 설정
holiday_group = self.create_holiday_group()
layout.addWidget(holiday_group)
@@ -275,6 +283,22 @@ class SettingsView(QDialog):
check_row2.addWidget(self.health_notification_check)
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()
time_format_label = QLabel("시간 형식:")
@@ -300,6 +324,22 @@ class SettingsView(QDialog):
format_row.addStretch()
layout.addLayout(format_row)
# 접근성: 글꼴 크기 + 고대비
a11y_row = QHBoxLayout()
a11y_row.addWidget(QLabel("글꼴 크기:"))
self.font_scale_combo = QComboBox()
self.font_scale_combo.addItem("100%", "1.0")
self.font_scale_combo.addItem("125%", "1.25")
self.font_scale_combo.addItem("150%", "1.5")
self.font_scale_combo.setFixedWidth(90)
a11y_row.addWidget(self.font_scale_combo)
a11y_row.addSpacing(16)
self.high_contrast_check = QCheckBox("고대비 모드")
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
a11y_row.addWidget(self.high_contrast_check)
a11y_row.addStretch()
layout.addLayout(a11y_row)
# 언어 선택
from core.i18n import available_languages, language_label
lang_row = QHBoxLayout()
@@ -381,6 +421,51 @@ class SettingsView(QDialog):
group.setLayout(layout)
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:
"""휴가 설정 그룹"""
group = QGroupBox(tr('group.leave'))
@@ -673,6 +758,19 @@ class SettingsView(QDialog):
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_path_layout = QHBoxLayout()
db_path_label = QLabel("DB 경로:")
@@ -694,6 +792,22 @@ class SettingsView(QDialog):
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
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를 안 끄는 사용자용)
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
self.clock_in_unlock_check.setToolTip(
@@ -791,11 +905,41 @@ class SettingsView(QDialog):
if hasattr(self, 'clock_in_unlock_check'):
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)
)
# 접근성
if hasattr(self, 'font_scale_combo'):
scale = str(settings.get(FONT_SCALE, '1.0'))
idx = self.font_scale_combo.findData(scale)
if idx >= 0:
self.font_scale_combo.setCurrentIndex(idx)
if hasattr(self, 'high_contrast_check'):
self.high_contrast_check.setChecked(settings.get(HIGH_CONTRAST, False))
# 알림
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, 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')
@@ -840,6 +984,49 @@ class SettingsView(QDialog):
# 남은 연차 계산
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):
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
@@ -879,6 +1066,7 @@ class SettingsView(QDialog):
NOTIF_LUNCH: self.lunch_notification_check.isChecked(),
NOTIF_OVERTIME: self.overtime_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(),
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
@@ -888,6 +1076,18 @@ class SettingsView(QDialog):
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
if hasattr(self, 'clock_in_unlock_check'):
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, 'font_scale_combo'):
settings[FONT_SCALE] = self.font_scale_combo.currentData()
if hasattr(self, 'high_contrast_check'):
settings[HIGH_CONTRAST] = self.high_contrast_check.isChecked()
if hasattr(self, 'language_combo'):
settings[LANGUAGE] = self.language_combo.currentData()
+52 -1
View File
@@ -99,6 +99,11 @@ class StatsView(QDialog):
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
# 추정 급여 카드 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
self.salary_label.setVisible(False)
summary_group = QGroupBox(tr('stats.monthly_summary'))
summary_layout = QGridLayout()
summary_layout.setSpacing(4)
@@ -124,6 +129,12 @@ class StatsView(QDialog):
summary_group.setLayout(summary_layout)
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
@@ -154,7 +165,11 @@ class StatsView(QDialog):
pattern_group.setLayout(pattern_layout)
layout.addWidget(pattern_group)
layout.addStretch()
# 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(self.clock_in_chart, 1)
widget.setLayout(layout)
return widget
@@ -206,11 +221,47 @@ class StatsView(QDialog):
if hasattr(self, 'monthly_chart'):
draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', []))
# 추정 급여 (옵션 활성 시)
self._update_salary_estimate(monthly_stats.get('records', []))
# 목표 진행률
if hasattr(self, 'goal_widget'):
self.goal_widget.refresh()
# 패턴 분석
self.analyze_patterns(monthly_stats.get('records', []))
def _update_salary_estimate(self, records):
"""월간 추정 급여 표시 (SALARY_ENABLED=true 일 때만)."""
if not hasattr(self, 'salary_label'):
return
if self.db.get_setting('salary_enabled', 'false').lower() != 'true':
self.salary_label.setVisible(False)
return
try:
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
except (ValueError, TypeError):
self.salary_label.setVisible(False)
return
if wage <= 0:
self.salary_label.setVisible(False)
return
from core.salary import estimate_pay, format_won
result = estimate_pay(records, wage, rate)
self.salary_label.setText(
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
)
self.salary_label.setVisible(True)
def analyze_patterns(self, records):
"""패턴 분석"""
# 출근 분포 차트는 데이터 유무와 무관하게 갱신 (빈 차트 표시)
if hasattr(self, 'clock_in_chart'):
from ui.chart_widget import draw_clock_in_distribution
draw_clock_in_distribution(self.clock_in_chart, records or [])
if not records:
self.pattern_text.setText(tr('stats.no_data'))
return
+89
View File
@@ -0,0 +1,89 @@
"""
퇴근 후 표시되는 "오늘 요약" 카드 위젯.
다음 출근 시 자동 숨김. 메인 화면 상단에 conditional하게 표시.
"""
from __future__ import annotations
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt5.QtCore import Qt
class TodaySummaryCard(QFrame):
"""퇴근 처리 직후 표시되는 요약 카드."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("today_summary_card")
self.setStyleSheet("""
QFrame#today_summary_card {
background-color: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.4);
border-radius: 8px;
padding: 6px;
}
QLabel { padding: 1px; }
""")
self.setVisible(False)
layout = QVBoxLayout()
layout.setContentsMargins(10, 6, 10, 6)
layout.setSpacing(2)
header = QHBoxLayout()
title = QLabel("📋 오늘의 요약")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
close_btn = QPushButton("✕")
close_btn.setFixedSize(20, 20)
close_btn.setStyleSheet("border: none; font-weight: bold;")
close_btn.clicked.connect(self.hide)
header.addWidget(close_btn)
layout.addLayout(header)
self.total_label = QLabel("")
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
layout.addWidget(self.total_label)
layout.addWidget(self.detail_label)
layout.addWidget(self.salary_label)
self.setLayout(layout)
def show_summary(self, total_hours: float, lunch_minutes: int,
break_minutes: int, overtime_actual: int,
overtime_earned: int, salary_text: str = "") -> None:
"""카드 내용 채우고 표시.
Args:
total_hours: 총 근무시간(시간)
lunch_minutes: 점심 시간(분)
break_minutes: 외출 시간(분)
overtime_actual: 실제 연장근무(분)
overtime_earned: 적립 연장근무(분)
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
"""
h = int(total_hours)
m = int((total_hours - h) * 60)
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
details = []
if lunch_minutes > 0:
details.append(f"점심 {lunch_minutes}분")
if break_minutes > 0:
details.append(f"외출 {break_minutes}분")
if overtime_actual > 0:
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
self.detail_label.setText(" · ".join(details) if details else "")
self.detail_label.setVisible(bool(details))
if salary_text:
self.salary_label.setText(f"💰 {salary_text}")
self.salary_label.setVisible(True)
else:
self.salary_label.setVisible(False)
self.setVisible(True)
+173
View File
@@ -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
+157
View File
@@ -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
+115
View File
@@ -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
View File
@@ -77,37 +77,55 @@ def is_newer(remote: str, local: str) -> bool:
return _parse_version(remote) > _parse_version(local)
def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]:
"""GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None.
# 업데이트 체크 결과 상수 (info=None 일 때 reason 식별)
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:
req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError):
return None
except urllib.error.HTTPError as e:
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', '')
if not tag or not is_newer(tag, current_version):
return None
if not tag:
return None, NO_RELEASE
if not is_newer(tag, current_version):
return None, UP_TO_DATE
asset_url = None
for asset in data.get('assets', []):
if asset.get('name') == ASSET_NAME:
asset_url = asset.get('browser_download_url')
break
if not asset_url:
return None
return None, NO_ASSET
return ReleaseInfo(
info = ReleaseInfo(
version=tag,
asset_url=asset_url,
notes=data.get('body', ''),
published_at=data.get('published_at', ''),
)
return info, None
def download_update(asset_url: str, dest_dir: Optional[Path] = None,