Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3db4ed2351 | ||
|
|
97dd4e39f7 | ||
|
|
47296dd35b | ||
|
|
c98ca361cd | ||
|
|
d41e5cb921 | ||
|
|
c5df37ca57 |
+138
@@ -4,6 +4,144 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [2.10.1] — 2026-05-01
|
||||||
|
|
||||||
|
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
|
||||||
|
- **`updater.spec`**: `console=True` → `console=False` (windowed 빌드).
|
||||||
|
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
|
||||||
|
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
|
||||||
|
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
|
||||||
|
에 타임스탬프 + 결과 기록.
|
||||||
|
- **`updater.py launch()`**: `subprocess.Popen` 에 `CREATE_NO_WINDOW` 플래그 추가
|
||||||
|
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
|
||||||
|
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
|
||||||
|
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
|
||||||
|
|
||||||
|
## [2.10.0] — 2026-05-01
|
||||||
|
|
||||||
|
### Added — 정부 공휴일 API 자동 동기화
|
||||||
|
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
|
||||||
|
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
|
||||||
|
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
|
||||||
|
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
|
||||||
|
- 키는 dev 본인 계정의 특일정보 API 한정 키
|
||||||
|
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
|
||||||
|
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
|
||||||
|
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
|
||||||
|
- sentinel: `settings['holidays_synced_date']`
|
||||||
|
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
|
||||||
|
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
|
||||||
|
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
|
||||||
|
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
|
||||||
|
- pytest: 175 → **189**
|
||||||
|
|
||||||
|
### 주의
|
||||||
|
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
|
||||||
|
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
|
||||||
|
|
||||||
|
## [2.9.0] — 2026-05-01
|
||||||
|
|
||||||
|
### Fixed — 휴일 hot-path 버그 (사용자 보고)
|
||||||
|
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
|
||||||
|
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
|
||||||
|
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
|
||||||
|
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
|
||||||
|
- 진행바: 휴일은 100% 고정 (의미 없음)
|
||||||
|
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
|
||||||
|
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
|
||||||
|
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
|
||||||
|
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
|
||||||
|
|
||||||
|
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
|
||||||
|
|
||||||
|
#### Phase 1 — 연차 미리등록 + 자동 적용
|
||||||
|
- **DB:** `get_leave_minutes_for(date)` / `has_full_day_leave(date)` /
|
||||||
|
`get_leave_records_by_date(date)` / `get_leave_records_by_range(start, end)`
|
||||||
|
- **TimeCalculator:** `effective_work_minutes(date_obj, db)` — 부분 연차만큼 정규 근무 차감
|
||||||
|
- **종일 연차일 자동 처리:**
|
||||||
|
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
|
||||||
|
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
|
||||||
|
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
|
||||||
|
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
|
||||||
|
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
|
||||||
|
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
|
||||||
|
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
|
||||||
|
|
||||||
|
#### Phase 2 — 통합 스케줄 + 반복 연차
|
||||||
|
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
|
||||||
|
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
|
||||||
|
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
|
||||||
|
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
|
||||||
|
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
|
||||||
|
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **근로자의 날(5/1) 자동 추가** — `holidays.KR` 패키지가 누락하는 노동자 휴일을
|
||||||
|
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- pytest: 122 → **175** (+53)
|
||||||
|
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
|
||||||
|
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
|
||||||
|
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
|
||||||
|
- 통합 시나리오: 48 → **53** (+5)
|
||||||
|
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
|
||||||
|
|
||||||
|
## [2.8.0] — 2026-05-01
|
||||||
|
|
||||||
|
### Added — 도전과제 시스템 + 디자인 리뉴얼
|
||||||
|
- **🏆 도전과제 시스템** (153개 자동 평가) — 출근·퇴근·연장·연차·식사·외출·계절·시간대·시크릿 등 16개 카테고리.
|
||||||
|
- `core/achievements.py`: `Achievement` dataclass + `evaluate_all(db)` + `sync_definitions_to_db(db)`.
|
||||||
|
- 5분 throttle로 자동 평가, 신규 잠금 해제 시 시스템 알림 + Discord embed push.
|
||||||
|
- `achievements` 테이블 확장: `code`(UNIQUE), `category`, `tier`, `is_secret`, `progress`, `target`, `created_at` 컬럼 추가 (idempotent 마이그레이션).
|
||||||
|
- 5단계 등급: 🥉 브론즈 / 🥈 실버 / 🥇 골드 / 💎 플래티넘 / 🌟 레전드.
|
||||||
|
- 시크릿 9개 (회문, 잭팟 시각, 13일의 금요일, 7-7-7, 정확 8시간, π day, 피보나치 등).
|
||||||
|
- 메타 도전과제 (도전과제 자체 달성 카운트).
|
||||||
|
- **`ui/achievements_view.py`** — 4탭 다이얼로그 (전체 / 진행 중 / 완료 / 시크릿).
|
||||||
|
- 등급별 그라디언트 카드, 진행 게이지, 카테고리 태그, 시크릿 ❓ 처리.
|
||||||
|
- **자동 hire_date 추적** — 첫 `add_work_record` 호출 시 settings에 자동 기록 (1주년, 365일 후 출근 등 도전과제 활성화).
|
||||||
|
- **뷰 진입 카운터** — `stat_*_view_count`, `calendar_view_count`, `daily_report_count` 등 8개 settings 키. 도전과제 + 사용 통계용.
|
||||||
|
|
||||||
|
### Changed — 다크 테마 디자인 리뉴얼
|
||||||
|
- **`ui/dark_components.py`** 신설 — 재사용 가능한 다크 디자인 컴포넌트:
|
||||||
|
- `dialog_qss()`, `tabs_qss()`, `scroll_qss()`, `button_qss(variant)`.
|
||||||
|
- `build_gradient_header()`, `build_stat_card()`, `build_section_card()`.
|
||||||
|
- `style_progressbar()`, `transparent_label()` — 글로벌 QSS 충돌 회피.
|
||||||
|
- 카드 테마 7종: blue / cyan / green / gold / pink / red / gray.
|
||||||
|
- **`ui/stats_view.py`** — 4분할 카드 + 차트 섹션 카드. 골드 강조 탭. ghost 닫기 버튼.
|
||||||
|
- **`ui/help_view.py`** — 다크 톤 + HelpHTML 내부에 다크 CSS 주입 (h1/h2/h3, code, blockquote, table 컬러).
|
||||||
|
- **`ui/chart_widget.py`** — 모든 matplotlib 차트 다크 테마 적용 (figure/axes facecolor, grid, ticks, legend).
|
||||||
|
- **온보딩 위저드** — 저녁 분(minutes) 입력 옵션 + i18n화 (Korean/English 프리셋 라벨).
|
||||||
|
|
||||||
|
### Fixed — 안정성·일관성
|
||||||
|
- **타임존 자정 경계 버그**: `has_notification_today`가 `CURRENT_TIMESTAMP`(UTC) vs `DATE('now', 'localtime')` mismatch로 KST 0~9시 사이 알림 중복 발송 가능 — `DATE(sent_at, 'localtime')` 양쪽 적용.
|
||||||
|
- **DB 연결 누수 가드** — `_conn()` 컨텍스트 매니저 도입, 40+ 메서드 변환 (직접 `get_connection()` 호출 0건).
|
||||||
|
- **DB 이중 부트스트랩 제거** — `MainWindow(db=None)` 옵션 인자 추가, main.py가 만든 db를 재사용.
|
||||||
|
- **crash_handler 폴백** — DB 로깅/다이얼로그 단계 분리, 모두 실패해도 `~/.clockout_logs/crashes.log`에 기록.
|
||||||
|
- **updater PID race window** — 지수 backoff 재시도 (0.3→4.8s, 총 ~9초). Windows Defender 락 해제 대기 충분.
|
||||||
|
- **Discord URL 형식 검증** — Snowflake ID 17~20자리 + 50+자 토큰 정규식.
|
||||||
|
- **MealTimeDialog** — 출근 시각 범위 검증 + 야간 출근자 자정 경계 자동 처리.
|
||||||
|
- **CSV importer/exporter** — `dinner_minutes` 컬럼 round-trip 지원.
|
||||||
|
- **일일 보고서** — 저녁 섹션 추가 (이전엔 점심만 표시), `break_type` 분리, 자정 경계 처리.
|
||||||
|
- **저녁 알림** — `check_dinner_reminder()` 신규 (점심 알림과 대칭).
|
||||||
|
- **알림 임계값 설정화** — 5개 하드코딩 값(점심/저녁 알림 시간, 연장 누적, 주간 한도, 연속 야근)을 settings로 노출.
|
||||||
|
- **closeEvent 정리** — `aboutToQuit` 시그널로 timer/notifier/tray 정리.
|
||||||
|
- **마이그레이션 일관성** — 12개 마이그레이션 모두 `_conn()` + try/finally 보장.
|
||||||
|
|
||||||
|
### DB 인덱스 (성능)
|
||||||
|
- `idx_break_records_date_type`, `idx_break_records_date`.
|
||||||
|
- `idx_overtime_bank_date`, `idx_overtime_usage_date`, `idx_leave_records_date`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- pytest 116개 PASS, 통합 시나리오 44/48 PASS (PyQt 환경 4개 제외).
|
||||||
|
- 도전과제 한 사이클 시나리오 검증 (30일 시뮬레이션 → 19개 자동 잠금 해제).
|
||||||
|
|
||||||
## [2.7.0] — 2026-04-30
|
## [2.7.0] — 2026-04-30
|
||||||
|
|
||||||
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
|
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ $env:GITEA_TOKEN = '<PAT>'
|
|||||||
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
|
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
|
||||||
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
|
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
|
||||||
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
|
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
|
||||||
|
- **[meal_controller.py](ui/controllers/meal_controller.py)** — Lunch/dinner toggle + label refresh, extracted from `main_window.py` in v2.7.0. Same controller pattern as Lock/AutoLunch/Notification.
|
||||||
|
|
||||||
|
### ui/ (cross-cutting)
|
||||||
|
- **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
|
||||||
|
- **[styles.py](ui/styles.py)** — Shared QSS / color tokens.
|
||||||
|
|
||||||
### utils/
|
### utils/
|
||||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
|
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
|
||||||
@@ -74,6 +79,8 @@ $env:GITEA_TOKEN = '<PAT>'
|
|||||||
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
|
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
|
||||||
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
|
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
|
||||||
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
|
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
|
||||||
|
- **[csv_exporter.py](utils/csv_exporter.py)** — Same standard format as importer. Round-trips with `csv_importer`.
|
||||||
|
- **[resource_manager.py](utils/resource_manager.py)** — PyInstaller `_MEIPASS`-aware path resolver for icons / assets.
|
||||||
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
|
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
|
||||||
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
|
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
|
||||||
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
|
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
|
||||||
@@ -137,7 +144,9 @@ Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via
|
|||||||
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
|
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
|
||||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
|
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
|
||||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
|
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
|
||||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+).
|
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
|
||||||
|
|
||||||
|
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
|
||||||
|
|
||||||
All should be green before any release.
|
All should be green before any release.
|
||||||
|
|
||||||
|
|||||||
@@ -696,6 +696,74 @@ def s51_accessibility_keys():
|
|||||||
assert db.get_setting_bool('high_contrast') is True
|
assert db.get_setting_bool('high_contrast') is True
|
||||||
|
|
||||||
|
|
||||||
|
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
|
||||||
|
def s52b_planned_leave():
|
||||||
|
db = fresh_db('s52b')
|
||||||
|
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||||
|
assert db.has_full_day_leave('2026-05-15')
|
||||||
|
assert db.get_leave_minutes_for('2026-05-15') == 480
|
||||||
|
# 다른 날엔 영향 없음
|
||||||
|
assert not db.has_full_day_leave('2026-05-16')
|
||||||
|
|
||||||
|
|
||||||
|
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
|
||||||
|
def s52c_recurring_leave():
|
||||||
|
db = fresh_db('s52c')
|
||||||
|
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||||
|
# 2026-05-01 = Friday
|
||||||
|
assert db.get_leave_minutes_for('2026-05-01') == 240
|
||||||
|
# Monday
|
||||||
|
assert db.get_leave_minutes_for('2026-05-04') == 0
|
||||||
|
# 종일 아님
|
||||||
|
assert not db.has_full_day_leave('2026-05-01')
|
||||||
|
|
||||||
|
|
||||||
|
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
|
||||||
|
def s52d_effective():
|
||||||
|
from core.time_calculator import TimeCalculator
|
||||||
|
db = fresh_db('s52d')
|
||||||
|
db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||||
|
calc = TimeCalculator(work_minutes=480)
|
||||||
|
target = datetime(2026, 5, 15)
|
||||||
|
assert calc.effective_work_minutes(target, db) == 240
|
||||||
|
# 다른 날엔 변화 없음
|
||||||
|
other = datetime(2026, 5, 16)
|
||||||
|
assert calc.effective_work_minutes(other, db) == 480
|
||||||
|
|
||||||
|
|
||||||
|
@case("S52E. effective_work_minutes: 종일 연차 시 0")
|
||||||
|
def s52e_full_day():
|
||||||
|
from core.time_calculator import TimeCalculator
|
||||||
|
db = fresh_db('s52e')
|
||||||
|
db.add_leave_record('2026-05-15', '연차', 1.0)
|
||||||
|
calc = TimeCalculator(work_minutes=480)
|
||||||
|
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
|
||||||
|
def s52a_holiday_hotpath():
|
||||||
|
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
|
||||||
|
from core.time_calculator import TimeCalculator
|
||||||
|
db = fresh_db('s52a')
|
||||||
|
holiday_date = '2026-05-01' # 근로자의 날
|
||||||
|
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
|
||||||
|
|
||||||
|
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
# 휴일 인식
|
||||||
|
assert calc.is_non_working_day(ci, db)
|
||||||
|
assert calc.get_day_type(ci, db) == 'holiday'
|
||||||
|
|
||||||
|
# 출근 1분 후: 적립 0 (30분 단위 절삭)
|
||||||
|
now1 = ci + timedelta(minutes=1)
|
||||||
|
actual, earned = calc.calculate_holiday_overtime(ci, now1)
|
||||||
|
assert actual == 1 and earned == 0
|
||||||
|
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
|
||||||
|
now30 = ci + timedelta(minutes=30)
|
||||||
|
actual, earned = calc.calculate_holiday_overtime(ci, now30)
|
||||||
|
assert actual == 30 and earned == 30
|
||||||
|
|
||||||
|
|
||||||
@case("S52. CSV import + overtime 적립까지 정상 동작")
|
@case("S52. CSV import + overtime 적립까지 정상 동작")
|
||||||
def s52_csv_overtime():
|
def s52_csv_overtime():
|
||||||
from utils.csv_importer import parse_csv, import_records
|
from utils.csv_importer import parse_csv, import_records
|
||||||
@@ -764,6 +832,11 @@ def main():
|
|||||||
s49_discord_empty()
|
s49_discord_empty()
|
||||||
s50_goals()
|
s50_goals()
|
||||||
s51_accessibility_keys()
|
s51_accessibility_keys()
|
||||||
|
s52a_holiday_hotpath()
|
||||||
|
s52b_planned_leave()
|
||||||
|
s52c_recurring_leave()
|
||||||
|
s52d_effective()
|
||||||
|
s52e_full_day()
|
||||||
s52_csv_overtime()
|
s52_csv_overtime()
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1072
-992
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,24 @@ _DICT = {
|
|||||||
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
|
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
|
||||||
'notif.lunch_reminder.title': '🍱 점심시간 등록',
|
'notif.lunch_reminder.title': '🍱 점심시간 등록',
|
||||||
'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?',
|
'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?',
|
||||||
|
'notif.dinner_reminder.title': '🍽️ 저녁시간 등록',
|
||||||
|
'notif.dinner_reminder.body': '저녁시간을 등록하지 않으셨네요.\n저녁시간을 추가하시겠어요?',
|
||||||
|
|
||||||
|
# === 온보딩 근무 패턴 프리셋 ===
|
||||||
|
'onboarding.preset.standard_8h': '표준 8시간 (점심 60분)',
|
||||||
|
'onboarding.preset.short_7h30m': '단축근무 7시간 30분 (점심 30분)',
|
||||||
|
'onboarding.preset.short_7h': '단축근무 7시간 (점심 60분)',
|
||||||
|
'onboarding.preset.short_6h': '단축근무 6시간 (점심 30분)',
|
||||||
|
'onboarding.preset.half_4h': '반일 4시간 (점심 0분)',
|
||||||
|
'onboarding.preset.custom_box': '사용자 정의',
|
||||||
|
'onboarding.preset.custom_radio': '직접 입력:',
|
||||||
|
'onboarding.preset.suffix_hours': ' 시간',
|
||||||
|
'onboarding.preset.suffix_minutes': ' 분',
|
||||||
|
'onboarding.preset.lunch_prefix': '점심 ',
|
||||||
|
'onboarding.preset.dinner_prefix': '저녁 ',
|
||||||
|
'onboarding.preset.dinner_tooltip': '야근으로 저녁 식사가 자주 발생하면 입력 (보통 0~60분)',
|
||||||
|
'notif.health_break.title': '🌿 휴식 권고',
|
||||||
|
'notif.health_break.body': '{hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.',
|
||||||
'notif.overtime_earning.title': '🔥 연장근무 적립 예정',
|
'notif.overtime_earning.title': '🔥 연장근무 적립 예정',
|
||||||
'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
|
'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
|
||||||
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
|
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
|
||||||
@@ -321,6 +339,24 @@ _DICT = {
|
|||||||
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
|
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
|
||||||
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
|
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
|
||||||
'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?",
|
'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?",
|
||||||
|
'notif.dinner_reminder.title': '🍽️ Dinner Reminder',
|
||||||
|
'notif.dinner_reminder.body': "You haven't registered dinner yet.\nWant to add it?",
|
||||||
|
|
||||||
|
# === Onboarding work pattern presets ===
|
||||||
|
'onboarding.preset.standard_8h': 'Standard 8h (Lunch 60min)',
|
||||||
|
'onboarding.preset.short_7h30m': 'Reduced 7h 30m (Lunch 30min)',
|
||||||
|
'onboarding.preset.short_7h': 'Reduced 7h (Lunch 60min)',
|
||||||
|
'onboarding.preset.short_6h': 'Reduced 6h (Lunch 30min)',
|
||||||
|
'onboarding.preset.half_4h': 'Half-day 4h (No Lunch)',
|
||||||
|
'onboarding.preset.custom_box': 'Custom',
|
||||||
|
'onboarding.preset.custom_radio': 'Manual entry:',
|
||||||
|
'onboarding.preset.suffix_hours': ' h',
|
||||||
|
'onboarding.preset.suffix_minutes': ' min',
|
||||||
|
'onboarding.preset.lunch_prefix': 'Lunch ',
|
||||||
|
'onboarding.preset.dinner_prefix': 'Dinner ',
|
||||||
|
'onboarding.preset.dinner_tooltip': 'Set if you often have dinner during overtime (typically 0~60 min)',
|
||||||
|
'notif.health_break.title': '🌿 Take a Break',
|
||||||
|
'notif.health_break.body': "You've been at your desk for over {hours} hours.\nStand up and stretch!",
|
||||||
'notif.overtime_earning.title': '🔥 Overtime Will Accrue',
|
'notif.overtime_earning.title': '🔥 Overtime Will Accrue',
|
||||||
'notif.overtime_earning.body': "{time_str} of overtime will be banked today!",
|
'notif.overtime_earning.body': "{time_str} of overtime will be banked today!",
|
||||||
'notif.overtime_threshold.title': '💰 Overtime Balance High',
|
'notif.overtime_threshold.title': '💰 Overtime Balance High',
|
||||||
|
|||||||
+58
-37
@@ -7,11 +7,25 @@ from typing import Optional
|
|||||||
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
|
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
|
||||||
|
|
||||||
from core.settings_keys import (
|
from core.settings_keys import (
|
||||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||||
|
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
|
||||||
|
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
|
||||||
|
OVERTIME_UNIT,
|
||||||
)
|
)
|
||||||
from core.i18n import tr
|
from core.i18n import tr
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int_setting(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||||
|
"""db에서 정수 설정값을 안전하게 읽어 [lo, hi]로 클램프."""
|
||||||
|
if db is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
v = int(db.get_setting(key, str(default)) or default)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
v = default
|
||||||
|
return max(lo, min(hi, v))
|
||||||
|
|
||||||
|
|
||||||
class Notifier(QObject):
|
class Notifier(QObject):
|
||||||
"""알림 시스템 클래스"""
|
"""알림 시스템 클래스"""
|
||||||
|
|
||||||
@@ -27,6 +41,7 @@ class Notifier(QObject):
|
|||||||
# 알림 상태 추적
|
# 알림 상태 추적
|
||||||
self.notified_30min = False
|
self.notified_30min = False
|
||||||
self.notified_lunch = False
|
self.notified_lunch = False
|
||||||
|
self.notified_dinner = False
|
||||||
self.notified_overtime = False
|
self.notified_overtime = False
|
||||||
self.notified_health = False
|
self.notified_health = False
|
||||||
self.notified_weekly = False
|
self.notified_weekly = False
|
||||||
@@ -85,46 +100,52 @@ class Notifier(QObject):
|
|||||||
|
|
||||||
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
|
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
|
||||||
current_time: Optional[datetime] = None):
|
current_time: Optional[datetime] = None):
|
||||||
"""
|
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
|
||||||
점심시간 등록 알림
|
|
||||||
Args:
|
|
||||||
clock_in_time: 출근 시간
|
|
||||||
lunch_enabled: 점심시간 등록 여부
|
|
||||||
current_time: 현재 시간
|
|
||||||
"""
|
|
||||||
if current_time is None:
|
if current_time is None:
|
||||||
current_time = datetime.now()
|
current_time = datetime.now()
|
||||||
if not self._enabled(NOTIF_LUNCH):
|
if not self._enabled(NOTIF_LUNCH):
|
||||||
return
|
return
|
||||||
|
|
||||||
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
|
|
||||||
if lunch_enabled or self.notified_lunch:
|
if lunch_enabled or self.notified_lunch:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 출근 후 4시간 경과 (점심시간으로 추정)
|
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
|
||||||
time_since_clock_in = current_time - clock_in_time
|
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||||
if time_since_clock_in.total_seconds() >= 4 * 3600:
|
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.lunch_reminder.title'),
|
tr('notif.lunch_reminder.title'),
|
||||||
tr('notif.lunch_reminder.body'),
|
tr('notif.lunch_reminder.body'),
|
||||||
)
|
)
|
||||||
self.notified_lunch = True
|
self.notified_lunch = True
|
||||||
|
|
||||||
|
def check_dinner_reminder(self, clock_in_time: datetime, dinner_enabled: bool,
|
||||||
|
current_time: Optional[datetime] = None):
|
||||||
|
"""저녁시간 등록 알림. 출근 후 DINNER_REMINDER_HOURS 경과 시.
|
||||||
|
|
||||||
|
야근(연장근무) 사용자가 저녁을 깜빡 잊는 패턴 대응.
|
||||||
|
"""
|
||||||
|
if current_time is None:
|
||||||
|
current_time = datetime.now()
|
||||||
|
if not self._enabled(NOTIF_DINNER):
|
||||||
|
return
|
||||||
|
if dinner_enabled or self.notified_dinner:
|
||||||
|
return
|
||||||
|
|
||||||
|
threshold_hours = _get_int_setting(self.db, DINNER_REMINDER_HOURS, 8, 1, 16)
|
||||||
|
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||||
|
self.notification_signal.emit(
|
||||||
|
tr('notif.dinner_reminder.title'),
|
||||||
|
tr('notif.dinner_reminder.body'),
|
||||||
|
)
|
||||||
|
self.notified_dinner = True
|
||||||
|
|
||||||
def check_overtime_earning(self, overtime_minutes: int):
|
def check_overtime_earning(self, overtime_minutes: int):
|
||||||
"""
|
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
|
||||||
연장근무 적립 알림
|
|
||||||
Args:
|
|
||||||
overtime_minutes: 예상 연장근무 시간 (분)
|
|
||||||
"""
|
|
||||||
if not self._enabled(NOTIF_OVERTIME):
|
if not self._enabled(NOTIF_OVERTIME):
|
||||||
return
|
return
|
||||||
if overtime_minutes >= 30 and not self.notified_overtime:
|
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
|
||||||
hours = overtime_minutes // 60
|
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
|
||||||
mins = overtime_minutes % 60
|
if overtime_minutes >= unit and not self.notified_overtime:
|
||||||
|
|
||||||
from utils.time_format import format_hours_minutes
|
from utils.time_format import format_hours_minutes
|
||||||
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
|
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
|
||||||
|
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.overtime_earning.title'),
|
tr('notif.overtime_earning.title'),
|
||||||
tr('notif.overtime_earning.body', time_str=time_str),
|
tr('notif.overtime_earning.body', time_str=time_str),
|
||||||
@@ -132,10 +153,11 @@ class Notifier(QObject):
|
|||||||
self.notified_overtime = True
|
self.notified_overtime = True
|
||||||
|
|
||||||
def notify_overtime_threshold(self, total_overtime_hours: float):
|
def notify_overtime_threshold(self, total_overtime_hours: float):
|
||||||
"""연장근무 누적 알림 (20시간 이상)"""
|
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
|
||||||
if not self._enabled(NOTIF_OVERTIME):
|
if not self._enabled(NOTIF_OVERTIME):
|
||||||
return
|
return
|
||||||
if total_overtime_hours >= 20 and not self.notified_threshold:
|
threshold = _get_int_setting(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||||
|
if total_overtime_hours >= threshold and not self.notified_threshold:
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.overtime_threshold.title'),
|
tr('notif.overtime_threshold.title'),
|
||||||
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
|
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
|
||||||
@@ -143,10 +165,11 @@ class Notifier(QObject):
|
|||||||
self.notified_threshold = True
|
self.notified_threshold = True
|
||||||
|
|
||||||
def notify_health_warning(self, consecutive_overtime_days: int):
|
def notify_health_warning(self, consecutive_overtime_days: int):
|
||||||
"""건강 경고 (연속 연장근무 일수)"""
|
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
|
||||||
if not self._enabled(NOTIF_HEALTH):
|
if not self._enabled(NOTIF_HEALTH):
|
||||||
return
|
return
|
||||||
if consecutive_overtime_days >= 3 and not self.notified_health:
|
threshold = _get_int_setting(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||||
|
if consecutive_overtime_days >= threshold and not self.notified_health:
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.health.title'),
|
tr('notif.health.title'),
|
||||||
tr('notif.health.body', days=consecutive_overtime_days),
|
tr('notif.health.body', days=consecutive_overtime_days),
|
||||||
@@ -154,10 +177,11 @@ class Notifier(QObject):
|
|||||||
self.notified_health = True
|
self.notified_health = True
|
||||||
|
|
||||||
def notify_weekly_hours(self, total_hours: float):
|
def notify_weekly_hours(self, total_hours: float):
|
||||||
"""주 52시간 경고"""
|
"""주 X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
|
||||||
if not self._enabled(NOTIF_HEALTH):
|
if not self._enabled(NOTIF_HEALTH):
|
||||||
return
|
return
|
||||||
if total_hours > 52 and not self.notified_weekly:
|
threshold = _get_int_setting(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||||
|
if total_hours > threshold and not self.notified_weekly:
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.weekly_52.title'),
|
tr('notif.weekly_52.title'),
|
||||||
tr('notif.weekly_52.body', hours=total_hours),
|
tr('notif.weekly_52.body', hours=total_hours),
|
||||||
@@ -167,7 +191,7 @@ class Notifier(QObject):
|
|||||||
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
|
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
|
||||||
"""장시간 연속 근무 휴식 알림.
|
"""장시간 연속 근무 휴식 알림.
|
||||||
|
|
||||||
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
|
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
|
||||||
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
|
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
|
||||||
"""
|
"""
|
||||||
if current_time is None:
|
if current_time is None:
|
||||||
@@ -178,16 +202,12 @@ class Notifier(QObject):
|
|||||||
return
|
return
|
||||||
if self.db.has_notification_today('system', 'health_break'):
|
if self.db.has_notification_today('system', 'health_break'):
|
||||||
return
|
return
|
||||||
try:
|
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
|
||||||
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
|
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
|
||||||
if elapsed_sec >= threshold_hours * 3600:
|
if elapsed_sec >= threshold_hours * 3600:
|
||||||
from core.i18n import tr
|
|
||||||
self.notification_signal.emit(
|
self.notification_signal.emit(
|
||||||
tr('notif.health_break.title') if False else "🌿 휴식 권고",
|
tr('notif.health_break.title'),
|
||||||
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
|
tr('notif.health_break.body', hours=threshold_hours),
|
||||||
)
|
)
|
||||||
self.db.log_notification('system', 'health_break')
|
self.db.log_notification('system', 'health_break')
|
||||||
|
|
||||||
@@ -195,6 +215,7 @@ class Notifier(QObject):
|
|||||||
"""알림 상태 리셋 (날짜 변경 시)"""
|
"""알림 상태 리셋 (날짜 변경 시)"""
|
||||||
self.notified_30min = False
|
self.notified_30min = False
|
||||||
self.notified_lunch = False
|
self.notified_lunch = False
|
||||||
|
self.notified_dinner = False
|
||||||
self.notified_overtime = False
|
self.notified_overtime = False
|
||||||
self.notified_health = False
|
self.notified_health = False
|
||||||
self.notified_weekly = False
|
self.notified_weekly = False
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
반복 연차 패턴 파싱 + 일자 확장.
|
||||||
|
|
||||||
|
지원 패턴:
|
||||||
|
- 'weekly:friday' — 매주 금요일
|
||||||
|
- 'weekly:mon,wed,fri' — 매주 월·수·금
|
||||||
|
- 'biweekly:friday' — 격주 금요일 (start_date 기준)
|
||||||
|
- 'monthly:15' — 매월 15일 (해당 월에 그 일이 없으면 스킵)
|
||||||
|
|
||||||
|
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range()로 펼친다.
|
||||||
|
같은 날짜에 leave_records(구체 인스턴스)가 이미 있으면 호출자가 합산 로직 책임.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
_WEEKDAY_MAP = {
|
||||||
|
'mon': 0, 'monday': 0,
|
||||||
|
'tue': 1, 'tuesday': 1,
|
||||||
|
'wed': 2, 'wednesday': 2,
|
||||||
|
'thu': 3, 'thursday': 3,
|
||||||
|
'fri': 4, 'friday': 4,
|
||||||
|
'sat': 5, 'saturday': 5,
|
||||||
|
'sun': 6, 'sunday': 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Occurrence:
|
||||||
|
"""반복 패턴이 펼친 한 인스턴스."""
|
||||||
|
date: date
|
||||||
|
leave_type: str
|
||||||
|
days: float
|
||||||
|
pattern: str
|
||||||
|
memo: str = ''
|
||||||
|
recurring_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pattern(pattern: str):
|
||||||
|
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
|
||||||
|
if not pattern or ':' not in pattern:
|
||||||
|
return None
|
||||||
|
kind, rest = pattern.split(':', 1)
|
||||||
|
kind = kind.strip().lower()
|
||||||
|
rest = rest.strip().lower()
|
||||||
|
if kind in ('weekly', 'biweekly'):
|
||||||
|
days = [d.strip() for d in rest.split(',') if d.strip()]
|
||||||
|
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
|
||||||
|
if not weekdays:
|
||||||
|
return None
|
||||||
|
return (kind, weekdays)
|
||||||
|
if kind == 'monthly':
|
||||||
|
try:
|
||||||
|
day_of_month = int(rest)
|
||||||
|
if 1 <= day_of_month <= 31:
|
||||||
|
return (kind, day_of_month)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def matches(rec: Dict, target_date: date) -> bool:
|
||||||
|
"""단일 패턴 rec이 target_date에 매치되는지."""
|
||||||
|
start = _parse_date(rec.get('start_date'))
|
||||||
|
end = _parse_date(rec.get('end_date'))
|
||||||
|
if start is None:
|
||||||
|
return False
|
||||||
|
if target_date < start:
|
||||||
|
return False
|
||||||
|
if end is not None and target_date > end:
|
||||||
|
return False
|
||||||
|
|
||||||
|
parsed = _parse_pattern(rec.get('pattern', ''))
|
||||||
|
if parsed is None:
|
||||||
|
return False
|
||||||
|
kind, info = parsed
|
||||||
|
|
||||||
|
if kind == 'weekly':
|
||||||
|
return target_date.weekday() in info
|
||||||
|
|
||||||
|
if kind == 'biweekly':
|
||||||
|
if target_date.weekday() not in info:
|
||||||
|
return False
|
||||||
|
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
|
||||||
|
weeks = (target_date - start).days // 7
|
||||||
|
return weeks % 2 == 0
|
||||||
|
|
||||||
|
if kind == 'monthly':
|
||||||
|
return target_date.day == info
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
|
||||||
|
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
|
||||||
|
|
||||||
|
반환은 날짜 오름차순. 같은 날 여러 패턴이 매치되면 모두 포함.
|
||||||
|
"""
|
||||||
|
out: List[Occurrence] = []
|
||||||
|
if start > end:
|
||||||
|
return out
|
||||||
|
cur = start
|
||||||
|
while cur <= end:
|
||||||
|
for r in records:
|
||||||
|
try:
|
||||||
|
if matches(r, cur):
|
||||||
|
out.append(Occurrence(
|
||||||
|
date=cur,
|
||||||
|
leave_type=r.get('leave_type') or '연차',
|
||||||
|
days=float(r.get('days') or 0),
|
||||||
|
pattern=r.get('pattern', ''),
|
||||||
|
memo=r.get('memo') or '',
|
||||||
|
recurring_id=r.get('id'),
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
# 잘못된 패턴 1개가 전체를 망치지 않도록
|
||||||
|
continue
|
||||||
|
cur += timedelta(days=1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
|
||||||
|
"""단일 날짜에 매치되는 인스턴스만."""
|
||||||
|
return expand_for_range(records, target_date, target_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
|
|
||||||
|
|
||||||
|
def describe_pattern(pattern: str) -> str:
|
||||||
|
"""사용자에게 보여줄 패턴 설명. ko."""
|
||||||
|
parsed = _parse_pattern(pattern)
|
||||||
|
if parsed is None:
|
||||||
|
return pattern
|
||||||
|
kind, info = parsed
|
||||||
|
if kind in ('weekly', 'biweekly'):
|
||||||
|
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
||||||
|
prefix = '매주' if kind == 'weekly' else '격주'
|
||||||
|
return f"{prefix} {','.join(names)}요일"
|
||||||
|
if kind == 'monthly':
|
||||||
|
return f"매월 {info}일"
|
||||||
|
return pattern
|
||||||
@@ -22,9 +22,17 @@ CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로
|
|||||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||||
NOTIF_LUNCH = 'notification_lunch'
|
NOTIF_LUNCH = 'notification_lunch'
|
||||||
|
NOTIF_DINNER = 'notification_dinner'
|
||||||
NOTIF_OVERTIME = 'notification_overtime'
|
NOTIF_OVERTIME = 'notification_overtime'
|
||||||
NOTIF_HEALTH = 'notification_health'
|
NOTIF_HEALTH = 'notification_health'
|
||||||
|
|
||||||
|
# 알림 임계값 (설정화 — 이전엔 하드코딩이던 것)
|
||||||
|
LUNCH_REMINDER_HOURS = 'lunch_reminder_hours' # 출근 후 N시간 경과 시 점심 미등록 알림 (기본 4)
|
||||||
|
DINNER_REMINDER_HOURS = 'dinner_reminder_hours' # 출근 후 N시간 경과 시 저녁 미등록 알림 (기본 8)
|
||||||
|
OVERTIME_THRESHOLD_HOURS = 'overtime_threshold_hours' # 누적 적립 알림 시간 (기본 20)
|
||||||
|
WEEKLY_HOURS_THRESHOLD = 'weekly_hours_threshold' # 주 X시간 경고 (기본 52, 한국 노동법)
|
||||||
|
HEALTH_CONSECUTIVE_OT_DAYS = 'health_consecutive_ot_days' # 연속 연장근무 일수 경고 (기본 3)
|
||||||
|
|
||||||
# 연차
|
# 연차
|
||||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||||
@@ -76,3 +84,22 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
|
|||||||
# 마이그레이션 sentinel
|
# 마이그레이션 sentinel
|
||||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||||
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
||||||
|
|
||||||
|
# === v2.8.0 도전과제 시스템 ===
|
||||||
|
# 사용자 메타
|
||||||
|
BIRTHDAY = 'birthday' # MM-DD 형식, 빈 문자열이면 비활성
|
||||||
|
HIRE_DATE = 'hire_date' # YYYY-MM-DD, 첫 work_records 자동 기록
|
||||||
|
|
||||||
|
# 뷰 진입 카운터 (도전과제 + 사용 통계용)
|
||||||
|
STAT_WEEKLY_VIEW_COUNT = 'stat_weekly_view_count'
|
||||||
|
STAT_MONTHLY_VIEW_COUNT = 'stat_monthly_view_count'
|
||||||
|
STAT_PATTERN_VIEW_COUNT = 'stat_pattern_view_count'
|
||||||
|
CALENDAR_VIEW_COUNT = 'calendar_view_count'
|
||||||
|
LEAVE_CALENDAR_VIEW_COUNT = 'leave_calendar_view_count'
|
||||||
|
DAILY_REPORT_COUNT = 'daily_report_count'
|
||||||
|
ACHIEVEMENTS_VIEW_COUNT = 'achievements_view_count'
|
||||||
|
CHART_HOVER_DISCOVERED = 'chart_hover_discovered'
|
||||||
|
|
||||||
|
# 도전과제 알림
|
||||||
|
NOTIF_ACHIEVEMENT = 'notification_achievement'
|
||||||
|
DISCORD_NOTIF_ACHIEVEMENT = 'discord_notif_achievement'
|
||||||
|
|||||||
@@ -236,6 +236,55 @@ class TimeCalculator:
|
|||||||
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
||||||
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
||||||
|
|
||||||
|
def effective_work_minutes(self, date_obj: datetime, db) -> int:
|
||||||
|
"""해당 날짜의 실효 근무 시간(분).
|
||||||
|
|
||||||
|
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
|
||||||
|
종일 연차(>= work_minutes)면 0 반환.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_obj: 기준 날짜 (datetime; 시각은 무시)
|
||||||
|
db: Database 인스턴스 (None이면 차감 없음)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
실효 work_minutes (>= 0)
|
||||||
|
"""
|
||||||
|
if db is None:
|
||||||
|
return self.work_minutes
|
||||||
|
date_str = date_obj.strftime("%Y-%m-%d")
|
||||||
|
leave_min = db.get_leave_minutes_for(date_str)
|
||||||
|
return max(0, self.work_minutes - leave_min)
|
||||||
|
|
||||||
|
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
|
||||||
|
include_lunch: bool = False,
|
||||||
|
include_dinner: bool = False,
|
||||||
|
break_minutes: int = 0,
|
||||||
|
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
휴일/주말 근무: 모든 시간을 연장근무로 계산.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
clock_in: 출근 시간
|
||||||
|
current_time: 현재(또는 퇴근) 시간
|
||||||
|
include_lunch/dinner: 식사 시간 차감 여부
|
||||||
|
break_minutes: 외출 시간 (분) — 연장근무에서 제외
|
||||||
|
unit_minutes: 적립 단위 (15/30/60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(실제 연장 분, 적립 분) — 둘 다 0 이상.
|
||||||
|
"""
|
||||||
|
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
|
||||||
|
if include_lunch:
|
||||||
|
elapsed_minutes -= self.lunch_duration_minutes
|
||||||
|
if include_dinner:
|
||||||
|
elapsed_minutes -= self.dinner_duration_minutes
|
||||||
|
elapsed_minutes -= break_minutes
|
||||||
|
elapsed_minutes = max(0, elapsed_minutes)
|
||||||
|
|
||||||
|
unit = unit_minutes if unit_minutes > 0 else 30
|
||||||
|
earned = (elapsed_minutes // unit) * unit
|
||||||
|
return elapsed_minutes, earned
|
||||||
|
|
||||||
def is_weekend(self, date_obj: datetime) -> bool:
|
def is_weekend(self, date_obj: datetime) -> bool:
|
||||||
"""
|
"""
|
||||||
주말 여부 확인
|
주말 여부 확인
|
||||||
|
|||||||
+1
-1
@@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.7.0'
|
__version__ = '2.10.1'
|
||||||
|
|||||||
@@ -145,9 +145,9 @@ def main():
|
|||||||
from utils.debug_log import dlog
|
from utils.debug_log import dlog
|
||||||
dlog(f"onboarding skipped: {e}")
|
dlog(f"onboarding skipped: {e}")
|
||||||
|
|
||||||
# 메인 윈도우 생성 및 표시
|
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
|
||||||
try:
|
try:
|
||||||
window = MainWindow()
|
window = MainWindow(db=db)
|
||||||
|
|
||||||
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
|
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
|
||||||
def on_new_connection():
|
def on_new_connection():
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
pytest 공통 설정.
|
||||||
|
|
||||||
|
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||||
|
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||||
@@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
|
|||||||
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
||||||
overtime_minutes=120, overtime_earned=120)
|
overtime_minutes=120, overtime_earned=120)
|
||||||
assert fresh_db.get_consecutive_overtime_days() == 3
|
assert fresh_db.get_consecutive_overtime_days() == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestLeaveQueriesByDate:
|
||||||
|
def test_get_leave_minutes_for_no_records(self, fresh_db):
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
|
||||||
|
|
||||||
|
def test_full_day_leave_detected(self, fresh_db):
|
||||||
|
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||||
|
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||||
|
|
||||||
|
def test_half_day_not_full(self, fresh_db):
|
||||||
|
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
|
||||||
|
assert not fresh_db.has_full_day_leave('2026-05-15')
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
|
||||||
|
|
||||||
|
def test_two_halves_become_full(self, fresh_db):
|
||||||
|
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||||
|
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
|
||||||
|
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||||
|
|
||||||
|
def test_records_by_date(self, fresh_db):
|
||||||
|
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
|
||||||
|
recs = fresh_db.get_leave_records_by_date('2026-05-15')
|
||||||
|
assert len(recs) == 1
|
||||||
|
assert recs[0]['leave_type'] == '연차'
|
||||||
|
assert recs[0]['memo'] == '메모'
|
||||||
|
|
||||||
|
def test_records_by_range(self, fresh_db):
|
||||||
|
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
|
||||||
|
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
|
||||||
|
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
|
||||||
|
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
|
||||||
|
assert len(recs) == 2
|
||||||
|
# 날짜 정렬
|
||||||
|
assert recs[0]['date'] == '2026-05-01'
|
||||||
|
assert recs[1]['date'] == '2026-05-10'
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecurringLeavesDB:
|
||||||
|
def test_add_and_list(self, fresh_db):
|
||||||
|
rid = fresh_db.add_recurring_leave(
|
||||||
|
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
|
||||||
|
)
|
||||||
|
assert rid > 0
|
||||||
|
recs = fresh_db.get_recurring_leaves()
|
||||||
|
assert len(recs) == 1
|
||||||
|
assert recs[0]['pattern'] == 'weekly:friday'
|
||||||
|
assert recs[0]['memo'] == '단축'
|
||||||
|
|
||||||
|
def test_active_on_filter(self, fresh_db):
|
||||||
|
# 종료일이 지난 패턴
|
||||||
|
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||||
|
'2025-01-01', '2025-12-31')
|
||||||
|
# 아직 시작 안 한 패턴
|
||||||
|
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
|
||||||
|
'2027-01-01', None)
|
||||||
|
# 현재 활성 패턴
|
||||||
|
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
|
||||||
|
'2026-01-01', None)
|
||||||
|
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
|
||||||
|
assert len(active) == 1
|
||||||
|
assert active[0]['pattern'] == 'monthly:15'
|
||||||
|
|
||||||
|
def test_delete(self, fresh_db):
|
||||||
|
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||||
|
'2026-01-01')
|
||||||
|
assert len(fresh_db.get_recurring_leaves()) == 1
|
||||||
|
fresh_db.delete_recurring_leave(rid)
|
||||||
|
assert fresh_db.get_recurring_leaves() == []
|
||||||
|
|
||||||
|
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
|
||||||
|
# 매주 금요일 반차
|
||||||
|
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
|
||||||
|
'2026-01-01')
|
||||||
|
# 2026-05-01 = Friday → 240분
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
|
||||||
|
# 2026-05-04 = Monday → 0분
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
|
||||||
|
|
||||||
|
def test_concrete_plus_recurring_sum(self, fresh_db):
|
||||||
|
# 매주 금요일 반차 + 그날 별도 반반차 추가
|
||||||
|
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||||
|
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
|
||||||
|
# 0.5 + 0.25 = 0.75일 = 360분
|
||||||
|
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
|
||||||
|
assert not fresh_db.has_full_day_leave('2026-05-01')
|
||||||
|
|
||||||
|
def test_concrete_plus_recurring_full_day(self, fresh_db):
|
||||||
|
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||||
|
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
|
||||||
|
# 0.5 + 0.5 = 1.0일
|
||||||
|
assert fresh_db.has_full_day_leave('2026-05-01')
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class TestSendNetwork:
|
|||||||
resp.__exit__.return_value = False
|
resp.__exit__.return_value = False
|
||||||
mock_urlopen.return_value = resp
|
mock_urlopen.return_value = resp
|
||||||
|
|
||||||
ok = send('https://discord.com/api/webhooks/123/abc', 'title', 'desc')
|
ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc')
|
||||||
assert ok is True
|
assert ok is True
|
||||||
|
|
||||||
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
|
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
|
||||||
@@ -55,18 +55,18 @@ class TestSendNetwork:
|
|||||||
resp.__exit__.return_value = False
|
resp.__exit__.return_value = False
|
||||||
mock_urlopen.return_value = resp
|
mock_urlopen.return_value = resp
|
||||||
|
|
||||||
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
|
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||||
|
|
||||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||||
def test_network_error_returns_false(self, mock_urlopen):
|
def test_network_error_returns_false(self, mock_urlopen):
|
||||||
import urllib.error
|
import urllib.error
|
||||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||||
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
|
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||||
|
|
||||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||||
def test_timeout_returns_false(self, mock_urlopen):
|
def test_timeout_returns_false(self, mock_urlopen):
|
||||||
mock_urlopen.side_effect = TimeoutError('slow')
|
mock_urlopen.side_effect = TimeoutError('slow')
|
||||||
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
|
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||||
|
|
||||||
|
|
||||||
class TestPayloadShape:
|
class TestPayloadShape:
|
||||||
@@ -79,7 +79,7 @@ class TestPayloadShape:
|
|||||||
resp.__exit__.return_value = False
|
resp.__exit__.return_value = False
|
||||||
mock_urlopen.return_value = resp
|
mock_urlopen.return_value = resp
|
||||||
|
|
||||||
send('https://discord.com/api/webhooks/x', 'TITLE', 'DESC',
|
send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 'TITLE', 'DESC',
|
||||||
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
|
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
|
||||||
req = mock_urlopen.call_args[0][0]
|
req = mock_urlopen.call_args[0][0]
|
||||||
body = _json.loads(req.data.decode('utf-8'))
|
body = _json.loads(req.data.decode('utf-8'))
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
utils.holiday_api 단위 테스트.
|
||||||
|
|
||||||
|
실제 정부 API는 호출하지 않음 — 모두 urlopen mock.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from utils.holiday_api import (
|
||||||
|
fetch_korean_holidays, _parse_response, is_configured,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_response(items):
|
||||||
|
"""API 정상 응답 형식 빌드."""
|
||||||
|
return {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
|
||||||
|
'body': {
|
||||||
|
'items': {'item': items} if items else {'item': []},
|
||||||
|
'numOfRows': 100,
|
||||||
|
'pageNo': 1,
|
||||||
|
'totalCount': len(items) if isinstance(items, list) else 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseResponse:
|
||||||
|
def test_multiple_items(self):
|
||||||
|
items = [
|
||||||
|
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
|
||||||
|
'locdate': 20260501, 'seq': 1},
|
||||||
|
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
|
||||||
|
'locdate': 20260505, 'seq': 1},
|
||||||
|
]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 2
|
||||||
|
assert out[0]['date'] == '2026-05-01'
|
||||||
|
assert out[0]['name'] == '근로자의 날'
|
||||||
|
assert out[0]['is_holiday'] is True
|
||||||
|
assert out[1]['date'] == '2026-05-05'
|
||||||
|
|
||||||
|
def test_single_item_as_dict(self):
|
||||||
|
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
|
||||||
|
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
|
||||||
|
data = {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '00'},
|
||||||
|
'body': {'items': {'item': item}, 'totalCount': 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = _parse_response(data)
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]['name'] == '근로자의 날'
|
||||||
|
|
||||||
|
def test_empty_year(self):
|
||||||
|
# totalCount=0 같은 정상 빈 응답
|
||||||
|
data = {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '00'},
|
||||||
|
'body': {'items': '', 'totalCount': 0},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert _parse_response(data) == []
|
||||||
|
|
||||||
|
def test_error_result_code(self):
|
||||||
|
data = {
|
||||||
|
'response': {
|
||||||
|
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
|
||||||
|
'body': {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert _parse_response(data) is None
|
||||||
|
|
||||||
|
def test_isholiday_n_filtered_at_caller_level(self):
|
||||||
|
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
|
||||||
|
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
|
||||||
|
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]['is_holiday'] is False
|
||||||
|
|
||||||
|
def test_locdate_str_form(self):
|
||||||
|
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert out[0]['date'] == '2026-05-01'
|
||||||
|
|
||||||
|
def test_invalid_locdate_skipped(self):
|
||||||
|
items = [
|
||||||
|
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||||
|
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
|
||||||
|
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
|
||||||
|
]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 1 # 정상 1개만
|
||||||
|
assert out[0]['name'] == '근로자의 날'
|
||||||
|
|
||||||
|
def test_missing_required_fields_skipped(self):
|
||||||
|
items = [
|
||||||
|
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||||
|
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
|
||||||
|
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
|
||||||
|
]
|
||||||
|
out = _parse_response(_ok_response(items))
|
||||||
|
assert len(out) == 1
|
||||||
|
|
||||||
|
def test_malformed_response_returns_none(self):
|
||||||
|
# response 구조 자체가 깨진 경우
|
||||||
|
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
|
||||||
|
# 위는 implementation-dependent — 둘 다 합리적
|
||||||
|
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
|
||||||
|
assert _parse_response({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchNetwork:
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_success(self, mock_urlopen):
|
||||||
|
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
|
||||||
|
resp.__enter__.return_value = resp
|
||||||
|
resp.__exit__.return_value = False
|
||||||
|
mock_urlopen.return_value = resp
|
||||||
|
|
||||||
|
out = fetch_korean_holidays(2026)
|
||||||
|
assert out is not None
|
||||||
|
assert len(out) == 1
|
||||||
|
assert out[0]['date'] == '2026-05-01'
|
||||||
|
|
||||||
|
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
|
||||||
|
req = mock_urlopen.call_args[0][0]
|
||||||
|
assert 'serviceKey=' in req.full_url
|
||||||
|
assert 'solYear=2026' in req.full_url
|
||||||
|
assert '_type=json' in req.full_url
|
||||||
|
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_network_error_returns_none(self, mock_urlopen):
|
||||||
|
import urllib.error
|
||||||
|
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||||
|
assert fetch_korean_holidays(2026) is None
|
||||||
|
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_timeout_returns_none(self, mock_urlopen):
|
||||||
|
mock_urlopen.side_effect = TimeoutError('slow')
|
||||||
|
assert fetch_korean_holidays(2026) is None
|
||||||
|
|
||||||
|
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||||
|
def test_invalid_json_returns_none(self, mock_urlopen):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.read.return_value = b'<html>error</html>'
|
||||||
|
resp.__enter__.return_value = resp
|
||||||
|
resp.__exit__.return_value = False
|
||||||
|
mock_urlopen.return_value = resp
|
||||||
|
assert fetch_korean_holidays(2026) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigured:
|
||||||
|
def test_key_set(self):
|
||||||
|
assert is_configured() is True
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
core.recurring_leaves 단위 테스트.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from core.recurring_leaves import (
|
||||||
|
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePattern:
|
||||||
|
@pytest.mark.parametrize("pattern,expected_kind", [
|
||||||
|
('weekly:friday', 'weekly'),
|
||||||
|
('weekly:fri', 'weekly'),
|
||||||
|
('weekly:mon,wed,fri', 'weekly'),
|
||||||
|
('biweekly:friday', 'biweekly'),
|
||||||
|
('monthly:15', 'monthly'),
|
||||||
|
('monthly:1', 'monthly'),
|
||||||
|
])
|
||||||
|
def test_valid(self, pattern, expected_kind):
|
||||||
|
result = _parse_pattern(pattern)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0] == expected_kind
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pattern", [
|
||||||
|
'', 'weekly', 'weekly:', 'weekly:xyz',
|
||||||
|
'monthly:0', 'monthly:32', 'monthly:abc',
|
||||||
|
'unknown:fri', None,
|
||||||
|
])
|
||||||
|
def test_invalid(self, pattern):
|
||||||
|
assert _parse_pattern(pattern) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatches:
|
||||||
|
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
|
||||||
|
return {
|
||||||
|
'pattern': pattern,
|
||||||
|
'start_date': start,
|
||||||
|
'end_date': end,
|
||||||
|
'days': days,
|
||||||
|
'leave_type': leave_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_weekly_single_day(self):
|
||||||
|
rec = self._rec('weekly:friday')
|
||||||
|
assert matches(rec, date(2026, 5, 1)) # Fri
|
||||||
|
assert not matches(rec, date(2026, 5, 2)) # Sat
|
||||||
|
assert not matches(rec, date(2026, 5, 4)) # Mon
|
||||||
|
|
||||||
|
def test_weekly_multiple_days(self):
|
||||||
|
rec = self._rec('weekly:mon,wed,fri')
|
||||||
|
assert matches(rec, date(2026, 5, 4)) # Mon
|
||||||
|
assert matches(rec, date(2026, 5, 6)) # Wed
|
||||||
|
assert matches(rec, date(2026, 5, 8)) # Fri
|
||||||
|
assert not matches(rec, date(2026, 5, 5)) # Tue
|
||||||
|
assert not matches(rec, date(2026, 5, 7)) # Thu
|
||||||
|
|
||||||
|
def test_biweekly_alignment(self):
|
||||||
|
# start_date 2026-01-02 = Friday (week 0)
|
||||||
|
rec = self._rec('biweekly:friday', start='2026-01-02')
|
||||||
|
assert matches(rec, date(2026, 1, 2)) # week 0
|
||||||
|
assert not matches(rec, date(2026, 1, 9)) # week 1
|
||||||
|
assert matches(rec, date(2026, 1, 16)) # week 2
|
||||||
|
|
||||||
|
def test_monthly(self):
|
||||||
|
rec = self._rec('monthly:15')
|
||||||
|
assert matches(rec, date(2026, 1, 15))
|
||||||
|
assert matches(rec, date(2026, 5, 15))
|
||||||
|
assert not matches(rec, date(2026, 5, 14))
|
||||||
|
assert not matches(rec, date(2026, 5, 16))
|
||||||
|
|
||||||
|
def test_monthly_skipped_in_short_month(self):
|
||||||
|
# 31일은 30일 달에는 매치되지 않음
|
||||||
|
rec = self._rec('monthly:31')
|
||||||
|
assert matches(rec, date(2026, 1, 31))
|
||||||
|
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
|
||||||
|
|
||||||
|
def test_before_start(self):
|
||||||
|
rec = self._rec('weekly:friday', start='2026-05-01')
|
||||||
|
assert matches(rec, date(2026, 5, 1))
|
||||||
|
assert not matches(rec, date(2026, 4, 24)) # 시작 전
|
||||||
|
|
||||||
|
def test_after_end(self):
|
||||||
|
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
|
||||||
|
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
|
||||||
|
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
|
||||||
|
|
||||||
|
def test_no_end_means_forever(self):
|
||||||
|
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
|
||||||
|
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
|
||||||
|
|
||||||
|
def test_invalid_pattern_returns_false(self):
|
||||||
|
rec = self._rec('garbage:xyz')
|
||||||
|
assert not matches(rec, date(2026, 5, 1))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandRange:
|
||||||
|
def _rec(self, pattern, start='2026-01-01'):
|
||||||
|
return {
|
||||||
|
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
|
||||||
|
'days': 0.5, 'leave_type': '반차', 'memo': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_expand_weekly_one_month(self):
|
||||||
|
rec = self._rec('weekly:friday')
|
||||||
|
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||||
|
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
|
||||||
|
assert len(occs) == 5
|
||||||
|
assert all(o.date.weekday() == 4 for o in occs)
|
||||||
|
|
||||||
|
def test_expand_empty_when_outside(self):
|
||||||
|
rec = self._rec('weekly:friday', start='2027-01-01')
|
||||||
|
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||||
|
assert occs == []
|
||||||
|
|
||||||
|
def test_expand_invalid_range(self):
|
||||||
|
# start > end
|
||||||
|
rec = self._rec('weekly:friday')
|
||||||
|
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
|
||||||
|
assert occs == []
|
||||||
|
|
||||||
|
def test_expand_multiple_recs(self):
|
||||||
|
rec_fri = self._rec('weekly:friday')
|
||||||
|
rec_mon = self._rec('weekly:monday')
|
||||||
|
rec_mon['id'] = 2
|
||||||
|
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
|
||||||
|
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
|
||||||
|
assert len(occs) == 2
|
||||||
|
|
||||||
|
def test_expand_for_date_single(self):
|
||||||
|
rec = self._rec('monthly:15')
|
||||||
|
occs = expand_for_date([rec], date(2026, 5, 15))
|
||||||
|
assert len(occs) == 1
|
||||||
|
assert occs[0].date == date(2026, 5, 15)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescribePattern:
|
||||||
|
def test_weekly_korean(self):
|
||||||
|
assert '매주' in describe_pattern('weekly:friday')
|
||||||
|
assert '금' in describe_pattern('weekly:friday')
|
||||||
|
|
||||||
|
def test_biweekly(self):
|
||||||
|
assert '격주' in describe_pattern('biweekly:friday')
|
||||||
|
|
||||||
|
def test_monthly(self):
|
||||||
|
assert '매월' in describe_pattern('monthly:15')
|
||||||
|
assert '15' in describe_pattern('monthly:15')
|
||||||
@@ -98,3 +98,77 @@ class TestDayType:
|
|||||||
mon = datetime(2026, 5, 4)
|
mon = datetime(2026, 5, 4)
|
||||||
assert not calc.is_weekend(mon)
|
assert not calc.is_weekend(mon)
|
||||||
assert calc.get_day_type(mon) == 'normal'
|
assert calc.get_day_type(mon) == 'normal'
|
||||||
|
|
||||||
|
|
||||||
|
class TestHolidayOvertime:
|
||||||
|
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
|
||||||
|
|
||||||
|
def test_zero_elapsed_returns_zero(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
|
||||||
|
assert actual == 0 and earned == 0
|
||||||
|
|
||||||
|
def test_one_minute_elapsed_no_lunch(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(minutes=1)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||||
|
assert actual == 1
|
||||||
|
assert earned == 0 # 30분 단위 절삭
|
||||||
|
|
||||||
|
def test_30min_elapsed_truncates_to_30(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(minutes=30)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||||
|
assert actual == 30 and earned == 30
|
||||||
|
|
||||||
|
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(minutes=29)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||||
|
assert actual == 29 and earned == 0
|
||||||
|
|
||||||
|
def test_lunch_subtracted(self, calc_8h):
|
||||||
|
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(hours=9)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||||
|
ci, now, include_lunch=True
|
||||||
|
)
|
||||||
|
assert actual == 8 * 60
|
||||||
|
assert earned == 8 * 60
|
||||||
|
|
||||||
|
def test_break_minutes_subtracted(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(hours=2)
|
||||||
|
# 외출 30분 → 90분 적립
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||||
|
ci, now, break_minutes=30
|
||||||
|
)
|
||||||
|
assert actual == 90 and earned == 90
|
||||||
|
|
||||||
|
def test_unit_minutes_15(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(minutes=44)
|
||||||
|
# 44분 → 30분 적립 (15분 단위)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||||
|
ci, now, unit_minutes=15
|
||||||
|
)
|
||||||
|
assert actual == 44 and earned == 30
|
||||||
|
|
||||||
|
def test_unit_minutes_60(self, calc_8h):
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(minutes=119)
|
||||||
|
# 119분 → 60분 적립 (60분 단위)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||||
|
ci, now, unit_minutes=60
|
||||||
|
)
|
||||||
|
assert actual == 119 and earned == 60
|
||||||
|
|
||||||
|
def test_negative_clamped_to_zero(self, calc_8h):
|
||||||
|
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
|
||||||
|
ci = datetime(2026, 5, 1, 9, 0)
|
||||||
|
now = ci + timedelta(hours=1)
|
||||||
|
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||||
|
ci, now, include_lunch=True, include_dinner=True
|
||||||
|
)
|
||||||
|
assert actual == 0 and earned == 0
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""
|
||||||
|
도전과제 뷰 — 4탭 (전체 / 진행 중 / 완료 / 시크릿).
|
||||||
|
|
||||||
|
디자인 원칙:
|
||||||
|
- 카드 = 등급별 그라디언트 배경 + 외곽선 빛 (획득 시 강한 색)
|
||||||
|
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
|
||||||
|
- 진행 게이지 = 두꺼운 색상 막대 (등급 색)
|
||||||
|
- 카테고리 = 작은 인라인 태그
|
||||||
|
- 시크릿 미발견 = ❓ 처리
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QTabWidget, QWidget, QScrollArea,
|
||||||
|
QProgressBar, QFrame, QGridLayout, QSizePolicy)
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QFont
|
||||||
|
|
||||||
|
from core.achievements import get_all_with_status, get_stats
|
||||||
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
|
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||||
|
TIER_THEMES = {
|
||||||
|
'bronze': {
|
||||||
|
'border': '#cd7f32',
|
||||||
|
'border_strong': '#e09947',
|
||||||
|
'bg_top': '#3a2a18',
|
||||||
|
'bg_bot': '#241810',
|
||||||
|
'text': '#ffd9a8',
|
||||||
|
'label': '🥉',
|
||||||
|
'name': '브론즈',
|
||||||
|
},
|
||||||
|
'silver': {
|
||||||
|
'border': '#a8a8a8',
|
||||||
|
'border_strong': '#d0d0d0',
|
||||||
|
'bg_top': '#2e2e36',
|
||||||
|
'bg_bot': '#1c1c22',
|
||||||
|
'text': '#e8e8f0',
|
||||||
|
'label': '🥈',
|
||||||
|
'name': '실버',
|
||||||
|
},
|
||||||
|
'gold': {
|
||||||
|
'border': '#ffb700',
|
||||||
|
'border_strong': '#ffd24a',
|
||||||
|
'bg_top': '#3a2e10',
|
||||||
|
'bg_bot': '#241c08',
|
||||||
|
'text': '#ffe9a0',
|
||||||
|
'label': '🥇',
|
||||||
|
'name': '골드',
|
||||||
|
},
|
||||||
|
'platinum': {
|
||||||
|
'border': '#7fdbff',
|
||||||
|
'border_strong': '#a8e8ff',
|
||||||
|
'bg_top': '#1a3340',
|
||||||
|
'bg_bot': '#0e1f28',
|
||||||
|
'text': '#c5ecff',
|
||||||
|
'label': '💎',
|
||||||
|
'name': '플래티넘',
|
||||||
|
},
|
||||||
|
'legend': {
|
||||||
|
'border': '#ff6b9d',
|
||||||
|
'border_strong': '#ff90b8',
|
||||||
|
'bg_top': '#3a1a2a',
|
||||||
|
'bg_bot': '#26101a',
|
||||||
|
'text': '#ffc0d4',
|
||||||
|
'label': '🌟',
|
||||||
|
'name': '레전드',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
||||||
|
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
||||||
|
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
||||||
|
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
||||||
|
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
||||||
|
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
||||||
|
'ambition': '야망', 'meta': '메타',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AchievementsView(QDialog):
|
||||||
|
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||||
|
|
||||||
|
def __init__(self, db, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.db = db
|
||||||
|
self.setWindowTitle("🏆 도전과제")
|
||||||
|
self.setMinimumSize(960, 720)
|
||||||
|
self.resize(1100, 800)
|
||||||
|
self._increment_view_count()
|
||||||
|
self.setStyleSheet("QDialog { background: #0e0e14; }")
|
||||||
|
self.init_ui()
|
||||||
|
apply_dark_titlebar(self, dark=True)
|
||||||
|
|
||||||
|
def _increment_view_count(self) -> None:
|
||||||
|
try:
|
||||||
|
cur = self.db.get_setting_int('achievements_view_count', 0)
|
||||||
|
self.db.set_setting('achievements_view_count', str(cur + 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def init_ui(self) -> None:
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(20, 20, 20, 16)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
stats = get_stats(self.db)
|
||||||
|
|
||||||
|
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
|
||||||
|
layout.addWidget(self._build_header(stats))
|
||||||
|
|
||||||
|
# === 탭 ===
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
self.tabs.setStyleSheet(self._tabs_qss())
|
||||||
|
|
||||||
|
all_items = get_all_with_status(self.db)
|
||||||
|
earned_items = [a for a in all_items if a['earned_date'] is not None]
|
||||||
|
in_progress = [a for a in all_items
|
||||||
|
if a['earned_date'] is None and not a['is_secret']]
|
||||||
|
secret_items = [a for a in all_items if a['is_secret']]
|
||||||
|
|
||||||
|
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
||||||
|
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||||
|
f"⚡ 진행 중 · {len(in_progress)}")
|
||||||
|
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||||
|
f"✓ 완료 · {len(earned_items)}")
|
||||||
|
self.tabs.addTab(
|
||||||
|
self._build_grid_tab(secret_items, secret_mode=True),
|
||||||
|
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
|
||||||
|
)
|
||||||
|
layout.addWidget(self.tabs, 1)
|
||||||
|
|
||||||
|
# === 닫기 버튼 ===
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch()
|
||||||
|
close_btn = QPushButton("닫기")
|
||||||
|
close_btn.setMinimumWidth(100)
|
||||||
|
close_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background: #2a2a36; color: #e0e0e8;
|
||||||
|
border: 1px solid #44446a; border-radius: 6px;
|
||||||
|
padding: 8px 20px; font-size: 10pt;
|
||||||
|
}
|
||||||
|
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
|
||||||
|
""")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
btn_row.addWidget(close_btn)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
# ----- 헤더 -----
|
||||||
|
def _build_header(self, stats: dict) -> QWidget:
|
||||||
|
container = QFrame()
|
||||||
|
container.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||||
|
border: 1px solid #3a3a5a;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
||||||
|
""")
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
|
||||||
|
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
|
||||||
|
|
||||||
|
# 큰 숫자 행
|
||||||
|
num_row = QHBoxLayout()
|
||||||
|
num_row.setSpacing(24)
|
||||||
|
|
||||||
|
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
||||||
|
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>")
|
||||||
|
big.setTextFormat(Qt.RichText)
|
||||||
|
num_row.addWidget(big)
|
||||||
|
|
||||||
|
spacer = QFrame()
|
||||||
|
spacer.setFrameShape(QFrame.VLine)
|
||||||
|
spacer.setStyleSheet("color: #3a3a5a;")
|
||||||
|
num_row.addWidget(spacer)
|
||||||
|
|
||||||
|
secret_lbl = QLabel(
|
||||||
|
f"<div style='line-height: 1.3;'>"
|
||||||
|
f"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>"
|
||||||
|
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
||||||
|
f"{stats['secret_earned']}</span>"
|
||||||
|
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
secret_lbl.setTextFormat(Qt.RichText)
|
||||||
|
num_row.addWidget(secret_lbl)
|
||||||
|
|
||||||
|
num_row.addStretch()
|
||||||
|
|
||||||
|
pct_lbl = QLabel(
|
||||||
|
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||||
|
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>"
|
||||||
|
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
||||||
|
f"{pct:.1f}%</span></div>"
|
||||||
|
)
|
||||||
|
pct_lbl.setTextFormat(Qt.RichText)
|
||||||
|
pct_lbl.setAlignment(Qt.AlignRight)
|
||||||
|
num_row.addWidget(pct_lbl)
|
||||||
|
|
||||||
|
layout.addLayout(num_row)
|
||||||
|
|
||||||
|
# 진행 바
|
||||||
|
bar = QProgressBar()
|
||||||
|
bar.setMaximum(max(stats['total'], 1))
|
||||||
|
bar.setValue(stats['earned'])
|
||||||
|
bar.setTextVisible(False)
|
||||||
|
bar.setMinimumHeight(8)
|
||||||
|
bar.setMaximumHeight(8)
|
||||||
|
bar.setStyleSheet("""
|
||||||
|
QProgressBar {
|
||||||
|
background: #1a1a26;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
layout.addWidget(bar)
|
||||||
|
|
||||||
|
container.setLayout(layout)
|
||||||
|
return container
|
||||||
|
|
||||||
|
# ----- 탭 그리드 -----
|
||||||
|
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setStyleSheet("""
|
||||||
|
QScrollArea { background: transparent; border: none; }
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background: #1a1a24; width: 10px; border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background: #44446a; border-radius: 5px; min-height: 30px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical:hover { background: #6b9eff; }
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||||
|
""")
|
||||||
|
container = QWidget()
|
||||||
|
container.setStyleSheet("background: transparent;")
|
||||||
|
grid = QGridLayout()
|
||||||
|
grid.setSpacing(12)
|
||||||
|
grid.setContentsMargins(8, 8, 8, 8)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
empty = QLabel("(아직 없음)")
|
||||||
|
empty.setAlignment(Qt.AlignCenter)
|
||||||
|
empty.setStyleSheet(
|
||||||
|
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
|
||||||
|
)
|
||||||
|
grid.addWidget(empty, 0, 0)
|
||||||
|
else:
|
||||||
|
cols = 3
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
card = self._build_card(item, secret_mode=secret_mode)
|
||||||
|
grid.addWidget(card, i // cols, i % cols)
|
||||||
|
# 빈 컬럼 stretch 방지
|
||||||
|
for c in range(cols):
|
||||||
|
grid.setColumnStretch(c, 1)
|
||||||
|
|
||||||
|
container.setLayout(grid)
|
||||||
|
scroll.setWidget(container)
|
||||||
|
return scroll
|
||||||
|
|
||||||
|
# ----- 단일 카드 -----
|
||||||
|
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
|
||||||
|
is_earned = item['earned_date'] is not None
|
||||||
|
is_locked_secret = item['is_secret'] and not is_earned
|
||||||
|
tier = item['tier'] or 'bronze'
|
||||||
|
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
||||||
|
|
||||||
|
# 시크릿 미발견은 회색 톤으로
|
||||||
|
if is_locked_secret:
|
||||||
|
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
||||||
|
border = '#3a3a4a'
|
||||||
|
text_color = '#666'
|
||||||
|
else:
|
||||||
|
bg_top = theme['bg_top']
|
||||||
|
bg_bot = theme['bg_bot']
|
||||||
|
border = theme['border_strong'] if is_earned else theme['border']
|
||||||
|
text_color = theme['text'] if is_earned else '#c0c0d0'
|
||||||
|
|
||||||
|
# 외곽선 강도: 획득 시 2px + 더 진한 색
|
||||||
|
border_width = 2 if is_earned else 1
|
||||||
|
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
|
||||||
|
|
||||||
|
card = QFrame()
|
||||||
|
card.setFrameShape(QFrame.NoFrame)
|
||||||
|
card.setMinimumHeight(150)
|
||||||
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
|
card.setStyleSheet(f"""
|
||||||
|
QFrame {{
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||||
|
stop:0 {bg_top}, stop:1 {bg_bot});
|
||||||
|
border: {border_width}px solid {border};
|
||||||
|
border-radius: 10px;
|
||||||
|
}}
|
||||||
|
QLabel {{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: {text_color};
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
outer = QVBoxLayout()
|
||||||
|
outer.setContentsMargins(14, 12, 14, 12)
|
||||||
|
outer.setSpacing(8)
|
||||||
|
|
||||||
|
# 1행: 이모지 + 이름 + 등급 라벨
|
||||||
|
top_row = QHBoxLayout()
|
||||||
|
top_row.setSpacing(10)
|
||||||
|
|
||||||
|
if is_locked_secret:
|
||||||
|
icon_text = "❓"
|
||||||
|
else:
|
||||||
|
icon_text = item['badge_icon'] or '🏆'
|
||||||
|
icon = QLabel(icon_text)
|
||||||
|
icon.setStyleSheet(
|
||||||
|
f"font-size: 32pt; background: transparent; border: none; "
|
||||||
|
f"color: {text_color};"
|
||||||
|
)
|
||||||
|
icon.setMinimumWidth(48)
|
||||||
|
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
|
||||||
|
top_row.addWidget(icon)
|
||||||
|
|
||||||
|
# 이름 + 카테고리 (세로 스택)
|
||||||
|
name_box = QVBoxLayout()
|
||||||
|
name_box.setSpacing(2)
|
||||||
|
name_box.setContentsMargins(0, 4, 0, 0)
|
||||||
|
|
||||||
|
name_text = "???" if is_locked_secret else (item['name'] or '')
|
||||||
|
name = QLabel(name_text)
|
||||||
|
name.setStyleSheet(
|
||||||
|
f"font-size: 12pt; font-weight: bold; "
|
||||||
|
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
|
||||||
|
f"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
name.setWordWrap(True)
|
||||||
|
name_box.addWidget(name)
|
||||||
|
|
||||||
|
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
|
||||||
|
if not is_locked_secret:
|
||||||
|
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
||||||
|
cat_label.setStyleSheet(
|
||||||
|
f"font-size: 8.5pt; "
|
||||||
|
f"color: {theme['border_strong']}; "
|
||||||
|
f"background: rgba(255,255,255,0.05); "
|
||||||
|
f"border: 1px solid {theme['border']}; "
|
||||||
|
f"border-radius: 8px; "
|
||||||
|
f"padding: 1px 4px;"
|
||||||
|
)
|
||||||
|
cat_label.setMaximumHeight(20)
|
||||||
|
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||||
|
cat_wrap = QHBoxLayout()
|
||||||
|
cat_wrap.setContentsMargins(0, 0, 0, 0)
|
||||||
|
cat_wrap.addWidget(cat_label)
|
||||||
|
cat_wrap.addStretch()
|
||||||
|
name_box.addLayout(cat_wrap)
|
||||||
|
|
||||||
|
top_row.addLayout(name_box, 1)
|
||||||
|
outer.addLayout(top_row)
|
||||||
|
|
||||||
|
# 2행: 설명
|
||||||
|
if is_locked_secret:
|
||||||
|
desc_text = "🔒 달성하면 공개됩니다"
|
||||||
|
else:
|
||||||
|
desc_text = item['description'] or ''
|
||||||
|
desc = QLabel(desc_text)
|
||||||
|
desc.setWordWrap(True)
|
||||||
|
desc.setStyleSheet(
|
||||||
|
f"color: #a0a0b8; font-size: 9.5pt; "
|
||||||
|
f"background: transparent; border: none; padding: 0;"
|
||||||
|
)
|
||||||
|
outer.addWidget(desc)
|
||||||
|
|
||||||
|
# 3행: 진행 게이지 또는 획득 일자
|
||||||
|
if is_earned:
|
||||||
|
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
||||||
|
earned.setStyleSheet(
|
||||||
|
f"color: {theme['border_strong']}; "
|
||||||
|
f"font-weight: bold; font-size: 9.5pt; "
|
||||||
|
f"background: rgba(255,255,255,0.08); "
|
||||||
|
f"border: 1px solid {theme['border']}; "
|
||||||
|
f"border-radius: 6px; padding: 4px 8px;"
|
||||||
|
)
|
||||||
|
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addWidget(earned)
|
||||||
|
row.addStretch()
|
||||||
|
outer.addLayout(row)
|
||||||
|
elif not is_locked_secret:
|
||||||
|
target = max(1, item.get('target') or 1)
|
||||||
|
progress = item.get('progress') or 0
|
||||||
|
pct = (progress / target * 100) if target else 0
|
||||||
|
|
||||||
|
# 게이지 + 숫자 라벨
|
||||||
|
gauge_row = QHBoxLayout()
|
||||||
|
gauge_row.setSpacing(8)
|
||||||
|
|
||||||
|
pb = QProgressBar()
|
||||||
|
pb.setMaximum(target)
|
||||||
|
pb.setValue(min(progress, target))
|
||||||
|
pb.setTextVisible(False)
|
||||||
|
pb.setMinimumHeight(10)
|
||||||
|
pb.setMaximumHeight(10)
|
||||||
|
pb.setStyleSheet(f"""
|
||||||
|
QProgressBar {{
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
QProgressBar::chunk {{
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
gauge_row.addWidget(pb, 1)
|
||||||
|
|
||||||
|
num = QLabel(f"{progress} / {target}")
|
||||||
|
num.setStyleSheet(
|
||||||
|
f"color: {theme['border_strong']}; font-size: 9pt; "
|
||||||
|
f"font-weight: bold; background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
num.setMinimumWidth(60)
|
||||||
|
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||||
|
gauge_row.addWidget(num)
|
||||||
|
|
||||||
|
outer.addLayout(gauge_row)
|
||||||
|
else:
|
||||||
|
# 시크릿 잠금 — 회색 점선 placeholder
|
||||||
|
placeholder = QLabel("· · · · · · · · · ·")
|
||||||
|
placeholder.setStyleSheet(
|
||||||
|
"color: #444; font-size: 12pt; letter-spacing: 4px; "
|
||||||
|
"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
placeholder.setAlignment(Qt.AlignCenter)
|
||||||
|
outer.addWidget(placeholder)
|
||||||
|
|
||||||
|
outer.addStretch(1)
|
||||||
|
card.setLayout(outer)
|
||||||
|
return card
|
||||||
|
|
||||||
|
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||||
|
def _tabs_qss(self) -> str:
|
||||||
|
return """
|
||||||
|
QTabWidget::pane {
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
border-radius: 10px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
QTabBar::tab {
|
||||||
|
background: #1c1c28;
|
||||||
|
color: #a0a0b8;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
margin-right: 3px;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background: #14141c;
|
||||||
|
color: #ffd24a;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid #ffd24a;
|
||||||
|
}
|
||||||
|
QTabBar::tab:hover:!selected {
|
||||||
|
background: #2a2a36;
|
||||||
|
color: #e0e0e8;
|
||||||
|
}
|
||||||
|
"""
|
||||||
+69
-19
@@ -20,6 +20,33 @@ except ImportError:
|
|||||||
_MPL = False
|
_MPL = False
|
||||||
|
|
||||||
|
|
||||||
|
# 다크 테마 색상 (dark_components 톤과 일치)
|
||||||
|
_CHART_BG = '#14141c'
|
||||||
|
_CHART_GRID = '#2a2a3a'
|
||||||
|
_CHART_TEXT = '#c0c0d0'
|
||||||
|
_CHART_BAR_NORMAL = '#6b9eff' # blue
|
||||||
|
_CHART_BAR_OVERTIME = '#ff90b8' # pink
|
||||||
|
_CHART_BAR_WEEKEND = '#fcd34d' # gold
|
||||||
|
_CHART_AVG_LINE = '#4ade80' # green
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_dark_axes(ax) -> None:
|
||||||
|
"""차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경."""
|
||||||
|
ax.set_facecolor(_CHART_BG)
|
||||||
|
ax.tick_params(axis='both', colors=_CHART_TEXT)
|
||||||
|
ax.xaxis.label.set_color(_CHART_TEXT)
|
||||||
|
ax.yaxis.label.set_color(_CHART_TEXT)
|
||||||
|
ax.title.set_color(_CHART_TEXT)
|
||||||
|
for spine in ax.spines.values():
|
||||||
|
spine.set_color(_CHART_GRID)
|
||||||
|
ax.grid(axis='y', alpha=0.25, color=_CHART_GRID)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_dark_figure(fig) -> None:
|
||||||
|
"""figure 배경을 다크 톤으로."""
|
||||||
|
fig.patch.set_facecolor(_CHART_BG)
|
||||||
|
|
||||||
|
|
||||||
class _Fallback(QWidget):
|
class _Fallback(QWidget):
|
||||||
"""matplotlib 미설치 시 안내."""
|
"""matplotlib 미설치 시 안내."""
|
||||||
def __init__(self, message: str):
|
def __init__(self, message: str):
|
||||||
@@ -38,10 +65,12 @@ def make_chart_widget(parent=None) -> QWidget:
|
|||||||
if not _MPL:
|
if not _MPL:
|
||||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||||
widget = QWidget(parent)
|
widget = QWidget(parent)
|
||||||
|
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
|
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG)
|
||||||
canvas = FigureCanvas(fig)
|
canvas = FigureCanvas(fig)
|
||||||
|
canvas.setStyleSheet(f"background: {_CHART_BG};")
|
||||||
layout.addWidget(canvas)
|
layout.addWidget(canvas)
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
widget._figure = fig
|
widget._figure = fig
|
||||||
@@ -55,9 +84,12 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
return
|
return
|
||||||
fig = widget._figure
|
fig = widget._figure
|
||||||
fig.clear()
|
fig.clear()
|
||||||
|
_apply_dark_figure(fig)
|
||||||
if not records:
|
if not records:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
_apply_dark_axes(ax)
|
||||||
|
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||||
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -68,20 +100,23 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
|
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
||||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
||||||
|
color=_CHART_BAR_OVERTIME)
|
||||||
ax.set_ylabel('시간')
|
ax.set_ylabel('시간')
|
||||||
ax.legend(loc='upper left', fontsize=8)
|
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||||
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||||
ax.grid(axis='y', alpha=0.3)
|
_apply_dark_axes(ax)
|
||||||
|
|
||||||
# 호버 annotation 설정
|
# 호버 annotation 설정
|
||||||
annot = ax.annotate(
|
annot = ax.annotate(
|
||||||
"", xy=(0, 0), xytext=(15, 15),
|
"", xy=(0, 0), xytext=(15, 15),
|
||||||
textcoords="offset points",
|
textcoords="offset points",
|
||||||
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
|
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
|
||||||
|
alpha=0.95),
|
||||||
color="white", fontsize=9,
|
color="white", fontsize=9,
|
||||||
arrowprops=dict(arrowstyle="->", color="#888"),
|
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
|
||||||
)
|
)
|
||||||
annot.set_visible(False)
|
annot.set_visible(False)
|
||||||
|
|
||||||
@@ -102,6 +137,14 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
annot.set_text(text)
|
annot.set_text(text)
|
||||||
annot.set_visible(True)
|
annot.set_visible(True)
|
||||||
widget._canvas.draw_idle()
|
widget._canvas.draw_idle()
|
||||||
|
# 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록
|
||||||
|
db = getattr(widget, '_achievement_db', None)
|
||||||
|
if db is not None:
|
||||||
|
try:
|
||||||
|
if db.get_setting('chart_hover_discovered', 'false').lower() != 'true':
|
||||||
|
db.set_setting('chart_hover_discovered', 'true')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
if annot.get_visible():
|
if annot.get_visible():
|
||||||
annot.set_visible(False)
|
annot.set_visible(False)
|
||||||
@@ -117,13 +160,15 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
return
|
return
|
||||||
fig = widget._figure
|
fig = widget._figure
|
||||||
fig.clear()
|
fig.clear()
|
||||||
|
_apply_dark_figure(fig)
|
||||||
if not records:
|
if not records:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
_apply_dark_axes(ax)
|
||||||
|
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||||
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 출근 시각을 분 단위로 (00:00=0)
|
|
||||||
minutes_list = []
|
minutes_list = []
|
||||||
for r in records:
|
for r in records:
|
||||||
ci = r.get('clock_in')
|
ci = r.get('clock_in')
|
||||||
@@ -137,26 +182,31 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not minutes_list:
|
if not minutes_list:
|
||||||
|
ax = fig.add_subplot(111)
|
||||||
|
_apply_dark_axes(ax)
|
||||||
|
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||||
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
|
|
||||||
# 30분 빈
|
|
||||||
bin_size = 30
|
bin_size = 30
|
||||||
min_m = (min(minutes_list) // bin_size) * bin_size
|
min_m = (min(minutes_list) // bin_size) * bin_size
|
||||||
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
|
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
|
||||||
bins = list(range(min_m, max_m + bin_size, bin_size))
|
bins = list(range(min_m, max_m + bin_size, bin_size))
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
|
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||||
|
edgecolor=_CHART_BG, linewidth=1)
|
||||||
avg = sum(minutes_list) / len(minutes_list)
|
avg = sum(minutes_list) / len(minutes_list)
|
||||||
ax.axvline(avg, color='#ff6b6b', linestyle='--',
|
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||||
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
||||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
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],
|
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||||
rotation=45, fontsize=8)
|
rotation=45, fontsize=8)
|
||||||
ax.set_ylabel('일수')
|
ax.set_ylabel('일수')
|
||||||
ax.set_title('출근 시각 분포')
|
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||||
ax.legend(loc='upper right', fontsize=8)
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||||
ax.grid(axis='y', alpha=0.3)
|
_apply_dark_axes(ax)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +216,7 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
|||||||
return
|
return
|
||||||
fig = widget._figure
|
fig = widget._figure
|
||||||
fig.clear()
|
fig.clear()
|
||||||
|
_apply_dark_figure(fig)
|
||||||
|
|
||||||
from datetime import datetime as _dt
|
from datetime import datetime as _dt
|
||||||
weekday_totals = [0.0] * 7
|
weekday_totals = [0.0] * 7
|
||||||
@@ -182,9 +233,8 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
|||||||
labels = ['월', '화', '수', '목', '금', '토', '일']
|
labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
|
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||||
ax.bar(labels, avg, color=colors)
|
ax.bar(labels, avg, color=colors)
|
||||||
ax.set_ylabel('평균 시간')
|
ax.set_ylabel('평균 시간')
|
||||||
ax.set_title('요일별 평균 근무시간')
|
_apply_dark_axes(ax)
|
||||||
ax.grid(axis='y', alpha=0.3)
|
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ class LockMonitor:
|
|||||||
self.window = window
|
self.window = window
|
||||||
self.db = window.db
|
self.db = window.db
|
||||||
self.last_locked: bool = False
|
self.last_locked: bool = False
|
||||||
|
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
|
||||||
|
|
||||||
def tick(self) -> None:
|
def tick(self) -> None:
|
||||||
try:
|
try:
|
||||||
from utils.lock_detector import is_screen_locked
|
from utils.lock_detector import is_screen_locked
|
||||||
locked = is_screen_locked()
|
locked = is_screen_locked()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
if not self._detector_failed_once:
|
||||||
|
self._detector_failed_once = True
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"lock detector failed (silenced after first): {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
was_locked = self.last_locked
|
was_locked = self.last_locked
|
||||||
|
|||||||
@@ -2,11 +2,24 @@
|
|||||||
알림 오케스트레이션.
|
알림 오케스트레이션.
|
||||||
|
|
||||||
5분 가드로 건강/주간/누적 임계 알림을 throttle.
|
5분 가드로 건강/주간/누적 임계 알림을 throttle.
|
||||||
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
|
notifier.py의 알림 메서드를 적절한 시점에 호출.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from core.settings_keys import (
|
||||||
|
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||||
|
)
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||||
|
try:
|
||||||
|
v = int(db.get_setting(key, str(default)) or default)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
v = default
|
||||||
|
return max(lo, min(hi, v))
|
||||||
|
|
||||||
|
|
||||||
class NotificationOrchestrator:
|
class NotificationOrchestrator:
|
||||||
"""update_display() 1Hz tick에서 호출."""
|
"""update_display() 1Hz tick에서 호출."""
|
||||||
@@ -66,19 +79,26 @@ class NotificationOrchestrator:
|
|||||||
f"기간: {last_mon} ~ {last_sun}",
|
f"기간: {last_mon} ~ {last_sun}",
|
||||||
color=COLOR_BLUE, fields=fields)
|
color=COLOR_BLUE, fields=fields)
|
||||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
dlog(f"discord weekly_report failed: {e}")
|
||||||
|
|
||||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
|
||||||
|
is_holiday: bool = False) -> None:
|
||||||
n = self.notifier
|
n = self.notifier
|
||||||
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
|
||||||
n.check_clock_out_soon(expected_clock_out, now)
|
if not is_holiday:
|
||||||
|
n.check_clock_out_soon(expected_clock_out, now)
|
||||||
|
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
|
||||||
n.check_lunch_reminder(self.window.clock_in_time,
|
n.check_lunch_reminder(self.window.clock_in_time,
|
||||||
self.window.lunch_break_enabled, now)
|
self.window.lunch_break_enabled, now)
|
||||||
|
n.check_dinner_reminder(self.window.clock_in_time,
|
||||||
|
getattr(self.window, 'dinner_break_enabled', False), now)
|
||||||
if remaining_seconds < 0:
|
if remaining_seconds < 0:
|
||||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
||||||
|
|
||||||
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
|
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
|
||||||
|
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
|
||||||
|
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
|
||||||
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||||
self._last_5min_bucket = now.minute
|
self._last_5min_bucket = now.minute
|
||||||
|
|
||||||
@@ -89,18 +109,73 @@ class NotificationOrchestrator:
|
|||||||
break_minutes = self.db.get_total_break_minutes_today()
|
break_minutes = self.db.get_total_break_minutes_today()
|
||||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
||||||
|
|
||||||
|
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
|
||||||
consecutive = self.db.get_consecutive_overtime_days()
|
consecutive = self.db.get_consecutive_overtime_days()
|
||||||
if consecutive >= 3:
|
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||||
|
if consecutive >= consecutive_th:
|
||||||
n.notify_health_warning(consecutive)
|
n.notify_health_warning(consecutive)
|
||||||
self._discord_health(consecutive, break_minutes, now)
|
self._discord_health(consecutive, break_minutes, now)
|
||||||
|
|
||||||
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
||||||
if weekly_hours > 52:
|
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||||
|
if weekly_hours > weekly_th:
|
||||||
n.notify_weekly_hours(weekly_hours)
|
n.notify_weekly_hours(weekly_hours)
|
||||||
|
|
||||||
balance_minutes = self.db.get_total_overtime_balance()
|
balance_minutes = self.db.get_total_overtime_balance()
|
||||||
if balance_minutes >= 1200:
|
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||||
|
if balance_minutes / 60.0 >= ot_threshold_h:
|
||||||
n.notify_overtime_threshold(balance_minutes / 60.0)
|
n.notify_overtime_threshold(balance_minutes / 60.0)
|
||||||
|
|
||||||
|
# 도전과제 평가 (5분 throttle)
|
||||||
|
self._evaluate_achievements(now)
|
||||||
|
|
||||||
|
def _evaluate_achievements(self, now: datetime) -> None:
|
||||||
|
"""도전과제 평가 + 신규 잠금 해제 알림.
|
||||||
|
|
||||||
|
실패는 silent — 도전과제 시스템이 메인 흐름을 막으면 안 됨.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from core.achievements import evaluate_all
|
||||||
|
unlocked = evaluate_all(self.db)
|
||||||
|
except Exception as e:
|
||||||
|
dlog(f"achievement eval failed: {e}")
|
||||||
|
return
|
||||||
|
if not unlocked:
|
||||||
|
return
|
||||||
|
# 시스템 알림 + Discord push (옵션)
|
||||||
|
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
|
||||||
|
for a in unlocked:
|
||||||
|
self.db.log_notification('system', f'achievement:{a.code}')
|
||||||
|
if notif_on:
|
||||||
|
title = f"{a.badge_icon} 도전과제 달성!"
|
||||||
|
body = f"{a.name}\n{a.description}"
|
||||||
|
self.notifier.notification_signal.emit(title, body)
|
||||||
|
# Discord 통합 push (여러 개면 묶어서)
|
||||||
|
self._discord_achievements(unlocked)
|
||||||
|
|
||||||
|
def _discord_achievements(self, unlocked: list) -> None:
|
||||||
|
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
|
||||||
|
return
|
||||||
|
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from utils import discord_webhook
|
||||||
|
fields = [{"name": f"{a.badge_icon} {a.name}",
|
||||||
|
"value": a.description, "inline": False}
|
||||||
|
for a in unlocked[:10]]
|
||||||
|
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||||
|
ok = discord_webhook.send(
|
||||||
|
url,
|
||||||
|
f"🏆 도전과제 {len(unlocked)}개 달성!",
|
||||||
|
f"새로 잠금 해제된 도전과제 입니다.{extra}",
|
||||||
|
color=discord_webhook.COLOR_YELLOW,
|
||||||
|
fields=fields,
|
||||||
|
)
|
||||||
|
self.db.log_notification('discord', 'achievement', success=ok)
|
||||||
|
except Exception as e:
|
||||||
|
dlog(f"discord achievement push failed: {e}")
|
||||||
|
|
||||||
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
|
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
|
||||||
"""건강 경고 Discord push (옵션)."""
|
"""건강 경고 Discord push (옵션)."""
|
||||||
if self.db.has_notification_today('discord', 'health'):
|
if self.db.has_notification_today('discord', 'health'):
|
||||||
@@ -115,5 +190,5 @@ class NotificationOrchestrator:
|
|||||||
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
||||||
ok = discord_webhook.send_health_warning(url, elapsed)
|
ok = discord_webhook.send_health_warning(url, elapsed)
|
||||||
self.db.log_notification('discord', 'health', success=ok)
|
self.db.log_notification('discord', 'health', success=ok)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
dlog(f"discord health push failed: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""
|
||||||
|
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
- 다이얼로그 배경: #0e0e14 (깊은 다크)
|
||||||
|
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
|
||||||
|
- 헤더: 큰 숫자 + 그라디언트 progress bar
|
||||||
|
- 탭: 골드 강조 선택
|
||||||
|
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
|
||||||
|
|
||||||
|
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
|
||||||
|
QProgressBar, QPushButton, QWidget, QSizePolicy,
|
||||||
|
QTabWidget)
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
|
||||||
|
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||||
|
DARK_BG = '#0e0e14'
|
||||||
|
DARK_PANEL = '#14141c'
|
||||||
|
DARK_PANEL_2 = '#1c1c28'
|
||||||
|
DARK_BORDER = '#2a2a3a'
|
||||||
|
DARK_BORDER_STRONG = '#44446a'
|
||||||
|
DARK_TEXT = '#e8e8f4'
|
||||||
|
DARK_TEXT_DIM = '#a0a0b8'
|
||||||
|
DARK_TEXT_FAINT = '#666680'
|
||||||
|
|
||||||
|
ACCENT_GOLD = '#ffd24a'
|
||||||
|
ACCENT_BLUE = '#6b9eff'
|
||||||
|
ACCENT_CYAN = '#4adef0'
|
||||||
|
ACCENT_PINK = '#ff90b8'
|
||||||
|
ACCENT_GREEN = '#4ade80'
|
||||||
|
ACCENT_ORANGE = '#fcd34d'
|
||||||
|
ACCENT_RED = '#fb7185'
|
||||||
|
|
||||||
|
# 카드 테마 (등급/상태별)
|
||||||
|
CARD_THEMES = {
|
||||||
|
'gold': {
|
||||||
|
'border': '#ffb700', 'border_strong': '#ffd24a',
|
||||||
|
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
|
||||||
|
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
|
||||||
|
},
|
||||||
|
'blue': {
|
||||||
|
'border': '#5a8eff', 'border_strong': '#6b9eff',
|
||||||
|
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
|
||||||
|
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
|
||||||
|
},
|
||||||
|
'cyan': {
|
||||||
|
'border': '#3acce0', 'border_strong': '#4adef0',
|
||||||
|
'bg_top': '#0e3340', 'bg_bot': '#08222b',
|
||||||
|
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
|
||||||
|
},
|
||||||
|
'green': {
|
||||||
|
'border': '#3ace70', 'border_strong': '#4ade80',
|
||||||
|
'bg_top': '#0e3324', 'bg_bot': '#082218',
|
||||||
|
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
|
||||||
|
},
|
||||||
|
'pink': {
|
||||||
|
'border': '#ff5a8c', 'border_strong': '#ff90b8',
|
||||||
|
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
|
||||||
|
'text': '#ffc0d4', 'accent': ACCENT_PINK,
|
||||||
|
},
|
||||||
|
'red': {
|
||||||
|
'border': '#ea5566', 'border_strong': '#fb7185',
|
||||||
|
'bg_top': '#3a1620', 'bg_bot': '#260e16',
|
||||||
|
'text': '#ffb8c0', 'accent': ACCENT_RED,
|
||||||
|
},
|
||||||
|
'gray': {
|
||||||
|
'border': '#44446a', 'border_strong': '#666688',
|
||||||
|
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
|
||||||
|
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def dialog_qss() -> str:
|
||||||
|
"""다이얼로그 전체 배경."""
|
||||||
|
return f"QDialog {{ background: {DARK_BG}; }}"
|
||||||
|
|
||||||
|
|
||||||
|
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
||||||
|
return f"""
|
||||||
|
QTabWidget::pane {{
|
||||||
|
background: {DARK_PANEL};
|
||||||
|
border: 1px solid {DARK_BORDER};
|
||||||
|
border-radius: 10px;
|
||||||
|
top: -1px;
|
||||||
|
}}
|
||||||
|
QTabBar::tab {{
|
||||||
|
background: {DARK_PANEL_2};
|
||||||
|
color: {DARK_TEXT_DIM};
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: 1px solid {DARK_BORDER};
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
margin-right: 3px;
|
||||||
|
font-size: 10pt;
|
||||||
|
}}
|
||||||
|
QTabBar::tab:selected {{
|
||||||
|
background: {DARK_PANEL};
|
||||||
|
color: {accent};
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid {accent};
|
||||||
|
}}
|
||||||
|
QTabBar::tab:hover:!selected {{
|
||||||
|
background: #2a2a36;
|
||||||
|
color: {DARK_TEXT};
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def scroll_qss() -> str:
|
||||||
|
return f"""
|
||||||
|
QScrollArea {{ background: transparent; border: none; }}
|
||||||
|
QScrollBar:vertical {{
|
||||||
|
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:vertical {{
|
||||||
|
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||||
|
QScrollBar:horizontal {{
|
||||||
|
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:horizontal {{
|
||||||
|
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def button_qss(variant: str = 'default') -> str:
|
||||||
|
""" variant: default | primary | success | danger | ghost """
|
||||||
|
if variant == 'primary':
|
||||||
|
return f"""
|
||||||
|
QPushButton {{
|
||||||
|
background: {ACCENT_BLUE}; color: white;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: #82b0ff; }}
|
||||||
|
QPushButton:pressed {{ background: #5a8eee; }}
|
||||||
|
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
||||||
|
"""
|
||||||
|
if variant == 'success':
|
||||||
|
return f"""
|
||||||
|
QPushButton {{
|
||||||
|
background: {ACCENT_GREEN}; color: #0e2a1a;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: #6ae899; }}
|
||||||
|
"""
|
||||||
|
if variant == 'danger':
|
||||||
|
return f"""
|
||||||
|
QPushButton {{
|
||||||
|
background: {ACCENT_RED}; color: white;
|
||||||
|
border: none; border-radius: 6px;
|
||||||
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: #fc8896; }}
|
||||||
|
"""
|
||||||
|
if variant == 'ghost':
|
||||||
|
return f"""
|
||||||
|
QPushButton {{
|
||||||
|
background: transparent; color: {DARK_TEXT_DIM};
|
||||||
|
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||||
|
padding: 6px 14px; font-size: 9.5pt;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||||
|
border-color: {ACCENT_BLUE}; }}
|
||||||
|
"""
|
||||||
|
# default
|
||||||
|
return f"""
|
||||||
|
QPushButton {{
|
||||||
|
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||||
|
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||||
|
padding: 8px 18px; font-size: 10pt;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||||
|
big_color: str = ACCENT_GOLD,
|
||||||
|
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
|
||||||
|
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 큰 숫자 위 작은 라벨 (예: "달성")
|
||||||
|
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
|
||||||
|
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
|
||||||
|
big_color: 큰 숫자 색
|
||||||
|
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||||
|
"""
|
||||||
|
container = QFrame()
|
||||||
|
container.setStyleSheet(f"""
|
||||||
|
QFrame {{
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||||
|
border: 1px solid #3a3a5a;
|
||||||
|
border-radius: 12px;
|
||||||
|
}}
|
||||||
|
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
||||||
|
""")
|
||||||
|
layout = QHBoxLayout()
|
||||||
|
layout.setContentsMargins(20, 14, 20, 14)
|
||||||
|
layout.setSpacing(20)
|
||||||
|
|
||||||
|
# 좌측: 제목 + 큰 숫자
|
||||||
|
left = QVBoxLayout()
|
||||||
|
left.setSpacing(2)
|
||||||
|
if title:
|
||||||
|
t = QLabel(title)
|
||||||
|
t.setStyleSheet(
|
||||||
|
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||||
|
f"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
left.addWidget(t)
|
||||||
|
big = QLabel(
|
||||||
|
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
||||||
|
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
|
||||||
|
f" {subtitle}</span>" if subtitle else '')
|
||||||
|
)
|
||||||
|
big.setTextFormat(Qt.RichText)
|
||||||
|
big.setStyleSheet("background: transparent; border: none;")
|
||||||
|
left.addWidget(big)
|
||||||
|
layout.addLayout(left)
|
||||||
|
|
||||||
|
# 우측: extra widgets
|
||||||
|
if extra_widgets:
|
||||||
|
layout.addStretch()
|
||||||
|
for w in extra_widgets:
|
||||||
|
layout.addWidget(w)
|
||||||
|
else:
|
||||||
|
layout.addStretch()
|
||||||
|
|
||||||
|
container.setLayout(layout)
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
|
def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||||
|
theme: str = 'blue', icon: str = '') -> QFrame:
|
||||||
|
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
||||||
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||||
|
card = QFrame()
|
||||||
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
|
card.setStyleSheet(f"""
|
||||||
|
QFrame {{
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||||
|
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||||
|
border: 1px solid {t['border']};
|
||||||
|
border-radius: 10px;
|
||||||
|
}}
|
||||||
|
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||||
|
""")
|
||||||
|
outer = QHBoxLayout()
|
||||||
|
outer.setContentsMargins(16, 12, 16, 12)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
if icon:
|
||||||
|
icon_lbl = QLabel(icon)
|
||||||
|
icon_lbl.setStyleSheet(
|
||||||
|
f"font-size: 28pt; background: transparent; border: none; "
|
||||||
|
f"color: {t['border_strong']};"
|
||||||
|
)
|
||||||
|
icon_lbl.setMinimumWidth(48)
|
||||||
|
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||||
|
outer.addWidget(icon_lbl)
|
||||||
|
|
||||||
|
text_box = QVBoxLayout()
|
||||||
|
text_box.setSpacing(2)
|
||||||
|
|
||||||
|
title_lbl = QLabel(title)
|
||||||
|
title_lbl.setStyleSheet(
|
||||||
|
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; "
|
||||||
|
f"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
text_box.addWidget(title_lbl)
|
||||||
|
|
||||||
|
val_lbl = QLabel(
|
||||||
|
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
|
||||||
|
f"{value}</span>"
|
||||||
|
)
|
||||||
|
val_lbl.setTextFormat(Qt.RichText)
|
||||||
|
val_lbl.setStyleSheet("background: transparent; border: none;")
|
||||||
|
text_box.addWidget(val_lbl)
|
||||||
|
|
||||||
|
if subtitle:
|
||||||
|
sub_lbl = QLabel(subtitle)
|
||||||
|
sub_lbl.setStyleSheet(
|
||||||
|
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||||
|
f"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
sub_lbl.setWordWrap(True)
|
||||||
|
text_box.addWidget(sub_lbl)
|
||||||
|
|
||||||
|
outer.addLayout(text_box, 1)
|
||||||
|
card.setLayout(outer)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def build_section_card(title: str, content: QWidget,
|
||||||
|
theme: str = 'gray', icon: str = '') -> QFrame:
|
||||||
|
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
||||||
|
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
||||||
|
card = QFrame()
|
||||||
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
card.setStyleSheet(f"""
|
||||||
|
QFrame {{
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||||
|
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||||
|
border: 1px solid {t['border']};
|
||||||
|
border-radius: 10px;
|
||||||
|
}}
|
||||||
|
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||||
|
""")
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.setContentsMargins(16, 12, 16, 14)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
|
||||||
|
head = QHBoxLayout()
|
||||||
|
if icon:
|
||||||
|
i = QLabel(icon)
|
||||||
|
i.setStyleSheet(
|
||||||
|
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||||
|
f"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
head.addWidget(i)
|
||||||
|
title_lbl = QLabel(title)
|
||||||
|
title_lbl.setStyleSheet(
|
||||||
|
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||||
|
f"background: transparent; border: none;"
|
||||||
|
)
|
||||||
|
head.addWidget(title_lbl)
|
||||||
|
head.addStretch()
|
||||||
|
layout.addLayout(head)
|
||||||
|
|
||||||
|
layout.addWidget(content, 1)
|
||||||
|
card.setLayout(layout)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
||||||
|
height: int = 10) -> None:
|
||||||
|
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
|
||||||
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||||
|
pb.setMinimumHeight(height)
|
||||||
|
pb.setMaximumHeight(height)
|
||||||
|
pb.setTextVisible(False)
|
||||||
|
pb.setStyleSheet(f"""
|
||||||
|
QProgressBar {{
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
border: none;
|
||||||
|
border-radius: {height // 2}px;
|
||||||
|
}}
|
||||||
|
QProgressBar::chunk {{
|
||||||
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
|
stop:0 {t['border']}, stop:1 {t['border_strong']});
|
||||||
|
border-radius: {height // 2}px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||||
|
color: str = DARK_TEXT) -> QLabel:
|
||||||
|
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
|
||||||
|
lbl = QLabel(text)
|
||||||
|
weight_str = 'bold' if weight == 'bold' else 'normal'
|
||||||
|
lbl.setStyleSheet(
|
||||||
|
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
||||||
|
f"background: transparent; border: none; padding: 0; margin: 0;"
|
||||||
|
)
|
||||||
|
return lbl
|
||||||
+112
-16
@@ -2,6 +2,7 @@
|
|||||||
사용 설명 가이드 창.
|
사용 설명 가이드 창.
|
||||||
|
|
||||||
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
|
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
|
||||||
|
도전과제/통계 다이얼로그와 동일한 다크 톤.
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QPushButton, QWidget, QTabWidget, QTextBrowser)
|
QPushButton, QWidget, QTabWidget, QTextBrowser)
|
||||||
@@ -9,6 +10,10 @@ from PyQt5.QtCore import Qt
|
|||||||
|
|
||||||
from core.i18n import tr, tr_html
|
from core.i18n import tr, tr_html
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
from ui.dark_components import (
|
||||||
|
dialog_qss, tabs_qss, button_qss,
|
||||||
|
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HelpView(QDialog):
|
class HelpView(QDialog):
|
||||||
@@ -28,42 +33,48 @@ class HelpView(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle(tr('window.help'))
|
self.setWindowTitle(tr('window.help'))
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumSize(680, 720)
|
self.setMinimumSize(720, 720)
|
||||||
|
self.resize(820, 760)
|
||||||
|
self.setStyleSheet(dialog_qss())
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
apply_dark_titlebar(self)
|
apply_dark_titlebar(self, dark=True)
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
main_layout = QVBoxLayout()
|
main_layout = QVBoxLayout()
|
||||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
main_layout.setContentsMargins(20, 16, 20, 14)
|
||||||
main_layout.setSpacing(0)
|
main_layout.setSpacing(10)
|
||||||
|
|
||||||
title = QLabel(tr('window.help'))
|
# 다크 타이틀
|
||||||
title.setObjectName("dialog_title")
|
title = QLabel(f"📖 {tr('window.help')}")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setStyleSheet(
|
||||||
|
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||||
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
|
)
|
||||||
main_layout.addWidget(title)
|
main_layout.addWidget(title)
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
tabs.setDocumentMode(True)
|
tabs.setDocumentMode(True)
|
||||||
|
tabs.setStyleSheet(tabs_qss())
|
||||||
for html_key, tab_label_key in self._TABS:
|
for html_key, tab_label_key in self._TABS:
|
||||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
||||||
main_layout.addWidget(tabs)
|
main_layout.addWidget(tabs, 1)
|
||||||
|
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setContentsMargins(20, 10, 20, 20)
|
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||||
|
|
||||||
# 온보딩 다시 보기 (왼쪽)
|
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||||
onboarding_button.setMinimumHeight(40)
|
onboarding_button.setMinimumHeight(36)
|
||||||
|
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||||
button_layout.addWidget(onboarding_button)
|
button_layout.addWidget(onboarding_button)
|
||||||
|
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
|
|
||||||
close_button = QPushButton(tr('btn.close'))
|
close_button = QPushButton(tr('btn.close'))
|
||||||
close_button.setObjectName("btn_primary")
|
close_button.setMinimumHeight(36)
|
||||||
close_button.setMinimumHeight(40)
|
|
||||||
close_button.setMinimumWidth(120)
|
close_button.setMinimumWidth(120)
|
||||||
|
close_button.setStyleSheet(button_qss('primary'))
|
||||||
close_button.clicked.connect(self.close)
|
close_button.clicked.connect(self.close)
|
||||||
button_layout.addWidget(close_button)
|
button_layout.addWidget(close_button)
|
||||||
main_layout.addLayout(button_layout)
|
main_layout.addLayout(button_layout)
|
||||||
@@ -78,15 +89,100 @@ class HelpView(QDialog):
|
|||||||
|
|
||||||
def _make_tab(self, html: str) -> QWidget:
|
def _make_tab(self, html: str) -> QWidget:
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
|
container.setStyleSheet(f"background: {DARK_PANEL};")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setContentsMargins(16, 12, 16, 12)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
browser = QTextBrowser()
|
browser = QTextBrowser()
|
||||||
browser.setOpenExternalLinks(False)
|
browser.setOpenExternalLinks(False)
|
||||||
browser.setHtml(html)
|
# HTML 내부에 다크 톤 스타일 주입
|
||||||
|
styled_html = self._inject_dark_styles(html)
|
||||||
|
browser.setHtml(styled_html)
|
||||||
|
browser.setStyleSheet(f"""
|
||||||
|
QTextBrowser {{
|
||||||
|
background: {DARK_PANEL};
|
||||||
|
color: {DARK_TEXT};
|
||||||
|
border: none;
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 10.5pt;
|
||||||
|
selection-background-color: {ACCENT_GOLD};
|
||||||
|
selection-color: #1a1a26;
|
||||||
|
}}
|
||||||
|
QScrollBar:vertical {{
|
||||||
|
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:vertical {{
|
||||||
|
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
|
||||||
|
}}
|
||||||
|
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
|
||||||
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||||
|
""")
|
||||||
layout.addWidget(browser)
|
layout.addWidget(browser)
|
||||||
container.setLayout(layout)
|
container.setLayout(layout)
|
||||||
return container
|
return container
|
||||||
|
|
||||||
|
def _inject_dark_styles(self, html: str) -> str:
|
||||||
|
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||||
|
css = f"""
|
||||||
|
<style>
|
||||||
|
body, p, li {{
|
||||||
|
color: #e8e8f4;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}}
|
||||||
|
h1, h2, h3, h4 {{
|
||||||
|
color: #ffd24a;
|
||||||
|
margin-top: 1.2em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}}
|
||||||
|
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }}
|
||||||
|
h3 {{ font-size: 13pt; color: #6b9eff; }}
|
||||||
|
h4 {{ font-size: 11pt; color: #4ade80; }}
|
||||||
|
b, strong {{ color: #ff90b8; }}
|
||||||
|
code {{
|
||||||
|
background: #1c1c28;
|
||||||
|
color: #ffd24a;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
pre {{
|
||||||
|
background: #1c1c28;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
color: #e8e8f4;
|
||||||
|
}}
|
||||||
|
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||||
|
li {{ margin-bottom: 4px; }}
|
||||||
|
a {{ color: #4adef0; text-decoration: none; }}
|
||||||
|
a:hover {{ text-decoration: underline; }}
|
||||||
|
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||||
|
th {{
|
||||||
|
background: #2a2a3a;
|
||||||
|
color: #ffd24a;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #44446a;
|
||||||
|
text-align: left;
|
||||||
|
}}
|
||||||
|
td {{
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #2a2a3a;
|
||||||
|
color: #e8e8f4;
|
||||||
|
}}
|
||||||
|
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
|
||||||
|
blockquote {{
|
||||||
|
border-left: 3px solid #6b9eff;
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 4px 16px;
|
||||||
|
color: #a0a0b8;
|
||||||
|
background: rgba(107, 158, 255, 0.05);
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
"""
|
||||||
|
return css + html
|
||||||
|
|
||||||
|
|
||||||
# 단독 실행 테스트
|
# 단독 실행 테스트
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+17
-10
@@ -43,11 +43,10 @@ class LeaveCalendarView(QDialog):
|
|||||||
header.addStretch()
|
header.addStretch()
|
||||||
layout.addLayout(header)
|
layout.addLayout(header)
|
||||||
|
|
||||||
# 범례
|
# 범례 (사용 완료 + 예정 분리)
|
||||||
legend = QHBoxLayout()
|
legend = QHBoxLayout()
|
||||||
for label, color in [("🟩 종일(1.0)", "#4caf50"),
|
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
|
||||||
("🟨 반차(0.5)", "#ffc107"),
|
"🔵 예정", "🔘 종일+예정"]:
|
||||||
("🟪 반반차(0.25)", "#9c27b0")]:
|
|
||||||
l = QLabel(label)
|
l = QLabel(label)
|
||||||
l.setStyleSheet(f"padding: 2px 6px;")
|
l.setStyleSheet(f"padding: 2px 6px;")
|
||||||
legend.addWidget(l)
|
legend.addWidget(l)
|
||||||
@@ -76,7 +75,9 @@ class LeaveCalendarView(QDialog):
|
|||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def _mark_dates(self):
|
def _mark_dates(self):
|
||||||
"""연차 사용 일자에 색상 표시."""
|
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
|
||||||
|
from datetime import date as _date
|
||||||
|
today = _date.today()
|
||||||
records = self.db.get_all_leave_records(limit=365)
|
records = self.db.get_all_leave_records(limit=365)
|
||||||
for r in records:
|
for r in records:
|
||||||
try:
|
try:
|
||||||
@@ -85,12 +86,18 @@ class LeaveCalendarView(QDialog):
|
|||||||
continue
|
continue
|
||||||
qd = QDate(d.year, d.month, d.day)
|
qd = QDate(d.year, d.month, d.day)
|
||||||
days = float(r.get('days') or 0)
|
days = float(r.get('days') or 0)
|
||||||
if days >= 1.0:
|
is_planned = d > today
|
||||||
color = QColor("#4caf50")
|
if is_planned:
|
||||||
elif days >= 0.5:
|
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
|
||||||
color = QColor("#ffc107")
|
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
|
||||||
else:
|
else:
|
||||||
color = QColor("#9c27b0")
|
# 과거/오늘 = 사용 완료 색상
|
||||||
|
if days >= 1.0:
|
||||||
|
color = QColor("#4caf50")
|
||||||
|
elif days >= 0.5:
|
||||||
|
color = QColor("#ffc107")
|
||||||
|
else:
|
||||||
|
color = QColor("#9c27b0")
|
||||||
fmt = QTextCharFormat()
|
fmt = QTextCharFormat()
|
||||||
fmt.setBackground(QBrush(color))
|
fmt.setBackground(QBrush(color))
|
||||||
fmt.setForeground(QBrush(QColor("white")))
|
fmt.setForeground(QBrush(QColor("white")))
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ class LeaveView(QDialog):
|
|||||||
cal_button.clicked.connect(self._show_calendar)
|
cal_button.clicked.connect(self._show_calendar)
|
||||||
button_layout.addWidget(cal_button)
|
button_layout.addWidget(cal_button)
|
||||||
|
|
||||||
|
schedule_button = QPushButton("🗓️ 스케줄")
|
||||||
|
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||||
|
schedule_button.clicked.connect(self._show_schedule)
|
||||||
|
button_layout.addWidget(schedule_button)
|
||||||
|
|
||||||
close_button = QPushButton(tr('btn.close'))
|
close_button = QPushButton(tr('btn.close'))
|
||||||
close_button.clicked.connect(self.close)
|
close_button.clicked.connect(self.close)
|
||||||
button_layout.addWidget(close_button)
|
button_layout.addWidget(close_button)
|
||||||
@@ -102,6 +107,13 @@ class LeaveView(QDialog):
|
|||||||
dlg = LeaveCalendarView(self, self.db)
|
dlg = LeaveCalendarView(self, self.db)
|
||||||
dlg.exec_()
|
dlg.exec_()
|
||||||
|
|
||||||
|
def _show_schedule(self):
|
||||||
|
from ui.schedule_view import ScheduleView
|
||||||
|
dlg = ScheduleView(self, self.db)
|
||||||
|
dlg.exec_()
|
||||||
|
# 닫고 돌아오면 잔액/리스트 갱신
|
||||||
|
self.load_data()
|
||||||
|
|
||||||
def load_data(self):
|
def load_data(self):
|
||||||
"""데이터 로드"""
|
"""데이터 로드"""
|
||||||
# 잔액 업데이트
|
# 잔액 업데이트
|
||||||
@@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
|
|||||||
self.date_edit = QDateEdit()
|
self.date_edit = QDateEdit()
|
||||||
self.date_edit.setDate(QDate.currentDate())
|
self.date_edit.setDate(QDate.currentDate())
|
||||||
self.date_edit.setCalendarPopup(True)
|
self.date_edit.setCalendarPopup(True)
|
||||||
|
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
|
||||||
|
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
|
||||||
row1.addWidget(date_label)
|
row1.addWidget(date_label)
|
||||||
row1.addWidget(self.date_edit)
|
row1.addWidget(self.date_edit)
|
||||||
row1.addSpacing(8)
|
row1.addSpacing(8)
|
||||||
@@ -345,6 +359,38 @@ class AddLeaveDialog(QDialog):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 휴일/주말 검증 — 차감 의미 없으므로 차단
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
date_dt = _dt.strptime(date, "%Y-%m-%d")
|
||||||
|
if date_dt.weekday() in (5, 6): # 토/일
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"주말 등록 불가",
|
||||||
|
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self.db.is_holiday(date):
|
||||||
|
holiday = self.db.get_holiday(date)
|
||||||
|
name = (holiday or {}).get('name', '공휴일')
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"공휴일 등록 불가",
|
||||||
|
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
|
||||||
|
existing_min = self.db.get_leave_minutes_for(date)
|
||||||
|
existing_days = existing_min / max(1, self.db.get_work_minutes())
|
||||||
|
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"중복 등록 초과",
|
||||||
|
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
|
||||||
|
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# 확인 메시지
|
# 확인 메시지
|
||||||
hours = days * 8
|
hours = days * 8
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
|
|||||||
+354
-116
@@ -34,25 +34,34 @@ from ui.settings_view import SettingsView
|
|||||||
from ui.break_view import BreakView
|
from ui.break_view import BreakView
|
||||||
from core.notifier import Notifier
|
from core.notifier import Notifier
|
||||||
from utils.system_tray import SystemTrayIcon
|
from utils.system_tray import SystemTrayIcon
|
||||||
|
from utils.time_format import format_hours_minutes
|
||||||
from ui.styles import get_theme, ThemeColors, apply_dark_titlebar
|
from ui.styles import get_theme, ThemeColors, apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""메인 윈도우 클래스"""
|
"""메인 윈도우 클래스"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, db: 'Database' = None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
db: 사전 초기화된 Database 인스턴스. None이면 자체 부트스트랩.
|
||||||
|
(main.py가 backup/crash_handler용으로 먼저 만들고 전달)
|
||||||
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
# 테마 적용
|
# 테마 적용
|
||||||
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
|
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
|
||||||
|
|
||||||
# 데이터베이스 — db_path_override 설정 시 그 경로 사용 (클라우드 동기화 폴더 등)
|
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
|
||||||
bootstrap = Database()
|
if db is not None:
|
||||||
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
|
self.db = db
|
||||||
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
|
|
||||||
self.db = Database(override_path)
|
|
||||||
else:
|
else:
|
||||||
self.db = bootstrap
|
bootstrap = Database()
|
||||||
|
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
|
||||||
|
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
|
||||||
|
self.db = Database(override_path)
|
||||||
|
else:
|
||||||
|
self.db = bootstrap
|
||||||
self.event_monitor = EventMonitor()
|
self.event_monitor = EventMonitor()
|
||||||
|
|
||||||
# 언어 초기화 (설정값 반영)
|
# 언어 초기화 (설정값 반영)
|
||||||
@@ -81,6 +90,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.notifier = Notifier(self, db=self.db)
|
self.notifier = Notifier(self, db=self.db)
|
||||||
self.notifier.notification_signal.connect(self.show_notification)
|
self.notifier.notification_signal.connect(self.show_notification)
|
||||||
|
|
||||||
|
# 도전과제 정의 동기화 (실패는 silent — 핵심 기능 아님)
|
||||||
|
try:
|
||||||
|
from core.achievements import sync_definitions_to_db
|
||||||
|
sync_definitions_to_db(self.db)
|
||||||
|
except Exception as e:
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"achievements sync failed: {e}")
|
||||||
|
|
||||||
# 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
|
# 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
|
||||||
from ui.controllers.lock_monitor import LockMonitor
|
from ui.controllers.lock_monitor import LockMonitor
|
||||||
from ui.controllers.auto_lunch import AutoLunchManager
|
from ui.controllers.auto_lunch import AutoLunchManager
|
||||||
@@ -136,12 +153,41 @@ class MainWindow(QMainWindow):
|
|||||||
self._lock_timer.timeout.connect(self._check_screen_lock)
|
self._lock_timer.timeout.connect(self._check_screen_lock)
|
||||||
self._lock_timer.start(5000)
|
self._lock_timer.start(5000)
|
||||||
|
|
||||||
|
# 종료 시 타이머 정리 — aboutToQuit은 QApplication 종료 직전에만 1회 fire.
|
||||||
|
# 모든 종료 경로(트레이 메뉴/Discord/언어변경 등)를 한 곳에서 커버.
|
||||||
|
try:
|
||||||
|
qapp = QApplication.instance()
|
||||||
|
if qapp is not None:
|
||||||
|
qapp.aboutToQuit.connect(self._on_app_quit)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 초기 데이터 로드
|
# 초기 데이터 로드
|
||||||
self.load_today_data()
|
self.load_today_data()
|
||||||
|
|
||||||
# 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시)
|
# 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시)
|
||||||
QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True))
|
QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True))
|
||||||
|
|
||||||
|
def _on_app_quit(self) -> None:
|
||||||
|
"""QApplication.aboutToQuit 핸들러 — 타이머 정지 + 트레이 숨김.
|
||||||
|
|
||||||
|
in-flight 1Hz/5s tick이 부분 파괴된 객체에 대해 fire하는 것 방지.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if hasattr(self, 'timer') and self.timer is not None:
|
||||||
|
self.timer.stop()
|
||||||
|
if hasattr(self, '_lock_timer') and self._lock_timer is not None:
|
||||||
|
self._lock_timer.stop()
|
||||||
|
if hasattr(self, 'notifier') and self.notifier is not None:
|
||||||
|
# Notifier 내부 1분 timer
|
||||||
|
if hasattr(self.notifier, 'timer'):
|
||||||
|
self.notifier.timer.stop()
|
||||||
|
if hasattr(self, 'tray_icon') and self.tray_icon is not None:
|
||||||
|
self.tray_icon.hide()
|
||||||
|
except Exception:
|
||||||
|
# 정리 실패해도 종료는 진행 — Qt가 결국 다 cleanup
|
||||||
|
pass
|
||||||
|
|
||||||
def _check_screen_lock(self):
|
def _check_screen_lock(self):
|
||||||
"""LockMonitor 컨트롤러로 위임 (5초 polling)."""
|
"""LockMonitor 컨트롤러로 위임 (5초 polling)."""
|
||||||
self._lock_monitor.tick()
|
self._lock_monitor.tick()
|
||||||
@@ -329,6 +375,7 @@ class MainWindow(QMainWindow):
|
|||||||
stats_button = QPushButton(tr('menu.stats'))
|
stats_button = QPushButton(tr('menu.stats'))
|
||||||
calendar_button = QPushButton(tr('menu.calendar'))
|
calendar_button = QPushButton(tr('menu.calendar'))
|
||||||
report_button = QPushButton(tr('menu.daily_report'))
|
report_button = QPushButton(tr('menu.daily_report'))
|
||||||
|
achievements_button = QPushButton("🏆 도전과제")
|
||||||
help_button = QPushButton(tr('menu.help'))
|
help_button = QPushButton(tr('menu.help'))
|
||||||
settings_button = QPushButton(tr('menu.settings'))
|
settings_button = QPushButton(tr('menu.settings'))
|
||||||
|
|
||||||
@@ -340,13 +387,15 @@ class MainWindow(QMainWindow):
|
|||||||
(settings_button, 'menu.settings')]:
|
(settings_button, 'menu.settings')]:
|
||||||
register(btn, key)
|
register(btn, key)
|
||||||
|
|
||||||
for btn in [stats_button, calendar_button, report_button, help_button, settings_button]:
|
for btn in [stats_button, calendar_button, report_button,
|
||||||
|
achievements_button, help_button, settings_button]:
|
||||||
bottom_layout.addWidget(btn)
|
bottom_layout.addWidget(btn)
|
||||||
|
|
||||||
# 버튼 연결
|
# 버튼 연결
|
||||||
stats_button.clicked.connect(self.show_stats)
|
stats_button.clicked.connect(self.show_stats)
|
||||||
calendar_button.clicked.connect(self.show_calendar)
|
calendar_button.clicked.connect(self.show_calendar)
|
||||||
report_button.clicked.connect(self.generate_daily_report)
|
report_button.clicked.connect(self.generate_daily_report)
|
||||||
|
achievements_button.clicked.connect(self.show_achievements)
|
||||||
help_button.clicked.connect(self.show_help)
|
help_button.clicked.connect(self.show_help)
|
||||||
settings_button.clicked.connect(self.show_settings)
|
settings_button.clicked.connect(self.show_settings)
|
||||||
|
|
||||||
@@ -582,6 +631,21 @@ class MainWindow(QMainWindow):
|
|||||||
self.clock_out_button.setText("✅ 퇴근하기")
|
self.clock_out_button.setText("✅ 퇴근하기")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
|
||||||
|
today_str = datetime.now().date().isoformat()
|
||||||
|
if self.db.has_full_day_leave(today_str):
|
||||||
|
self.is_clocked_in = False
|
||||||
|
self.clock_out_button.setEnabled(False)
|
||||||
|
# 점심/저녁/외출/잔액 갱신만 수행
|
||||||
|
self.lunch_button.setChecked(False)
|
||||||
|
self.update_lunch_status()
|
||||||
|
self.dinner_button.setChecked(False)
|
||||||
|
self.update_dinner_status()
|
||||||
|
self.update_break_status()
|
||||||
|
self.update_overtime_balance()
|
||||||
|
self.update_leave_balance()
|
||||||
|
return
|
||||||
|
|
||||||
# 출근 기록 없음 - 자동 감지 시도
|
# 출근 기록 없음 - 자동 감지 시도
|
||||||
auto_clock_in = self.event_monitor.get_work_start_time()
|
auto_clock_in = self.event_monitor.get_work_start_time()
|
||||||
|
|
||||||
@@ -652,6 +716,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.start_new_workday(now)
|
self.start_new_workday(now)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 종일 연차일 — 출근 안 한 상태에서 전용 카드만 표시 후 종료.
|
||||||
|
# (수동 출근 override는 handle_clock_in 경로에서 별도 처리)
|
||||||
|
if not self.is_clocked_in:
|
||||||
|
today_str = now.date().isoformat()
|
||||||
|
if self.db.has_full_day_leave(today_str):
|
||||||
|
self._render_full_day_leave_state(today_str)
|
||||||
|
return
|
||||||
|
|
||||||
# 출근하지 않았으면 여기서 종료
|
# 출근하지 않았으면 여기서 종료
|
||||||
if not self.is_clocked_in or not self.clock_in_time:
|
if not self.is_clocked_in or not self.clock_in_time:
|
||||||
return
|
return
|
||||||
@@ -679,21 +751,46 @@ class MainWindow(QMainWindow):
|
|||||||
# 총 차감 시간 (추가근무 + 연차/반차)
|
# 총 차감 시간 (추가근무 + 연차/반차)
|
||||||
total_time_off = overtime_used_today + leave_used_today
|
total_time_off = overtime_used_today + leave_used_today
|
||||||
|
|
||||||
# 남은 시간 계산 (외출 시간 반영, 추가근무/반차 사용 시간 차감)
|
# 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름.
|
||||||
remaining = self.time_calc.calculate_remaining_time(
|
is_non_working = self.time_calc.is_non_working_day(now, self.db)
|
||||||
self.clock_in_time,
|
is_full_day_leave = self.db.has_full_day_leave(now.date().isoformat())
|
||||||
include_lunch=self.lunch_break_enabled,
|
is_holiday = is_non_working or is_full_day_leave
|
||||||
include_dinner=self.dinner_break_enabled,
|
|
||||||
current_time=now,
|
if is_holiday:
|
||||||
break_minutes=break_minutes
|
actual_ot, _ = self.time_calc.calculate_holiday_overtime(
|
||||||
)
|
self.clock_in_time, now,
|
||||||
# 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능)
|
include_lunch=self.lunch_break_enabled,
|
||||||
remaining -= timedelta(minutes=total_time_off)
|
include_dinner=self.dinner_break_enabled,
|
||||||
|
break_minutes=break_minutes,
|
||||||
|
)
|
||||||
|
# 사용한 추가근무 차감만 반영 (leave_used는 holiday/override 케이스에서 의미 없음)
|
||||||
|
actual_ot = max(0, actual_ot - overtime_used_today)
|
||||||
|
remaining = -timedelta(minutes=actual_ot)
|
||||||
|
else:
|
||||||
|
# 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 leave_used_today에
|
||||||
|
# 그대로 반영되어 카운트다운이 단축됨.
|
||||||
|
remaining = self.time_calc.calculate_remaining_time(
|
||||||
|
self.clock_in_time,
|
||||||
|
include_lunch=self.lunch_break_enabled,
|
||||||
|
include_dinner=self.dinner_break_enabled,
|
||||||
|
current_time=now,
|
||||||
|
break_minutes=break_minutes
|
||||||
|
)
|
||||||
|
# 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능)
|
||||||
|
remaining -= timedelta(minutes=total_time_off)
|
||||||
|
|
||||||
# 남은 시간 표시 및 추가 근무 처리
|
# 남은 시간 표시 및 추가 근무 처리
|
||||||
if remaining.total_seconds() < 0:
|
if remaining.total_seconds() < 0:
|
||||||
# 추가 근무 중
|
# 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기)
|
||||||
self.remaining_time_group.setTitle("추가 근무 중")
|
day_type = self.time_calc.get_day_type(now, self.db)
|
||||||
|
if is_full_day_leave and not is_non_working:
|
||||||
|
self.remaining_time_group.setTitle("연차 override (전체 적립)")
|
||||||
|
elif day_type == 'weekend':
|
||||||
|
self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
|
||||||
|
elif day_type == 'holiday':
|
||||||
|
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
|
||||||
|
else:
|
||||||
|
self.remaining_time_group.setTitle("추가 근무 중")
|
||||||
# + 기호로 표시
|
# + 기호로 표시
|
||||||
total_seconds = int(abs(remaining.total_seconds()))
|
total_seconds = int(abs(remaining.total_seconds()))
|
||||||
hours = total_seconds // 3600
|
hours = total_seconds // 3600
|
||||||
@@ -716,35 +813,46 @@ class MainWindow(QMainWindow):
|
|||||||
self._set_text_if_changed(self.remaining_time_label, remaining_str)
|
self._set_text_if_changed(self.remaining_time_label, remaining_str)
|
||||||
|
|
||||||
# 진행률 업데이트
|
# 진행률 업데이트
|
||||||
# - 외출 시간: 필요 근무시간 증가 (일을 안 한 시간이므로 더 일해야 함)
|
# 휴일은 정해진 근무시간이 없으므로 게이지 의미 없음 → 100%로 채워둠.
|
||||||
# - 추가근무 사용: 필요 근무시간 감소 (미리 일한 것을 사용하므로 덜 일해도 됨)
|
if is_holiday:
|
||||||
progress = self.time_calc.calculate_work_progress(
|
self.progress_bar.setValue(100)
|
||||||
self.clock_in_time,
|
else:
|
||||||
include_lunch=self.lunch_break_enabled,
|
progress = self.time_calc.calculate_work_progress(
|
||||||
include_dinner=self.dinner_break_enabled,
|
self.clock_in_time,
|
||||||
current_time=now,
|
include_lunch=self.lunch_break_enabled,
|
||||||
break_minutes=break_minutes,
|
include_dinner=self.dinner_break_enabled,
|
||||||
overtime_used_minutes=total_time_off
|
current_time=now,
|
||||||
)
|
break_minutes=break_minutes,
|
||||||
self.progress_bar.setValue(int(progress * 100))
|
overtime_used_minutes=total_time_off
|
||||||
|
)
|
||||||
|
self.progress_bar.setValue(int(progress * 100))
|
||||||
|
|
||||||
# 예상 퇴근 시간 (외출 시간 포함)
|
# 예상 퇴근 시간 (외출 시간 포함)
|
||||||
# 추가근무 사용 시간만큼 일찍 퇴근 가능하므로 실제 퇴근 시간에서 차감
|
# 휴일은 정해진 퇴근 시각이 없음 → 출근 시각을 그대로 표시 (= 즉시 적립 시작 의미)
|
||||||
expected_clock_out = self.time_calc.calculate_clock_out_time(
|
if is_holiday:
|
||||||
self.clock_in_time,
|
expected_clock_out = self.clock_in_time
|
||||||
include_lunch=self.lunch_break_enabled,
|
self._set_text_if_changed(
|
||||||
include_dinner=self.dinner_break_enabled,
|
self.expected_time_label,
|
||||||
break_minutes=break_minutes
|
"휴일 근무 (정해진 퇴근시각 없음)"
|
||||||
)
|
)
|
||||||
# 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김
|
else:
|
||||||
expected_clock_out -= timedelta(minutes=total_time_off)
|
expected_clock_out = self.time_calc.calculate_clock_out_time(
|
||||||
self._set_text_if_changed(
|
self.clock_in_time,
|
||||||
self.expected_time_label,
|
include_lunch=self.lunch_break_enabled,
|
||||||
f"예상 퇴근: {self.format_time(expected_clock_out)}"
|
include_dinner=self.dinner_break_enabled,
|
||||||
)
|
break_minutes=break_minutes
|
||||||
|
)
|
||||||
|
# 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김
|
||||||
|
expected_clock_out -= timedelta(minutes=total_time_off)
|
||||||
|
self._set_text_if_changed(
|
||||||
|
self.expected_time_label,
|
||||||
|
f"예상 퇴근: {self.format_time(expected_clock_out)}"
|
||||||
|
)
|
||||||
|
|
||||||
# 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함)
|
# 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함)
|
||||||
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds())
|
# 휴일이면 "퇴근 30분 전" 알림은 의미 없으므로 플래그로 게이팅.
|
||||||
|
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds(),
|
||||||
|
is_holiday=is_holiday)
|
||||||
|
|
||||||
# 트레이 / 미니 위젯 갱신
|
# 트레이 / 미니 위젯 갱신
|
||||||
if remaining.total_seconds() < 0:
|
if remaining.total_seconds() < 0:
|
||||||
@@ -763,6 +871,35 @@ class MainWindow(QMainWindow):
|
|||||||
date_str = f"{now.year}년 {now.month}월 {now.day}일 {weekday}요일"
|
date_str = f"{now.year}년 {now.month}월 {now.day}일 {weekday}요일"
|
||||||
self.date_label.setText(date_str)
|
self.date_label.setText(date_str)
|
||||||
|
|
||||||
|
def _render_full_day_leave_state(self, today_str: str) -> None:
|
||||||
|
"""오늘이 종일 연차이고 출근 안 한 상태 → 카운트다운 대신 휴가 카드 표시."""
|
||||||
|
records = self.db.get_leave_records_by_date(today_str)
|
||||||
|
# 가장 큰 일수의 leave_type을 대표로 표시 (보통 1.0짜리 1건)
|
||||||
|
if records:
|
||||||
|
primary = max(records, key=lambda r: r.get('days') or 0)
|
||||||
|
label = primary.get('leave_type') or '연차'
|
||||||
|
memo = primary.get('memo') or ''
|
||||||
|
else:
|
||||||
|
label = '연차'
|
||||||
|
memo = ''
|
||||||
|
|
||||||
|
self.remaining_time_group.setTitle("🌴 오늘은 휴가")
|
||||||
|
self.remaining_time_label.setText("연차 사용 중")
|
||||||
|
self.remaining_time_label.setStyleSheet(
|
||||||
|
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
|
||||||
|
)
|
||||||
|
self.progress_bar.setValue(100)
|
||||||
|
if memo:
|
||||||
|
self._set_text_if_changed(self.expected_time_label,
|
||||||
|
f"🌴 {label} — {memo}")
|
||||||
|
else:
|
||||||
|
self._set_text_if_changed(self.expected_time_label,
|
||||||
|
f"🌴 {label}")
|
||||||
|
# 트레이/미니 위젯
|
||||||
|
self.tray_icon.update_time_display("🌴 휴가")
|
||||||
|
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
|
||||||
|
self._mini_widget.update_remaining("🌴 휴가")
|
||||||
|
|
||||||
def toggle_lunch_break(self):
|
def toggle_lunch_break(self):
|
||||||
"""점심시간 토글 — MealController 위임."""
|
"""점심시간 토글 — MealController 위임."""
|
||||||
self._meal.toggle_lunch()
|
self._meal.toggle_lunch()
|
||||||
@@ -1107,16 +1244,14 @@ class MainWindow(QMainWindow):
|
|||||||
unit_minutes = 30
|
unit_minutes = 30
|
||||||
|
|
||||||
if is_non_working_day:
|
if is_non_working_day:
|
||||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 시간 제외)
|
||||||
work_minutes = int(total_hours * 60)
|
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||||
if self.lunch_break_enabled:
|
self.clock_in_time, now,
|
||||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
include_lunch=self.lunch_break_enabled,
|
||||||
if self.dinner_break_enabled:
|
include_dinner=self.dinner_break_enabled,
|
||||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
break_minutes=break_minutes,
|
||||||
work_minutes -= break_minutes
|
unit_minutes=unit_minutes,
|
||||||
work_minutes = max(0, work_minutes)
|
)
|
||||||
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
|
|
||||||
overtime_actual = work_minutes
|
|
||||||
else:
|
else:
|
||||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||||
@@ -1130,7 +1265,6 @@ class MainWindow(QMainWindow):
|
|||||||
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
|
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
|
||||||
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
|
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
|
||||||
if not auto_overtime and overtime_earned > 0:
|
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)
|
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
|
||||||
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
|
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
|
||||||
ask = QMessageBox.question(
|
ask = QMessageBox.question(
|
||||||
@@ -1294,24 +1428,25 @@ class MainWindow(QMainWindow):
|
|||||||
break_minutes = cursor.fetchone()[0] or 0
|
break_minutes = cursor.fetchone()[0] or 0
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# 추가근무 계산
|
# 추가근무 계산 (사용자 설정 적립 단위 적용)
|
||||||
|
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||||
|
if unit_minutes not in (15, 30, 60):
|
||||||
|
unit_minutes = 30
|
||||||
if is_non_working_day:
|
if is_non_working_day:
|
||||||
work_minutes = int(total_hours * 60)
|
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||||
if self.lunch_break_enabled:
|
self.clock_in_time, workday_end,
|
||||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
include_lunch=self.lunch_break_enabled,
|
||||||
if self.dinner_break_enabled:
|
include_dinner=self.dinner_break_enabled,
|
||||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
break_minutes=break_minutes,
|
||||||
work_minutes -= break_minutes
|
unit_minutes=unit_minutes,
|
||||||
# 음수 방지
|
)
|
||||||
work_minutes = max(0, work_minutes)
|
|
||||||
overtime_earned = (work_minutes // 30) * 30
|
|
||||||
overtime_actual = work_minutes
|
|
||||||
else:
|
else:
|
||||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||||
self.clock_in_time, workday_end,
|
self.clock_in_time, workday_end,
|
||||||
include_lunch=self.lunch_break_enabled,
|
include_lunch=self.lunch_break_enabled,
|
||||||
include_dinner=self.dinner_break_enabled,
|
include_dinner=self.dinner_break_enabled,
|
||||||
break_minutes=break_minutes
|
break_minutes=break_minutes,
|
||||||
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
|
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
|
||||||
@@ -1450,26 +1585,26 @@ class MainWindow(QMainWindow):
|
|||||||
lunch_enabled = bool(record.get('lunch_break', False))
|
lunch_enabled = bool(record.get('lunch_break', False))
|
||||||
dinner_enabled = bool(record.get('dinner_break', False))
|
dinner_enabled = bool(record.get('dinner_break', False))
|
||||||
|
|
||||||
|
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||||
|
if unit_minutes not in (15, 30, 60):
|
||||||
|
unit_minutes = 30
|
||||||
if is_non_working_day:
|
if is_non_working_day:
|
||||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (점심/저녁/외출 제외)
|
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 제외)
|
||||||
work_minutes = int(total_hours * 60)
|
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||||
if lunch_enabled:
|
clock_in_time, shutdown_time,
|
||||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
include_lunch=lunch_enabled,
|
||||||
if dinner_enabled:
|
include_dinner=dinner_enabled,
|
||||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
break_minutes=break_minutes,
|
||||||
work_minutes -= break_minutes
|
unit_minutes=unit_minutes,
|
||||||
# 음수 방지
|
)
|
||||||
work_minutes = max(0, work_minutes)
|
|
||||||
# 30분 단위로 절삭
|
|
||||||
overtime_earned = (work_minutes // 30) * 30
|
|
||||||
overtime_actual = work_minutes
|
|
||||||
else:
|
else:
|
||||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||||
clock_in_time, shutdown_time,
|
clock_in_time, shutdown_time,
|
||||||
include_lunch=lunch_enabled,
|
include_lunch=lunch_enabled,
|
||||||
include_dinner=dinner_enabled,
|
include_dinner=dinner_enabled,
|
||||||
break_minutes=break_minutes
|
break_minutes=break_minutes,
|
||||||
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
|
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
|
||||||
@@ -1521,23 +1656,24 @@ class MainWindow(QMainWindow):
|
|||||||
lunch_enabled = bool(record.get('lunch_break', False))
|
lunch_enabled = bool(record.get('lunch_break', False))
|
||||||
dinner_enabled = bool(record.get('dinner_break', False))
|
dinner_enabled = bool(record.get('dinner_break', False))
|
||||||
|
|
||||||
|
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||||
|
if unit_minutes not in (15, 30, 60):
|
||||||
|
unit_minutes = 30
|
||||||
if is_non_working_day:
|
if is_non_working_day:
|
||||||
work_minutes = int(total_hours * 60)
|
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||||
if lunch_enabled:
|
clock_in_time, fallback_time,
|
||||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
include_lunch=lunch_enabled,
|
||||||
if dinner_enabled:
|
include_dinner=dinner_enabled,
|
||||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
break_minutes=break_minutes,
|
||||||
work_minutes -= break_minutes
|
unit_minutes=unit_minutes,
|
||||||
# 음수 방지
|
)
|
||||||
work_minutes = max(0, work_minutes)
|
|
||||||
overtime_earned = (work_minutes // 30) * 30
|
|
||||||
overtime_actual = work_minutes
|
|
||||||
else:
|
else:
|
||||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||||
clock_in_time, fallback_time,
|
clock_in_time, fallback_time,
|
||||||
include_lunch=lunch_enabled,
|
include_lunch=lunch_enabled,
|
||||||
include_dinner=dinner_enabled,
|
include_dinner=dinner_enabled,
|
||||||
break_minutes=break_minutes
|
break_minutes=break_minutes,
|
||||||
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# DB 업데이트
|
# DB 업데이트
|
||||||
@@ -1557,6 +1693,20 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def manual_clock_in(self):
|
def manual_clock_in(self):
|
||||||
"""수동 출근 시간 입력"""
|
"""수동 출근 시간 입력"""
|
||||||
|
# 종일 연차 등록일이면 override 의도 확인
|
||||||
|
today_str = datetime.now().date().isoformat()
|
||||||
|
if self.db.has_full_day_leave(today_str):
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"종일 연차 등록됨",
|
||||||
|
"오늘은 종일 연차로 등록되어 있습니다.\n"
|
||||||
|
"그래도 출근하시겠어요?\n\n"
|
||||||
|
"(출근 시 모든 시간이 연장근무로 적립됩니다.)",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
|
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
|
||||||
default_time = self.clock_in_time if self.clock_in_time else None
|
default_time = self.clock_in_time if self.clock_in_time else None
|
||||||
|
|
||||||
@@ -1619,9 +1769,21 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def show_calendar(self):
|
def show_calendar(self):
|
||||||
"""캘린더 창 표시"""
|
"""캘린더 창 표시"""
|
||||||
|
# 도전과제 카운터
|
||||||
|
try:
|
||||||
|
cur = self.db.get_setting_int('calendar_view_count', 0)
|
||||||
|
self.db.set_setting('calendar_view_count', str(cur + 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
dialog = CalendarView(self, self.db)
|
dialog = CalendarView(self, self.db)
|
||||||
dialog.exec_()
|
dialog.exec_()
|
||||||
|
|
||||||
|
def show_schedule(self):
|
||||||
|
"""통합 스케줄(휴일+연차+반복) 창 표시."""
|
||||||
|
from ui.schedule_view import ScheduleView
|
||||||
|
dlg = ScheduleView(self, self.db)
|
||||||
|
dlg.exec_()
|
||||||
|
|
||||||
def show_leave_management(self):
|
def show_leave_management(self):
|
||||||
"""휴가 관리 창 표시"""
|
"""휴가 관리 창 표시"""
|
||||||
dialog = LeaveView(self, self.db)
|
dialog = LeaveView(self, self.db)
|
||||||
@@ -1652,6 +1814,18 @@ class MainWindow(QMainWindow):
|
|||||||
dialog = HelpView(self)
|
dialog = HelpView(self)
|
||||||
dialog.exec_()
|
dialog.exec_()
|
||||||
|
|
||||||
|
def show_achievements(self):
|
||||||
|
"""도전과제 다이얼로그 표시."""
|
||||||
|
from ui.achievements_view import AchievementsView
|
||||||
|
# 진입 시 즉시 한 번 평가 — UI에 최신 진행도 반영
|
||||||
|
try:
|
||||||
|
from core.achievements import evaluate_all
|
||||||
|
evaluate_all(self.db)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
dialog = AchievementsView(self.db, self)
|
||||||
|
dialog.exec_()
|
||||||
|
|
||||||
def _show_meal_context(self, meal_type: str, button, pos):
|
def _show_meal_context(self, meal_type: str, button, pos):
|
||||||
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
|
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
|
||||||
from PyQt5.QtWidgets import QMenu
|
from PyQt5.QtWidgets import QMenu
|
||||||
@@ -1669,13 +1843,14 @@ class MainWindow(QMainWindow):
|
|||||||
default_min = (self.time_calc.lunch_duration_minutes
|
default_min = (self.time_calc.lunch_duration_minutes
|
||||||
if meal_type == 'lunch'
|
if meal_type == 'lunch'
|
||||||
else self.time_calc.dinner_duration_minutes)
|
else self.time_calc.dinner_duration_minutes)
|
||||||
dialog = MealTimeDialog(self, meal_type=meal_type, default_minutes=default_min)
|
# 식사 시각은 출~퇴근 범위 내여야 함. 호출 시점은 항상 출근 후·미퇴근 상태.
|
||||||
|
dialog = MealTimeDialog(
|
||||||
|
self, meal_type=meal_type, default_minutes=default_min,
|
||||||
|
clock_in_time=self.clock_in_time,
|
||||||
|
)
|
||||||
if dialog.exec_() != QDialog.Accepted:
|
if dialog.exec_() != QDialog.Accepted:
|
||||||
return
|
return
|
||||||
start, end, minutes = dialog.get_times()
|
start, end, minutes = dialog.get_times()
|
||||||
if minutes <= 0:
|
|
||||||
QMessageBox.warning(self, "입력 오류", "종료 시간이 시작보다 빠릅니다.")
|
|
||||||
return
|
|
||||||
today = datetime.now().date().isoformat()
|
today = datetime.now().date().isoformat()
|
||||||
self.db.add_meal_record(today, start, end, meal_type=meal_type)
|
self.db.add_meal_record(today, start, end, meal_type=meal_type)
|
||||||
# 자동 토글 ON
|
# 자동 토글 ON
|
||||||
@@ -1706,8 +1881,10 @@ class MainWindow(QMainWindow):
|
|||||||
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
|
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
|
||||||
if not hasattr(self, 'today_summary_card'):
|
if not hasattr(self, 'today_summary_card'):
|
||||||
return
|
return
|
||||||
# 점심 시간 계산 (lunch_break_enabled면 설정값, 아니면 0)
|
# 점심/저녁 시간 (플래그 ON이면 설정값, 아니면 0)
|
||||||
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
|
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
|
||||||
|
dinner_min = (self.time_calc.dinner_duration_minutes
|
||||||
|
if getattr(self, 'dinner_break_enabled', False) else 0)
|
||||||
|
|
||||||
# 추정 급여 (옵션)
|
# 추정 급여 (옵션)
|
||||||
salary_text = ""
|
salary_text = ""
|
||||||
@@ -1726,6 +1903,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.today_summary_card.show_summary(
|
self.today_summary_card.show_summary(
|
||||||
total_hours=total_hours,
|
total_hours=total_hours,
|
||||||
lunch_minutes=lunch_min,
|
lunch_minutes=lunch_min,
|
||||||
|
dinner_minutes=dinner_min,
|
||||||
break_minutes=break_minutes,
|
break_minutes=break_minutes,
|
||||||
overtime_actual=overtime_actual,
|
overtime_actual=overtime_actual,
|
||||||
overtime_earned=overtime_earned,
|
overtime_earned=overtime_earned,
|
||||||
@@ -1745,8 +1923,9 @@ class MainWindow(QMainWindow):
|
|||||||
from utils import discord_webhook
|
from utils import discord_webhook
|
||||||
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
|
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
|
||||||
self.db.log_notification('discord', 'clock_in', success=ok)
|
self.db.log_notification('discord', 'clock_in', success=ok)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"discord clock_in push failed: {e}")
|
||||||
|
|
||||||
def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned):
|
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':
|
if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true':
|
||||||
@@ -1761,8 +1940,9 @@ class MainWindow(QMainWindow):
|
|||||||
total_hours, overtime_actual, overtime_earned,
|
total_hours, overtime_actual, overtime_earned,
|
||||||
)
|
)
|
||||||
self.db.log_notification('discord', 'clock_out', success=ok)
|
self.db.log_notification('discord', 'clock_out', success=ok)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"discord clock_out push failed: {e}")
|
||||||
|
|
||||||
def check_for_updates(self, silent: bool = False):
|
def check_for_updates(self, silent: bool = False):
|
||||||
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
||||||
@@ -2020,10 +2200,52 @@ class MainWindow(QMainWindow):
|
|||||||
if self.is_clocked_in and self.clock_in_time:
|
if self.is_clocked_in and self.clock_in_time:
|
||||||
self.update_display()
|
self.update_display()
|
||||||
|
|
||||||
|
def _append_meal_section(self, report_lines, today: str, label: str,
|
||||||
|
flag: bool, records: list, default_min: int) -> None:
|
||||||
|
"""일일 보고서의 점심/저녁 섹션 출력.
|
||||||
|
|
||||||
|
실측 기록(`break_records.break_type='lunch'/'dinner'`)이 있으면
|
||||||
|
시작/종료 시각과 실측 분을 표시하고, 플래그만 켜진 경우엔
|
||||||
|
설정값(`lunch_duration_minutes`/`dinner_duration_minutes`)을 표시.
|
||||||
|
"""
|
||||||
|
actual_min = sum((r.get('total_minutes') or 0) for r in records)
|
||||||
|
if records and actual_min > 0:
|
||||||
|
report_lines.append(f"{label}: {format_hours_minutes(actual_min)} (실측)")
|
||||||
|
for r in records:
|
||||||
|
try:
|
||||||
|
start_dt = datetime.fromisoformat(f"{today} {r['break_out']}")
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if r.get('break_in'):
|
||||||
|
try:
|
||||||
|
end_dt = datetime.fromisoformat(f"{today} {r['break_in']}")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if end_dt < start_dt:
|
||||||
|
end_dt += timedelta(days=1)
|
||||||
|
dur = int((end_dt - start_dt).total_seconds() / 60)
|
||||||
|
report_lines.append(
|
||||||
|
f" - {self.format_time(start_dt)} ~ {self.format_time(end_dt)} ({dur}분)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
report_lines.append(f" - {self.format_time(start_dt)} ~ 진행중")
|
||||||
|
elif flag:
|
||||||
|
report_lines.append(f"{label}: 포함 ({format_hours_minutes(default_min)})")
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
report_lines.append("")
|
||||||
|
|
||||||
def generate_daily_report(self):
|
def generate_daily_report(self):
|
||||||
"""오늘 하루 근무 내역 보고서 생성 및 클립보드 복사"""
|
"""오늘 하루 근무 내역 보고서 생성 및 클립보드 복사"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
# 도전과제 카운터 (보고서 생성 횟수)
|
||||||
|
try:
|
||||||
|
cur = self.db.get_setting_int('daily_report_count', 0)
|
||||||
|
self.db.set_setting('daily_report_count', str(cur + 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
|
|
||||||
# 오늘의 근무 기록 조회
|
# 오늘의 근무 기록 조회
|
||||||
@@ -2063,16 +2285,20 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
report_lines.append("")
|
report_lines.append("")
|
||||||
|
|
||||||
# 외출 시간
|
# 외출 / 점심 / 저녁 분리 — break_type 으로 구분 (v2.7.0+)
|
||||||
break_minutes = self.db.get_total_break_minutes_today()
|
# 'break'(또는 NULL) = 일반 외출, 'lunch'/'dinner' = 실측 식사 기록
|
||||||
if break_minutes > 0:
|
all_break_records = self.db.get_today_break_records()
|
||||||
break_hours = break_minutes // 60
|
real_break_records = [b for b in all_break_records
|
||||||
break_mins = break_minutes % 60
|
if (b.get('break_type') or 'break') == 'break']
|
||||||
report_lines.append(f"🚶 외출 시간: {break_hours}시간 {break_mins}분")
|
lunch_records = [b for b in all_break_records if b.get('break_type') == 'lunch']
|
||||||
|
dinner_records = [b for b in all_break_records if b.get('break_type') == 'dinner']
|
||||||
|
|
||||||
# 외출 상세 내역
|
# 외출 시간 (식사 제외)
|
||||||
break_records = self.db.get_today_break_records()
|
real_break_minutes = sum((b.get('total_minutes') or 0) for b in real_break_records)
|
||||||
for br in break_records:
|
has_active_break = any(not b.get('break_in') for b in real_break_records)
|
||||||
|
if real_break_minutes > 0 or has_active_break:
|
||||||
|
report_lines.append(f"🚶 외출 시간: {format_hours_minutes(real_break_minutes)}")
|
||||||
|
for br in real_break_records:
|
||||||
break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}")
|
break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}")
|
||||||
if br['break_in']:
|
if br['break_in']:
|
||||||
break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}")
|
break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}")
|
||||||
@@ -2080,18 +2306,30 @@ class MainWindow(QMainWindow):
|
|||||||
if break_in_time < break_out_time:
|
if break_in_time < break_out_time:
|
||||||
break_in_time += timedelta(days=1)
|
break_in_time += timedelta(days=1)
|
||||||
duration = int((break_in_time - break_out_time).total_seconds() / 60)
|
duration = int((break_in_time - break_out_time).total_seconds() / 60)
|
||||||
reason = f" ({br['reason']})" if br['reason'] else ""
|
reason = f" ({br['reason']})" if br.get('reason') else ""
|
||||||
report_lines.append(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}분){reason}")
|
report_lines.append(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}분){reason}")
|
||||||
else:
|
else:
|
||||||
reason = f" ({br['reason']})" if br['reason'] else ""
|
reason = f" ({br['reason']})" if br.get('reason') else ""
|
||||||
report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{reason}")
|
report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{reason}")
|
||||||
report_lines.append("")
|
report_lines.append("")
|
||||||
|
|
||||||
# 점심시간
|
# 점심시간
|
||||||
lunch_break = work_record.get('lunch_break', False)
|
lunch_flag = bool(work_record.get('lunch_break', False))
|
||||||
if lunch_break:
|
if lunch_flag or lunch_records:
|
||||||
report_lines.append(f"🍱 점심시간: 포함 (1시간)")
|
self._append_meal_section(
|
||||||
report_lines.append("")
|
report_lines, today, '🍱 점심시간',
|
||||||
|
lunch_flag, lunch_records,
|
||||||
|
self.time_calc.lunch_duration_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 저녁시간
|
||||||
|
dinner_flag = bool(work_record.get('dinner_break', False))
|
||||||
|
if dinner_flag or dinner_records:
|
||||||
|
self._append_meal_section(
|
||||||
|
report_lines, today, '🍽️ 저녁시간',
|
||||||
|
dinner_flag, dinner_records,
|
||||||
|
self.time_calc.dinner_duration_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
# 추가 근무 적립
|
# 추가 근무 적립
|
||||||
if work_record['overtime_minutes'] and work_record['overtime_minutes'] > 0:
|
if work_record['overtime_minutes'] and work_record['overtime_minutes'] > 0:
|
||||||
|
|||||||
+90
-22
@@ -3,44 +3,70 @@
|
|||||||
|
|
||||||
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
||||||
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
||||||
|
|
||||||
|
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
|
||||||
|
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QPushButton, QTimeEdit, QMessageBox)
|
QPushButton, QTimeEdit, QMessageBox)
|
||||||
from PyQt5.QtCore import QTime, Qt
|
from PyQt5.QtCore import QTime
|
||||||
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
class MealTimeDialog(QDialog):
|
class MealTimeDialog(QDialog):
|
||||||
"""점심/저녁 실제 시작·종료 시간 입력."""
|
"""점심/저녁 실제 시작·종료 시간 입력.
|
||||||
|
|
||||||
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
|
Args:
|
||||||
|
parent: 부모 위젯
|
||||||
|
meal_type: 'lunch' 또는 'dinner'
|
||||||
|
default_minutes: 안내 문구에 표시할 기본 차감 분
|
||||||
|
clock_in_time: 출근 datetime (옵션). 주어지면 식사가 출근 이후인지 검증.
|
||||||
|
clock_out_time: 퇴근 datetime (옵션, 보통 미퇴근 상태). 주어지면 퇴근 이전인지 검증.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60,
|
||||||
|
clock_in_time: Optional[datetime] = None,
|
||||||
|
clock_out_time: Optional[datetime] = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.meal_type = meal_type
|
self.meal_type = meal_type
|
||||||
|
self._clock_in = clock_in_time
|
||||||
|
self._clock_out = clock_out_time
|
||||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setFixedSize(360, 220)
|
self.setFixedSize(380, 260)
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(10)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
|
||||||
|
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||||
|
if clock_in_time is not None:
|
||||||
|
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
||||||
|
info = QLabel(info_text)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
|
# 합리적 기본값: 출근 이후로 보정
|
||||||
|
default_start_h = 12 if meal_type == 'lunch' else 18
|
||||||
|
default_end_h = 13 if meal_type == 'lunch' else 19
|
||||||
|
if clock_in_time is not None and clock_in_time.hour >= default_start_h:
|
||||||
|
# 출근이 이미 점심/저녁 기본 시각을 지났으면 출근 직후를 기본값으로
|
||||||
|
default_start_h = (clock_in_time.hour + 1) % 24
|
||||||
|
default_end_h = (default_start_h + 1) % 24
|
||||||
|
|
||||||
# 시작
|
# 시작
|
||||||
start_row = QHBoxLayout()
|
start_row = QHBoxLayout()
|
||||||
start_row.addWidget(QLabel("시작:"))
|
start_row.addWidget(QLabel("시작:"))
|
||||||
self.start_edit = QTimeEdit()
|
self.start_edit = QTimeEdit()
|
||||||
self.start_edit.setDisplayFormat("HH:mm")
|
self.start_edit.setDisplayFormat("HH:mm")
|
||||||
# 기본값: 점심=12:00, 저녁=18:00
|
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||||
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.addWidget(self.start_edit)
|
||||||
start_row.addStretch()
|
start_row.addStretch()
|
||||||
layout.addLayout(start_row)
|
layout.addLayout(start_row)
|
||||||
@@ -50,8 +76,7 @@ class MealTimeDialog(QDialog):
|
|||||||
end_row.addWidget(QLabel("종료:"))
|
end_row.addWidget(QLabel("종료:"))
|
||||||
self.end_edit = QTimeEdit()
|
self.end_edit = QTimeEdit()
|
||||||
self.end_edit.setDisplayFormat("HH:mm")
|
self.end_edit.setDisplayFormat("HH:mm")
|
||||||
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
|
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||||
self.end_edit.setTime(default_end)
|
|
||||||
end_row.addWidget(self.end_edit)
|
end_row.addWidget(self.end_edit)
|
||||||
end_row.addStretch()
|
end_row.addStretch()
|
||||||
layout.addLayout(end_row)
|
layout.addLayout(end_row)
|
||||||
@@ -79,30 +104,73 @@ class MealTimeDialog(QDialog):
|
|||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
apply_dark_titlebar(self)
|
apply_dark_titlebar(self)
|
||||||
|
|
||||||
def _update_preview(self):
|
# -------- 내부 시각 계산 --------
|
||||||
s = self.start_edit.time()
|
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
|
||||||
e = self.end_edit.time()
|
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
|
||||||
start_dt = datetime.combine(datetime.today(), s.toPyTime())
|
|
||||||
end_dt = datetime.combine(datetime.today(), e.toPyTime())
|
자정 경계 처리:
|
||||||
|
1) 야간 출근자(clock_in 18시 이후)가 새벽 식사 시각을 입력하면
|
||||||
|
시작이 출근 이전으로 보이는데, 이를 다음날 새벽으로 +1day shift.
|
||||||
|
2) 종료가 시작보다 빠르면 종료에 +1day (점심 12:55→13:30 같은 정상은 영향 X).
|
||||||
|
|
||||||
|
주간 출근자(clock_in 09시)가 08시 입력 시 +1day는 적용하지 않아 검증에서 거절.
|
||||||
|
"""
|
||||||
|
s = self.start_edit.time().toPyTime()
|
||||||
|
e = self.end_edit.time().toPyTime()
|
||||||
|
base_date = (self._clock_in.date() if self._clock_in is not None
|
||||||
|
else datetime.today().date())
|
||||||
|
start_dt = datetime.combine(base_date, s)
|
||||||
|
end_dt = datetime.combine(base_date, e)
|
||||||
|
|
||||||
|
# 야간 출근자 자동 보정
|
||||||
|
if (self._clock_in is not None and start_dt < self._clock_in
|
||||||
|
and self._clock_in.hour >= 18):
|
||||||
|
start_dt += timedelta(days=1)
|
||||||
|
end_dt += timedelta(days=1)
|
||||||
|
|
||||||
if end_dt < start_dt:
|
if end_dt < start_dt:
|
||||||
end_dt += timedelta(days=1)
|
end_dt += timedelta(days=1)
|
||||||
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
||||||
if minutes <= 0:
|
return start_dt, end_dt, minutes
|
||||||
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
|
|
||||||
|
def _update_preview(self):
|
||||||
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
|
if not ok:
|
||||||
|
self.preview.setText(f"⚠️ {reason}")
|
||||||
self.preview.setStyleSheet("color: #f44336;")
|
self.preview.setStyleSheet("color: #f44336;")
|
||||||
else:
|
else:
|
||||||
self.preview.setText(f"총 {minutes}분")
|
self.preview.setText(f"총 {minutes}분")
|
||||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||||
|
|
||||||
|
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||||
|
minutes: int) -> tuple[bool, str]:
|
||||||
|
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||||
|
if minutes <= 0:
|
||||||
|
return False, "시작이 종료보다 늦습니다"
|
||||||
|
if minutes > 8 * 60:
|
||||||
|
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||||
|
return False, "식사 시간이 8시간을 초과합니다"
|
||||||
|
if self._clock_in is not None and start_dt < self._clock_in:
|
||||||
|
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다"
|
||||||
|
if self._clock_out is not None and end_dt > self._clock_out:
|
||||||
|
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def accept(self):
|
||||||
|
"""저장 버튼: 검증 실패 시 다이얼로그 닫지 않고 메시지박스."""
|
||||||
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
|
if not ok:
|
||||||
|
QMessageBox.warning(self, "입력 오류", reason)
|
||||||
|
return
|
||||||
|
super().accept()
|
||||||
|
|
||||||
def get_times(self) -> tuple[str, str, int]:
|
def get_times(self) -> tuple[str, str, int]:
|
||||||
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
||||||
s = self.start_edit.time().toPyTime()
|
s = self.start_edit.time().toPyTime()
|
||||||
e = self.end_edit.time().toPyTime()
|
e = self.end_edit.time().toPyTime()
|
||||||
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
||||||
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
||||||
s_dt = datetime.combine(datetime.today(), s)
|
_, _, minutes = self._resolve_meal_window()
|
||||||
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
|
return start_str, end_str, minutes
|
||||||
|
|||||||
+62
-24
@@ -12,16 +12,20 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
|
|||||||
QMessageBox, QGroupBox)
|
QMessageBox, QGroupBox)
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
# (label, work_minutes, lunch_minutes)
|
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
|
||||||
|
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
|
||||||
|
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
|
||||||
|
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
|
||||||
WORK_PRESETS = [
|
WORK_PRESETS = [
|
||||||
("표준 8시간 (점심 60분)", 480, 60),
|
('onboarding.preset.standard_8h', 480, 60, 0),
|
||||||
("단축근무 7시간 30분 (점심 30분)", 450, 30),
|
('onboarding.preset.short_7h30m', 450, 30, 0),
|
||||||
("단축근무 7시간 (점심 60분)", 420, 60),
|
('onboarding.preset.short_7h', 420, 60, 0),
|
||||||
("단축근무 6시간 (점심 30분)", 360, 30),
|
('onboarding.preset.short_6h', 360, 30, 0),
|
||||||
("반일 4시간 (점심 0분)", 240, 0),
|
('onboarding.preset.half_4h', 240, 0, 0),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -52,48 +56,72 @@ class WorkPatternPage(QWizardPage):
|
|||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.button_group = QButtonGroup(self)
|
self.button_group = QButtonGroup(self)
|
||||||
for i, (label, _, _) in enumerate(WORK_PRESETS):
|
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
|
||||||
rb = QRadioButton(label)
|
rb = QRadioButton(tr(key))
|
||||||
self.button_group.addButton(rb, i)
|
self.button_group.addButton(rb, i)
|
||||||
layout.addWidget(rb)
|
layout.addWidget(rb)
|
||||||
if i == 0:
|
if i == 0:
|
||||||
rb.setChecked(True)
|
rb.setChecked(True)
|
||||||
|
|
||||||
# 사용자 정의
|
# 사용자 정의
|
||||||
custom_box = QGroupBox("사용자 정의")
|
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
|
||||||
custom_layout = QHBoxLayout()
|
custom_layout = QVBoxLayout()
|
||||||
self.custom_radio = QRadioButton("직접 입력:")
|
custom_layout.setSpacing(4)
|
||||||
|
|
||||||
|
suffix_h = tr('onboarding.preset.suffix_hours')
|
||||||
|
suffix_m = tr('onboarding.preset.suffix_minutes')
|
||||||
|
|
||||||
|
row1 = QHBoxLayout()
|
||||||
|
self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio'))
|
||||||
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
|
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
|
||||||
self.hours_spin = QSpinBox()
|
self.hours_spin = QSpinBox()
|
||||||
self.hours_spin.setRange(0, 12)
|
self.hours_spin.setRange(0, 12)
|
||||||
self.hours_spin.setValue(8)
|
self.hours_spin.setValue(8)
|
||||||
self.hours_spin.setSuffix(" 시간")
|
self.hours_spin.setSuffix(suffix_h)
|
||||||
self.minutes_spin = QSpinBox()
|
self.minutes_spin = QSpinBox()
|
||||||
self.minutes_spin.setRange(0, 59)
|
self.minutes_spin.setRange(0, 59)
|
||||||
self.minutes_spin.setSingleStep(15)
|
self.minutes_spin.setSingleStep(15)
|
||||||
self.minutes_spin.setSuffix(" 분")
|
self.minutes_spin.setSuffix(suffix_m)
|
||||||
|
row1.addWidget(self.custom_radio)
|
||||||
|
row1.addWidget(self.hours_spin)
|
||||||
|
row1.addWidget(self.minutes_spin)
|
||||||
|
row1.addStretch()
|
||||||
|
custom_layout.addLayout(row1)
|
||||||
|
|
||||||
|
row2 = QHBoxLayout()
|
||||||
self.lunch_spin = QSpinBox()
|
self.lunch_spin = QSpinBox()
|
||||||
self.lunch_spin.setRange(0, 120)
|
self.lunch_spin.setRange(0, 120)
|
||||||
self.lunch_spin.setSingleStep(5)
|
self.lunch_spin.setSingleStep(5)
|
||||||
self.lunch_spin.setValue(60)
|
self.lunch_spin.setValue(60)
|
||||||
self.lunch_spin.setPrefix("점심 ")
|
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
|
||||||
self.lunch_spin.setSuffix(" 분")
|
self.lunch_spin.setSuffix(suffix_m)
|
||||||
custom_layout.addWidget(self.custom_radio)
|
self.dinner_spin = QSpinBox()
|
||||||
custom_layout.addWidget(self.hours_spin)
|
self.dinner_spin.setRange(0, 120)
|
||||||
custom_layout.addWidget(self.minutes_spin)
|
self.dinner_spin.setSingleStep(5)
|
||||||
custom_layout.addWidget(self.lunch_spin)
|
self.dinner_spin.setValue(0)
|
||||||
custom_layout.addStretch()
|
self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix'))
|
||||||
|
self.dinner_spin.setSuffix(suffix_m)
|
||||||
|
self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip'))
|
||||||
|
row2.addSpacing(20)
|
||||||
|
row2.addWidget(self.lunch_spin)
|
||||||
|
row2.addWidget(self.dinner_spin)
|
||||||
|
row2.addStretch()
|
||||||
|
custom_layout.addLayout(row2)
|
||||||
|
|
||||||
custom_box.setLayout(custom_layout)
|
custom_box.setLayout(custom_layout)
|
||||||
layout.addWidget(custom_box)
|
layout.addWidget(custom_box)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
def selected_minutes(self):
|
def selected_minutes(self):
|
||||||
|
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
|
||||||
idx = self.button_group.checkedId()
|
idx = self.button_group.checkedId()
|
||||||
if 0 <= idx < len(WORK_PRESETS):
|
if 0 <= idx < len(WORK_PRESETS):
|
||||||
_, wm, lm = WORK_PRESETS[idx]
|
_, wm, lm, dm = WORK_PRESETS[idx]
|
||||||
return wm, lm
|
return wm, lm, dm
|
||||||
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
|
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
|
||||||
|
self.lunch_spin.value(),
|
||||||
|
self.dinner_spin.value())
|
||||||
|
|
||||||
|
|
||||||
class ClockInDetectionPage(QWizardPage):
|
class ClockInDetectionPage(QWizardPage):
|
||||||
@@ -232,6 +260,13 @@ class DiscordPage(QWizardPage):
|
|||||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||||
return
|
return
|
||||||
from utils import discord_webhook
|
from utils import discord_webhook
|
||||||
|
if not discord_webhook.is_valid_webhook_url(url):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "URL 형식 오류",
|
||||||
|
"Discord 웹훅 URL 형식이 아닙니다.\n"
|
||||||
|
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
|
||||||
|
)
|
||||||
|
return
|
||||||
ok = discord_webhook.send_test(url)
|
ok = discord_webhook.send_test(url)
|
||||||
if ok:
|
if ok:
|
||||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||||
@@ -286,7 +321,7 @@ class OnboardingWizard(QWizard):
|
|||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
# 1. 근무 패턴
|
# 1. 근무 패턴
|
||||||
wm, lm = self.work_page.selected_minutes()
|
wm, lm, dm = self.work_page.selected_minutes()
|
||||||
if wm < 30:
|
if wm < 30:
|
||||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||||
return
|
return
|
||||||
@@ -294,6 +329,7 @@ class OnboardingWizard(QWizard):
|
|||||||
settings = {
|
settings = {
|
||||||
'work_minutes': wm,
|
'work_minutes': wm,
|
||||||
'lunch_duration_minutes': lm,
|
'lunch_duration_minutes': lm,
|
||||||
|
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
|
||||||
'annual_leave_days': self.leave_page.leave_spin.value(),
|
'annual_leave_days': self.leave_page.leave_spin.value(),
|
||||||
'annual_leave_total': self.leave_page.leave_spin.value(),
|
'annual_leave_total': self.leave_page.leave_spin.value(),
|
||||||
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
|
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
|
||||||
@@ -301,6 +337,8 @@ class OnboardingWizard(QWizard):
|
|||||||
'overtime_rate': self.leave_page.rate_combo.currentData(),
|
'overtime_rate': self.leave_page.rate_combo.currentData(),
|
||||||
'onboarding_completed': True,
|
'onboarding_completed': True,
|
||||||
}
|
}
|
||||||
|
if dm > 0:
|
||||||
|
settings['dinner_duration_minutes'] = dm
|
||||||
|
|
||||||
# 2. 출근 감지 방식
|
# 2. 출근 감지 방식
|
||||||
mode = self.detect_page.detection_mode()
|
mode = self.detect_page.detection_mode()
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
반복 연차 등록/관리 다이얼로그.
|
||||||
|
|
||||||
|
지원: 매주/격주 요일, 매월 N일.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QComboBox, QDateEdit, QSpinBox,
|
||||||
|
QDoubleSpinBox, QLineEdit, QGroupBox,
|
||||||
|
QListWidget, QListWidgetItem, QMessageBox,
|
||||||
|
QCheckBox, QButtonGroup, QRadioButton)
|
||||||
|
from PyQt5.QtCore import QDate, Qt
|
||||||
|
|
||||||
|
from core.recurring_leaves import describe_pattern
|
||||||
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
|
_KO_WEEKDAYS = [('월', 'mon'), ('화', 'tue'), ('수', 'wed'),
|
||||||
|
('목', 'thu'), ('금', 'fri'), ('토', 'sat'), ('일', 'sun')]
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringLeaveDialog(QDialog):
|
||||||
|
"""반복 연차 패턴 추가/삭제."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, db=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.db = db
|
||||||
|
self.setWindowTitle("🔁 반복 연차 관리")
|
||||||
|
self.setMinimumSize(540, 480)
|
||||||
|
self._build_ui()
|
||||||
|
self._reload_list()
|
||||||
|
apply_dark_titlebar(self)
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 기존 패턴 목록
|
||||||
|
list_group = QGroupBox("등록된 반복 패턴")
|
||||||
|
lg = QVBoxLayout()
|
||||||
|
self.list_widget = QListWidget()
|
||||||
|
self.list_widget.setMinimumHeight(160)
|
||||||
|
lg.addWidget(self.list_widget)
|
||||||
|
del_btn = QPushButton("선택 삭제")
|
||||||
|
del_btn.clicked.connect(self._delete_selected)
|
||||||
|
lg.addWidget(del_btn)
|
||||||
|
list_group.setLayout(lg)
|
||||||
|
layout.addWidget(list_group)
|
||||||
|
|
||||||
|
# 신규 등록
|
||||||
|
add_group = QGroupBox("신규 패턴 추가")
|
||||||
|
ag = QVBoxLayout()
|
||||||
|
|
||||||
|
# 패턴 종류
|
||||||
|
kind_row = QHBoxLayout()
|
||||||
|
kind_row.addWidget(QLabel("주기:"))
|
||||||
|
self.kind_group = QButtonGroup(self)
|
||||||
|
self.rb_weekly = QRadioButton("매주")
|
||||||
|
self.rb_weekly.setChecked(True)
|
||||||
|
self.rb_biweekly = QRadioButton("격주")
|
||||||
|
self.rb_monthly = QRadioButton("매월 N일")
|
||||||
|
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||||
|
self.kind_group.addButton(rb)
|
||||||
|
kind_row.addWidget(rb)
|
||||||
|
kind_row.addStretch()
|
||||||
|
ag.addLayout(kind_row)
|
||||||
|
|
||||||
|
# 요일 체크박스 (weekly/biweekly)
|
||||||
|
wd_row = QHBoxLayout()
|
||||||
|
wd_row.addWidget(QLabel("요일:"))
|
||||||
|
self.weekday_checks = []
|
||||||
|
for ko, en in _KO_WEEKDAYS:
|
||||||
|
cb = QCheckBox(ko)
|
||||||
|
self.weekday_checks.append((cb, en))
|
||||||
|
wd_row.addWidget(cb)
|
||||||
|
wd_row.addStretch()
|
||||||
|
ag.addLayout(wd_row)
|
||||||
|
|
||||||
|
# 매월 N일
|
||||||
|
month_row = QHBoxLayout()
|
||||||
|
month_row.addWidget(QLabel("매월:"))
|
||||||
|
self.day_of_month = QSpinBox()
|
||||||
|
self.day_of_month.setRange(1, 31)
|
||||||
|
self.day_of_month.setValue(15)
|
||||||
|
self.day_of_month.setSuffix("일")
|
||||||
|
month_row.addWidget(self.day_of_month)
|
||||||
|
month_row.addStretch()
|
||||||
|
ag.addLayout(month_row)
|
||||||
|
|
||||||
|
# 차감 일수
|
||||||
|
days_row = QHBoxLayout()
|
||||||
|
days_row.addWidget(QLabel("차감:"))
|
||||||
|
self.days_combo = QComboBox()
|
||||||
|
self.days_combo.addItem("1.0일 (종일)", 1.0)
|
||||||
|
self.days_combo.addItem("0.5일 (반차)", 0.5)
|
||||||
|
self.days_combo.addItem("0.25일 (반반차)", 0.25)
|
||||||
|
days_row.addWidget(self.days_combo)
|
||||||
|
days_row.addStretch()
|
||||||
|
ag.addLayout(days_row)
|
||||||
|
|
||||||
|
# 시작/종료 날짜
|
||||||
|
date_row = QHBoxLayout()
|
||||||
|
date_row.addWidget(QLabel("시작:"))
|
||||||
|
self.start_edit = QDateEdit()
|
||||||
|
self.start_edit.setDate(QDate.currentDate())
|
||||||
|
self.start_edit.setCalendarPopup(True)
|
||||||
|
date_row.addWidget(self.start_edit)
|
||||||
|
|
||||||
|
date_row.addWidget(QLabel("종료:"))
|
||||||
|
self.end_edit = QDateEdit()
|
||||||
|
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||||
|
self.end_edit.setCalendarPopup(True)
|
||||||
|
date_row.addWidget(self.end_edit)
|
||||||
|
self.no_end_check = QCheckBox("종료 없음 (무기한)")
|
||||||
|
self.no_end_check.toggled.connect(
|
||||||
|
lambda v: self.end_edit.setEnabled(not v)
|
||||||
|
)
|
||||||
|
date_row.addWidget(self.no_end_check)
|
||||||
|
date_row.addStretch()
|
||||||
|
ag.addLayout(date_row)
|
||||||
|
|
||||||
|
# 메모
|
||||||
|
memo_row = QHBoxLayout()
|
||||||
|
memo_row.addWidget(QLabel("메모:"))
|
||||||
|
self.memo_edit = QLineEdit()
|
||||||
|
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
|
||||||
|
memo_row.addWidget(self.memo_edit)
|
||||||
|
ag.addLayout(memo_row)
|
||||||
|
|
||||||
|
# 추가 버튼
|
||||||
|
add_btn = QPushButton("➕ 추가")
|
||||||
|
add_btn.setObjectName("btn_primary")
|
||||||
|
add_btn.clicked.connect(self._save)
|
||||||
|
ag.addWidget(add_btn)
|
||||||
|
|
||||||
|
add_group.setLayout(ag)
|
||||||
|
layout.addWidget(add_group)
|
||||||
|
|
||||||
|
# 닫기
|
||||||
|
close_btn = QPushButton("닫기")
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def _reload_list(self):
|
||||||
|
self.list_widget.clear()
|
||||||
|
for r in self.db.get_recurring_leaves():
|
||||||
|
desc = describe_pattern(r['pattern'])
|
||||||
|
end = r.get('end_date') or '무기한'
|
||||||
|
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||||
|
f"· {r['start_date']} ~ {end}")
|
||||||
|
if r.get('memo'):
|
||||||
|
text += f" — {r['memo']}"
|
||||||
|
item = QListWidgetItem(text)
|
||||||
|
item.setData(Qt.UserRole, r['id'])
|
||||||
|
self.list_widget.addItem(item)
|
||||||
|
|
||||||
|
def _delete_selected(self):
|
||||||
|
item = self.list_widget.currentItem()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
rec_id = item.data(Qt.UserRole)
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "삭제 확인",
|
||||||
|
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.db.delete_recurring_leave(rec_id)
|
||||||
|
self._reload_list()
|
||||||
|
|
||||||
|
def _build_pattern(self) -> str | None:
|
||||||
|
if self.rb_monthly.isChecked():
|
||||||
|
return f"monthly:{self.day_of_month.value()}"
|
||||||
|
# weekly/biweekly
|
||||||
|
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
|
||||||
|
if not chosen:
|
||||||
|
return None
|
||||||
|
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
|
||||||
|
return f"{prefix}:" + ",".join(chosen)
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
pattern = self._build_pattern()
|
||||||
|
if not pattern:
|
||||||
|
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
|
||||||
|
return
|
||||||
|
days = self.days_combo.currentData()
|
||||||
|
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||||
|
start = self.start_edit.date().toString('yyyy-MM-dd')
|
||||||
|
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
|
||||||
|
memo = self.memo_edit.text().strip()
|
||||||
|
|
||||||
|
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||||
|
QMessageBox.information(self, "추가 완료",
|
||||||
|
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
|
||||||
|
self.memo_edit.clear()
|
||||||
|
self._reload_list()
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
|
||||||
|
|
||||||
|
기능:
|
||||||
|
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
|
||||||
|
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
|
||||||
|
- 반복 패턴 관리 → RecurringLeaveDialog
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
|
QPushButton, QCalendarWidget, QListWidget,
|
||||||
|
QListWidgetItem, QMessageBox, QMenu,
|
||||||
|
QGroupBox, QSplitter, QWidget)
|
||||||
|
from PyQt5.QtCore import Qt, QDate
|
||||||
|
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||||
|
|
||||||
|
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||||
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
|
# 색상 팔레트
|
||||||
|
_C_HOLIDAY = QColor("#e53935") # 빨강
|
||||||
|
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
|
||||||
|
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
|
||||||
|
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
|
||||||
|
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
|
||||||
|
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
|
||||||
|
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
|
||||||
|
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleView(QDialog):
|
||||||
|
"""월간 통합 스케줄 다이얼로그."""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, db=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.db = db
|
||||||
|
self.setWindowTitle("🗓️ 스케줄")
|
||||||
|
self.setMinimumSize(820, 560)
|
||||||
|
self._build_ui()
|
||||||
|
self._reload()
|
||||||
|
apply_dark_titlebar(self)
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
# 상단 툴바
|
||||||
|
bar = QHBoxLayout()
|
||||||
|
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
|
||||||
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
|
bar.addWidget(title)
|
||||||
|
bar.addStretch()
|
||||||
|
|
||||||
|
rec_btn = QPushButton("🔁 반복 패턴 관리")
|
||||||
|
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||||
|
bar.addWidget(rec_btn)
|
||||||
|
|
||||||
|
add_btn = QPushButton("➕ 연차 등록")
|
||||||
|
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||||
|
bar.addWidget(add_btn)
|
||||||
|
|
||||||
|
layout.addLayout(bar)
|
||||||
|
|
||||||
|
# 범례
|
||||||
|
legend = QHBoxLayout()
|
||||||
|
for label, color in [("공휴일", _C_HOLIDAY),
|
||||||
|
("연차 사용", _C_LEAVE_FULL_PAST),
|
||||||
|
("연차 예정", _C_LEAVE_FULL_PLAN),
|
||||||
|
("반차/반반차", _C_LEAVE_HALF_PAST),
|
||||||
|
("반복 패턴", _C_RECURRING)]:
|
||||||
|
sw = QLabel(f" {label} ")
|
||||||
|
sw.setStyleSheet(
|
||||||
|
f"background-color: {color.name()}; color: white; "
|
||||||
|
f"padding: 2px 6px; border-radius: 3px;"
|
||||||
|
)
|
||||||
|
legend.addWidget(sw)
|
||||||
|
legend.addStretch()
|
||||||
|
layout.addLayout(legend)
|
||||||
|
|
||||||
|
# 캘린더 + 상세 splitter
|
||||||
|
splitter = QSplitter(Qt.Horizontal)
|
||||||
|
|
||||||
|
# 좌측: 캘린더
|
||||||
|
self.calendar = QCalendarWidget()
|
||||||
|
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||||
|
self.calendar.clicked.connect(self._on_date_click)
|
||||||
|
self.calendar.currentPageChanged.connect(self._on_page_change)
|
||||||
|
splitter.addWidget(self.calendar)
|
||||||
|
|
||||||
|
# 우측: 상세 패널
|
||||||
|
right = QWidget()
|
||||||
|
right_layout = QVBoxLayout()
|
||||||
|
|
||||||
|
self.detail_title = QLabel("날짜를 선택하세요")
|
||||||
|
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||||
|
right_layout.addWidget(self.detail_title)
|
||||||
|
|
||||||
|
self.detail_list = QListWidget()
|
||||||
|
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
|
||||||
|
right_layout.addWidget(self.detail_list, 1)
|
||||||
|
|
||||||
|
right.setLayout(right_layout)
|
||||||
|
splitter.addWidget(right)
|
||||||
|
splitter.setSizes([520, 280])
|
||||||
|
|
||||||
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
|
close_btn = QPushButton("닫기")
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- reload
|
||||||
|
|
||||||
|
def _reload(self):
|
||||||
|
"""현재 화면 월에 대해 색상/리스트 갱신."""
|
||||||
|
# 모든 날짜 포맷 초기화
|
||||||
|
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
|
||||||
|
|
||||||
|
y = self.calendar.yearShown()
|
||||||
|
m = self.calendar.monthShown()
|
||||||
|
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
|
||||||
|
first = date(y, m, 1)
|
||||||
|
if m == 12:
|
||||||
|
last = date(y + 1, 1, 1) - timedelta(days=1)
|
||||||
|
else:
|
||||||
|
last = date(y, m + 1, 1) - timedelta(days=1)
|
||||||
|
view_start = first - timedelta(days=7)
|
||||||
|
view_end = last + timedelta(days=7)
|
||||||
|
|
||||||
|
# 휴일
|
||||||
|
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
|
||||||
|
view_end.isoformat()) \
|
||||||
|
if hasattr(self.db, 'get_holidays_in_range') else []
|
||||||
|
if not holidays:
|
||||||
|
holidays = self._fallback_holidays(view_start, view_end)
|
||||||
|
|
||||||
|
for h in holidays:
|
||||||
|
d = self._parse_date(h.get('date'))
|
||||||
|
if d is None:
|
||||||
|
continue
|
||||||
|
self._paint(d, _C_HOLIDAY, fg='white')
|
||||||
|
|
||||||
|
# 연차 (구체)
|
||||||
|
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
|
||||||
|
view_end.isoformat())
|
||||||
|
today = date.today()
|
||||||
|
for r in leaves:
|
||||||
|
d = self._parse_date(r.get('date'))
|
||||||
|
if d is None:
|
||||||
|
continue
|
||||||
|
days = float(r.get('days') or 0)
|
||||||
|
is_planned = d > today
|
||||||
|
if is_planned:
|
||||||
|
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
|
||||||
|
else:
|
||||||
|
if days >= 1.0:
|
||||||
|
color = _C_LEAVE_FULL_PAST
|
||||||
|
elif days >= 0.5:
|
||||||
|
color = _C_LEAVE_HALF_PAST
|
||||||
|
else:
|
||||||
|
color = _C_LEAVE_QUART_PAST
|
||||||
|
self._paint(d, color, fg='white')
|
||||||
|
|
||||||
|
# 반복 패턴 인스턴스
|
||||||
|
recurring = self.db.get_recurring_leaves()
|
||||||
|
for occ in expand_for_range(recurring, view_start, view_end):
|
||||||
|
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
|
||||||
|
existing = self.calendar.dateTextFormat(
|
||||||
|
QDate(occ.date.year, occ.date.month, occ.date.day))
|
||||||
|
if existing.background() != QBrush():
|
||||||
|
continue
|
||||||
|
self._paint(occ.date, _C_RECURRING, fg='white')
|
||||||
|
|
||||||
|
def _paint(self, d: date, color: QColor, fg: str = 'white'):
|
||||||
|
qd = QDate(d.year, d.month, d.day)
|
||||||
|
fmt = QTextCharFormat()
|
||||||
|
fmt.setBackground(QBrush(color))
|
||||||
|
fmt.setForeground(QBrush(QColor(fg)))
|
||||||
|
self.calendar.setDateTextFormat(qd, fmt)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- events
|
||||||
|
|
||||||
|
def _on_date_click(self, qd: QDate):
|
||||||
|
d = date(qd.year(), qd.month(), qd.day())
|
||||||
|
date_str = d.isoformat()
|
||||||
|
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
|
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
|
||||||
|
self.detail_list.clear()
|
||||||
|
|
||||||
|
# 휴일
|
||||||
|
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||||
|
if holiday:
|
||||||
|
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
|
||||||
|
item.setForeground(QBrush(QColor("#e53935")))
|
||||||
|
self.detail_list.addItem(item)
|
||||||
|
elif d.weekday() in (5, 6):
|
||||||
|
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
|
||||||
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
|
# 연차 (구체)
|
||||||
|
for r in self.db.get_leave_records_by_date(date_str):
|
||||||
|
days = float(r.get('days') or 0)
|
||||||
|
t = r.get('leave_type', '연차')
|
||||||
|
memo = r.get('memo') or ''
|
||||||
|
label = f"📌 {t} {days}일"
|
||||||
|
if memo:
|
||||||
|
label += f" — {memo}"
|
||||||
|
label += f" [id={r['id']}]"
|
||||||
|
item = QListWidgetItem(label)
|
||||||
|
item.setData(Qt.UserRole, ('concrete', r['id']))
|
||||||
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
|
# 반복 패턴 매치
|
||||||
|
recurring = self.db.get_recurring_leaves(active_on=date_str)
|
||||||
|
from core.recurring_leaves import expand_for_date
|
||||||
|
for occ in expand_for_date(recurring, d):
|
||||||
|
item = QListWidgetItem(
|
||||||
|
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
|
||||||
|
)
|
||||||
|
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||||
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
|
if self.detail_list.count() == 0:
|
||||||
|
self.detail_list.addItem("일정 없음")
|
||||||
|
|
||||||
|
def _on_page_change(self, year: int, month: int):
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _on_list_menu(self, pos):
|
||||||
|
item = self.detail_list.currentItem()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
data = item.data(Qt.UserRole)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
kind, _id = data
|
||||||
|
menu = QMenu(self)
|
||||||
|
del_act = menu.addAction("삭제")
|
||||||
|
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||||
|
if chosen == del_act:
|
||||||
|
self._delete_record(kind, _id)
|
||||||
|
|
||||||
|
def _delete_record(self, kind: str, _id: int):
|
||||||
|
if kind == 'concrete':
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "삭제 확인",
|
||||||
|
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.db.delete_leave_record(_id)
|
||||||
|
self._reload()
|
||||||
|
# 상세 갱신
|
||||||
|
d = self.calendar.selectedDate()
|
||||||
|
self._on_date_click(d)
|
||||||
|
elif kind == 'recurring':
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self, "삭제 확인",
|
||||||
|
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.db.delete_recurring_leave(_id)
|
||||||
|
self._reload()
|
||||||
|
d = self.calendar.selectedDate()
|
||||||
|
self._on_date_click(d)
|
||||||
|
|
||||||
|
def _open_recurring_dialog(self):
|
||||||
|
from ui.recurring_leave_dialog import RecurringLeaveDialog
|
||||||
|
dlg = RecurringLeaveDialog(self, self.db)
|
||||||
|
dlg.exec_()
|
||||||
|
self._reload()
|
||||||
|
|
||||||
|
def _open_add_leave_dialog(self):
|
||||||
|
from ui.leave_view import AddLeaveDialog
|
||||||
|
dlg = AddLeaveDialog(self, self.db)
|
||||||
|
# 선택된 날짜로 기본값 설정
|
||||||
|
d = self.calendar.selectedDate()
|
||||||
|
if d.isValid():
|
||||||
|
dlg.date_edit.setDate(d)
|
||||||
|
if dlg.exec_() == dlg.Accepted:
|
||||||
|
self._reload()
|
||||||
|
self._on_date_click(d)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_date(s):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
|
||||||
|
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
|
||||||
|
if not hasattr(self.db, 'get_holiday'):
|
||||||
|
return []
|
||||||
|
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
|
||||||
|
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
|
||||||
|
out = []
|
||||||
|
cur = view_start
|
||||||
|
while cur <= view_end:
|
||||||
|
h = self.db.get_holiday(cur.isoformat())
|
||||||
|
if h:
|
||||||
|
out.append(h)
|
||||||
|
cur += timedelta(days=1)
|
||||||
|
return out
|
||||||
+141
-12
@@ -17,8 +17,11 @@ from core.i18n import tr
|
|||||||
from core.settings_keys import (
|
from core.settings_keys import (
|
||||||
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
||||||
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
||||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||||
NOTIFICATION_BEFORE_MINUTES,
|
NOTIFICATION_BEFORE_MINUTES,
|
||||||
|
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
|
||||||
|
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
|
||||||
|
HEALTH_BREAK_HOURS, HEALTH_BREAK_ENABLED,
|
||||||
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
||||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||||
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
||||||
@@ -258,13 +261,50 @@ class SettingsView(QDialog):
|
|||||||
self.work_preset_combo.setCurrentIndex(custom_index)
|
self.work_preset_combo.setCurrentIndex(custom_index)
|
||||||
self.work_preset_combo.blockSignals(False)
|
self.work_preset_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _load_threshold_safely(self, settings: dict, setting_key: str,
|
||||||
|
attr_name: str, default: int) -> None:
|
||||||
|
"""settings dict에서 임계값을 읽어 SpinBox에 안전하게 적용.
|
||||||
|
|
||||||
|
get_settings() 결과는 이미 타입 변환된 dict이라 추가 DB 호출 없이 사용.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, attr_name):
|
||||||
|
return
|
||||||
|
spin = getattr(self, attr_name)
|
||||||
|
try:
|
||||||
|
val = int(settings.get(setting_key, default))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
val = default
|
||||||
|
# 이미 설정된 setRange를 존중 — 사용자 옛 값이 범위 밖이면 클램프
|
||||||
|
spin.setValue(max(spin.minimum(), min(spin.maximum(), val)))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_threshold_spin(lo: int, hi: int, default: int, suffix: str) -> QSpinBox:
|
||||||
|
"""고급 임계값용 표준 SpinBox."""
|
||||||
|
sb = QSpinBox()
|
||||||
|
sb.setRange(lo, hi)
|
||||||
|
sb.setValue(default)
|
||||||
|
sb.setSuffix(suffix)
|
||||||
|
sb.setFixedWidth(110)
|
||||||
|
return sb
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _labeled_row(label_text: str, widget) -> QHBoxLayout:
|
||||||
|
"""좌측 라벨(고정 폭) + 위젯 + 오른쪽 stretch 한 줄 레이아웃."""
|
||||||
|
row = QHBoxLayout()
|
||||||
|
lbl = QLabel(label_text)
|
||||||
|
lbl.setFixedWidth(140)
|
||||||
|
row.addWidget(lbl)
|
||||||
|
row.addWidget(widget)
|
||||||
|
row.addStretch()
|
||||||
|
return row
|
||||||
|
|
||||||
def create_notification_group(self) -> QGroupBox:
|
def create_notification_group(self) -> QGroupBox:
|
||||||
"""알림 설정 그룹"""
|
"""알림 설정 그룹"""
|
||||||
group = QGroupBox(tr('group.notification'))
|
group = QGroupBox(tr('group.notification'))
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(6)
|
||||||
|
|
||||||
# 알림 체크박스들을 2열로 배치
|
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||||
check_row1 = QHBoxLayout()
|
check_row1 = QHBoxLayout()
|
||||||
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
||||||
self.clock_out_notification_check.setChecked(True)
|
self.clock_out_notification_check.setChecked(True)
|
||||||
@@ -275,13 +315,25 @@ class SettingsView(QDialog):
|
|||||||
layout.addLayout(check_row1)
|
layout.addLayout(check_row1)
|
||||||
|
|
||||||
check_row2 = QHBoxLayout()
|
check_row2 = QHBoxLayout()
|
||||||
|
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
||||||
|
self.dinner_notification_check.setChecked(True)
|
||||||
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
||||||
self.overtime_notification_check.setChecked(True)
|
self.overtime_notification_check.setChecked(True)
|
||||||
|
check_row2.addWidget(self.dinner_notification_check)
|
||||||
|
check_row2.addWidget(self.overtime_notification_check)
|
||||||
|
layout.addLayout(check_row2)
|
||||||
|
|
||||||
|
check_row3 = QHBoxLayout()
|
||||||
self.health_notification_check = QCheckBox("건강 경고 알림")
|
self.health_notification_check = QCheckBox("건강 경고 알림")
|
||||||
self.health_notification_check.setChecked(True)
|
self.health_notification_check.setChecked(True)
|
||||||
check_row2.addWidget(self.overtime_notification_check)
|
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
|
||||||
check_row2.addWidget(self.health_notification_check)
|
self.health_break_notification_check.setChecked(True)
|
||||||
layout.addLayout(check_row2)
|
self.health_break_notification_check.setToolTip(
|
||||||
|
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
|
||||||
|
)
|
||||||
|
check_row3.addWidget(self.health_notification_check)
|
||||||
|
check_row3.addWidget(self.health_break_notification_check)
|
||||||
|
layout.addLayout(check_row3)
|
||||||
|
|
||||||
# 퇴근 N분 전 알림 시점 설정
|
# 퇴근 N분 전 알림 시점 설정
|
||||||
before_row = QHBoxLayout()
|
before_row = QHBoxLayout()
|
||||||
@@ -299,6 +351,47 @@ class SettingsView(QDialog):
|
|||||||
before_row.addStretch()
|
before_row.addStretch()
|
||||||
layout.addLayout(before_row)
|
layout.addLayout(before_row)
|
||||||
|
|
||||||
|
# === 고급 임계값 (접이식 그룹박스) ===
|
||||||
|
adv_box = QGroupBox("고급 임계값")
|
||||||
|
adv_box.setCheckable(True)
|
||||||
|
adv_box.setChecked(False) # 기본 접힘
|
||||||
|
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
|
||||||
|
adv_layout = QVBoxLayout()
|
||||||
|
adv_layout.setSpacing(4)
|
||||||
|
|
||||||
|
# 점심 알림 임계 (출근 후 N시간)
|
||||||
|
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||||
|
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
|
||||||
|
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
|
||||||
|
|
||||||
|
# 저녁 알림 임계 (출근 후 N시간)
|
||||||
|
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
|
||||||
|
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
|
||||||
|
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
|
||||||
|
|
||||||
|
# 연장근무 누적 임계
|
||||||
|
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
|
||||||
|
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
|
||||||
|
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
|
||||||
|
|
||||||
|
# 주 X시간 임계
|
||||||
|
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
|
||||||
|
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
|
||||||
|
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
|
||||||
|
|
||||||
|
# 연속 연장근무 일수
|
||||||
|
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, " 일")
|
||||||
|
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
|
||||||
|
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
|
||||||
|
|
||||||
|
# 휴식 권고 (연속 근무 시간)
|
||||||
|
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||||
|
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
|
||||||
|
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
|
||||||
|
|
||||||
|
adv_box.setLayout(adv_layout)
|
||||||
|
layout.addWidget(adv_box)
|
||||||
|
|
||||||
# 시간 형식 + 테마 한 줄에
|
# 시간 형식 + 테마 한 줄에
|
||||||
format_row = QHBoxLayout()
|
format_row = QHBoxLayout()
|
||||||
time_format_label = QLabel("시간 형식:")
|
time_format_label = QLabel("시간 형식:")
|
||||||
@@ -578,27 +671,39 @@ class SettingsView(QDialog):
|
|||||||
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
||||||
|
|
||||||
def add_korean_holidays_auto(self):
|
def add_korean_holidays_auto(self):
|
||||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가"""
|
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||||
current_year = datetime.now().year
|
|
||||||
|
11월 이후 호출 시 자동으로 다음 연도까지 등록 — 연말 경계에서
|
||||||
|
신정 등이 누락되는 것 방지.
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
current_year = now.year
|
||||||
|
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
||||||
|
include_next = now.month >= 11
|
||||||
|
target_label = (f"{current_year}년 + {current_year + 1}년"
|
||||||
|
if include_next else f"{current_year}년")
|
||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"한국 공휴일 자동 추가",
|
"한국 공휴일 자동 추가",
|
||||||
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||||
"포함:\n"
|
"포함:\n"
|
||||||
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
||||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||||
"• 정부 지정 대체·임시공휴일\n\n"
|
"• 정부 지정 대체·임시공휴일\n\n"
|
||||||
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
|
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
||||||
|
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.Yes:
|
if reply != QMessageBox.Yes:
|
||||||
return
|
return
|
||||||
|
|
||||||
added = self.db.add_korean_holidays_auto(current_year)
|
added = self.db.add_korean_holidays_auto(current_year, include_next_year=include_next)
|
||||||
if added < 0:
|
if added < 0:
|
||||||
# 패키지 미설치 시 고정 공휴일로 폴백
|
# 패키지 미설치 시 고정 공휴일로 폴백
|
||||||
self.db.add_korean_holidays(current_year)
|
self.db.add_korean_holidays(current_year)
|
||||||
|
if include_next:
|
||||||
|
self.db.add_korean_holidays(current_year + 1)
|
||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
@@ -933,14 +1038,27 @@ class SettingsView(QDialog):
|
|||||||
# 알림
|
# 알림
|
||||||
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
|
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
|
||||||
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
||||||
|
if hasattr(self, 'dinner_notification_check'):
|
||||||
|
self.dinner_notification_check.setChecked(settings.get(NOTIF_DINNER, True))
|
||||||
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
||||||
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
||||||
|
if hasattr(self, 'health_break_notification_check'):
|
||||||
|
self.health_break_notification_check.setChecked(
|
||||||
|
settings.get(HEALTH_BREAK_ENABLED, True))
|
||||||
if hasattr(self, 'notif_before_spin'):
|
if hasattr(self, 'notif_before_spin'):
|
||||||
try:
|
try:
|
||||||
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
self.notif_before_spin.setValue(30)
|
self.notif_before_spin.setValue(30)
|
||||||
|
|
||||||
|
# 고급 임계값
|
||||||
|
self._load_threshold_safely(settings, LUNCH_REMINDER_HOURS, 'lunch_reminder_spin', 4)
|
||||||
|
self._load_threshold_safely(settings, DINNER_REMINDER_HOURS, 'dinner_reminder_spin', 8)
|
||||||
|
self._load_threshold_safely(settings, OVERTIME_THRESHOLD_HOURS, 'overtime_threshold_spin', 20)
|
||||||
|
self._load_threshold_safely(settings, WEEKLY_HOURS_THRESHOLD, 'weekly_hours_spin', 52)
|
||||||
|
self._load_threshold_safely(settings, HEALTH_CONSECUTIVE_OT_DAYS, 'health_consecutive_spin', 3)
|
||||||
|
self._load_threshold_safely(settings, HEALTH_BREAK_HOURS, 'health_break_hours_spin', 4)
|
||||||
|
|
||||||
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
||||||
time_format = settings.get(TIME_FORMAT, '24')
|
time_format = settings.get(TIME_FORMAT, '24')
|
||||||
if isinstance(time_format, int):
|
if isinstance(time_format, int):
|
||||||
@@ -1067,11 +1185,22 @@ class SettingsView(QDialog):
|
|||||||
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
||||||
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
||||||
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
||||||
|
# 고급 임계값
|
||||||
|
LUNCH_REMINDER_HOURS: self.lunch_reminder_spin.value(),
|
||||||
|
DINNER_REMINDER_HOURS: self.dinner_reminder_spin.value(),
|
||||||
|
OVERTIME_THRESHOLD_HOURS: self.overtime_threshold_spin.value(),
|
||||||
|
WEEKLY_HOURS_THRESHOLD: self.weekly_hours_spin.value(),
|
||||||
|
HEALTH_CONSECUTIVE_OT_DAYS: self.health_consecutive_spin.value(),
|
||||||
|
HEALTH_BREAK_HOURS: self.health_break_hours_spin.value(),
|
||||||
TIME_FORMAT: self.time_format_combo.currentData(),
|
TIME_FORMAT: self.time_format_combo.currentData(),
|
||||||
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
||||||
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
||||||
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
|
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
|
||||||
}
|
}
|
||||||
|
if hasattr(self, 'dinner_notification_check'):
|
||||||
|
settings[NOTIF_DINNER] = self.dinner_notification_check.isChecked()
|
||||||
|
if hasattr(self, 'health_break_notification_check'):
|
||||||
|
settings[HEALTH_BREAK_ENABLED] = self.health_break_notification_check.isChecked()
|
||||||
if hasattr(self, 'auto_break_check'):
|
if hasattr(self, 'auto_break_check'):
|
||||||
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
||||||
if hasattr(self, 'clock_in_unlock_check'):
|
if hasattr(self, 'clock_in_unlock_check'):
|
||||||
@@ -1243,7 +1372,7 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
if filename:
|
if filename:
|
||||||
try:
|
try:
|
||||||
saved_path = CSVExporter.export_work_records(records, filename)
|
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"내보내기 완료",
|
"내보내기 완료",
|
||||||
|
|||||||
+142
-92
@@ -2,7 +2,8 @@
|
|||||||
통계 대시보드 - 주간/월간 통계
|
통계 대시보드 - 주간/월간 통계
|
||||||
"""
|
"""
|
||||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
|
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
|
||||||
|
QFrame)
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import sys
|
import sys
|
||||||
@@ -12,6 +13,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
from core.database import Database
|
from core.database import Database
|
||||||
from core.i18n import tr
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
from ui.dark_components import (
|
||||||
|
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||||
|
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StatsView(QDialog):
|
class StatsView(QDialog):
|
||||||
@@ -22,72 +27,91 @@ class StatsView(QDialog):
|
|||||||
self.db = db if db else Database()
|
self.db = db if db else Database()
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.load_stats()
|
self.load_stats()
|
||||||
apply_dark_titlebar(self)
|
apply_dark_titlebar(self, dark=True)
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""UI 초기화"""
|
"""UI 초기화"""
|
||||||
self.setWindowTitle(tr('window.stats'))
|
self.setWindowTitle(tr('window.stats'))
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumSize(420, 350)
|
self.setMinimumSize(720, 600)
|
||||||
|
self.resize(900, 720)
|
||||||
|
self.setStyleSheet(dialog_qss())
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(12, 10, 12, 10)
|
layout.setContentsMargins(20, 16, 20, 14)
|
||||||
|
|
||||||
title = QLabel(tr('stats.title'))
|
# 다크 톤 타이틀
|
||||||
title.setObjectName("dialog_title")
|
title = QLabel(f"📊 {tr('stats.title')}")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setStyleSheet(
|
||||||
|
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||||
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
|
)
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
|
tabs.setStyleSheet(tabs_qss())
|
||||||
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
|
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
|
||||||
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
|
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
|
||||||
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
|
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
|
||||||
layout.addWidget(tabs)
|
# 도전과제용 탭 진입 카운터
|
||||||
|
tabs.currentChanged.connect(self._on_tab_changed)
|
||||||
|
self._on_tab_changed(0)
|
||||||
|
layout.addWidget(tabs, 1)
|
||||||
|
|
||||||
|
# 닫기 버튼 — 우측 정렬
|
||||||
|
btn_row = QHBoxLayout()
|
||||||
|
btn_row.addStretch()
|
||||||
close_button = QPushButton(tr('btn.close'))
|
close_button = QPushButton(tr('btn.close'))
|
||||||
|
close_button.setMinimumWidth(100)
|
||||||
|
close_button.setStyleSheet(button_qss('ghost'))
|
||||||
close_button.clicked.connect(self.close)
|
close_button.clicked.connect(self.close)
|
||||||
layout.addWidget(close_button)
|
btn_row.addWidget(close_button)
|
||||||
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
def _on_tab_changed(self, idx: int) -> None:
|
||||||
|
"""탭별 진입 카운터 (도전과제 시스템용). 실패는 silent."""
|
||||||
|
keys = ['stat_weekly_view_count', 'stat_monthly_view_count',
|
||||||
|
'stat_pattern_view_count']
|
||||||
|
if 0 <= idx < len(keys):
|
||||||
|
try:
|
||||||
|
cur = self.db.get_setting_int(keys[idx], 0)
|
||||||
|
self.db.set_setting(keys[idx], str(cur + 1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def create_weekly_tab(self) -> QWidget:
|
def create_weekly_tab(self) -> QWidget:
|
||||||
"""주간 통계 탭 생성"""
|
"""주간 통계 탭 생성"""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
|
widget.setStyleSheet("background: transparent;")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(4, 4, 4, 4)
|
layout.setContentsMargins(8, 12, 8, 8)
|
||||||
|
|
||||||
summary_group = QGroupBox(tr('stats.weekly_summary'))
|
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||||
summary_layout = QGridLayout()
|
cards_row = QHBoxLayout()
|
||||||
summary_layout.setSpacing(4)
|
cards_row.setSpacing(10)
|
||||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||||
|
theme='blue', icon='⏱️')
|
||||||
|
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||||
|
theme='cyan', icon='📅')
|
||||||
|
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||||
|
theme='green', icon='📊')
|
||||||
|
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
||||||
|
theme='gold', icon='🔥')
|
||||||
|
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||||
|
self.weekly_avg_card, self.weekly_ot_card):
|
||||||
|
cards_row.addWidget(c, 1)
|
||||||
|
layout.addLayout(cards_row)
|
||||||
|
|
||||||
self.weekly_total_hours = QLabel("0")
|
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||||
self.weekly_total_hours.setObjectName("stat_value")
|
|
||||||
self.weekly_work_days = QLabel("0")
|
|
||||||
self.weekly_work_days.setObjectName("stat_value")
|
|
||||||
self.weekly_avg_hours = QLabel("0")
|
|
||||||
self.weekly_avg_hours.setObjectName("stat_value")
|
|
||||||
self.weekly_overtime = QLabel("0")
|
|
||||||
self.weekly_overtime.setObjectName("stat_value")
|
|
||||||
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
|
||||||
summary_layout.addWidget(self.weekly_total_hours, 0, 1)
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
|
||||||
summary_layout.addWidget(self.weekly_work_days, 1, 1)
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
|
||||||
summary_layout.addWidget(self.weekly_avg_hours, 2, 1)
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
|
||||||
summary_layout.addWidget(self.weekly_overtime, 3, 1)
|
|
||||||
|
|
||||||
summary_group.setLayout(summary_layout)
|
|
||||||
layout.addWidget(summary_group)
|
|
||||||
|
|
||||||
# 주간 차트 (일별 근무시간)
|
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.weekly_chart = make_chart_widget(widget)
|
self.weekly_chart = make_chart_widget(widget)
|
||||||
layout.addWidget(self.weekly_chart, 1)
|
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||||
|
theme='gray', icon='📈')
|
||||||
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
@@ -95,40 +119,36 @@ class StatsView(QDialog):
|
|||||||
def create_monthly_tab(self) -> QWidget:
|
def create_monthly_tab(self) -> QWidget:
|
||||||
"""월간 통계 탭 생성"""
|
"""월간 통계 탭 생성"""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
|
widget.setStyleSheet("background: transparent;")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(4, 4, 4, 4)
|
layout.setContentsMargins(8, 12, 8, 8)
|
||||||
|
|
||||||
# 추정 급여 카드 (옵션 활성 시)
|
# 카드 4개
|
||||||
|
cards_row = QHBoxLayout()
|
||||||
|
cards_row.setSpacing(10)
|
||||||
|
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
||||||
|
theme='blue', icon='⏱️')
|
||||||
|
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||||
|
theme='cyan', icon='📅')
|
||||||
|
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||||
|
theme='green', icon='📊')
|
||||||
|
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
||||||
|
theme='gold', icon='🔥')
|
||||||
|
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||||
|
self.monthly_avg_card, self.monthly_ot_card):
|
||||||
|
cards_row.addWidget(c, 1)
|
||||||
|
layout.addLayout(cards_row)
|
||||||
|
|
||||||
|
# 추정 급여 (옵션 활성 시)
|
||||||
self.salary_label = QLabel("")
|
self.salary_label = QLabel("")
|
||||||
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
|
self.salary_label.setStyleSheet(
|
||||||
|
f"background: rgba(74, 222, 128, 0.12); "
|
||||||
|
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; "
|
||||||
|
f"color: {ACCENT_GREEN}; font-weight: bold; "
|
||||||
|
f"padding: 10px 14px; font-size: 11pt;"
|
||||||
|
)
|
||||||
self.salary_label.setVisible(False)
|
self.salary_label.setVisible(False)
|
||||||
|
|
||||||
summary_group = QGroupBox(tr('stats.monthly_summary'))
|
|
||||||
summary_layout = QGridLayout()
|
|
||||||
summary_layout.setSpacing(4)
|
|
||||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
|
||||||
|
|
||||||
self.monthly_total_hours = QLabel("0")
|
|
||||||
self.monthly_total_hours.setObjectName("stat_value")
|
|
||||||
self.monthly_work_days = QLabel("0")
|
|
||||||
self.monthly_work_days.setObjectName("stat_value")
|
|
||||||
self.monthly_avg_hours = QLabel("0")
|
|
||||||
self.monthly_avg_hours.setObjectName("stat_value")
|
|
||||||
self.monthly_overtime = QLabel("0")
|
|
||||||
self.monthly_overtime.setObjectName("stat_value")
|
|
||||||
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
|
||||||
summary_layout.addWidget(self.monthly_total_hours, 0, 1)
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
|
||||||
summary_layout.addWidget(self.monthly_work_days, 1, 1)
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
|
||||||
summary_layout.addWidget(self.monthly_avg_hours, 2, 1)
|
|
||||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
|
||||||
summary_layout.addWidget(self.monthly_overtime, 3, 1)
|
|
||||||
|
|
||||||
summary_group.setLayout(summary_layout)
|
|
||||||
layout.addWidget(summary_group)
|
|
||||||
layout.addWidget(self.salary_label)
|
layout.addWidget(self.salary_label)
|
||||||
|
|
||||||
# 목표 진행률
|
# 목표 진행률
|
||||||
@@ -139,7 +159,9 @@ class StatsView(QDialog):
|
|||||||
# 월간 차트
|
# 월간 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.monthly_chart = make_chart_widget(widget)
|
self.monthly_chart = make_chart_widget(widget)
|
||||||
layout.addWidget(self.monthly_chart, 1)
|
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||||
|
theme='gray', icon='📊')
|
||||||
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
@@ -147,46 +169,71 @@ class StatsView(QDialog):
|
|||||||
def create_pattern_tab(self) -> QWidget:
|
def create_pattern_tab(self) -> QWidget:
|
||||||
"""패턴 분석 탭 생성"""
|
"""패턴 분석 탭 생성"""
|
||||||
widget = QWidget()
|
widget = QWidget()
|
||||||
|
widget.setStyleSheet("background: transparent;")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(4, 4, 4, 4)
|
layout.setContentsMargins(8, 12, 8, 8)
|
||||||
|
|
||||||
pattern_group = QGroupBox(tr('stats.pattern_insights'))
|
|
||||||
pattern_layout = QVBoxLayout()
|
|
||||||
pattern_layout.setSpacing(4)
|
|
||||||
pattern_layout.setContentsMargins(8, 20, 8, 6)
|
|
||||||
|
|
||||||
|
# 패턴 텍스트 카드
|
||||||
self.pattern_text = QLabel(tr('stats.analyzing'))
|
self.pattern_text = QLabel(tr('stats.analyzing'))
|
||||||
self.pattern_text.setWordWrap(True)
|
self.pattern_text.setWordWrap(True)
|
||||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||||
|
self.pattern_text.setStyleSheet(
|
||||||
pattern_layout.addWidget(self.pattern_text)
|
f"font-size: 11pt; color: {DARK_TEXT}; "
|
||||||
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
pattern_group.setLayout(pattern_layout)
|
)
|
||||||
layout.addWidget(pattern_group)
|
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||||
|
theme='cyan', icon='🔍'))
|
||||||
|
|
||||||
# 출근 시각 분포 차트
|
# 출근 시각 분포 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.clock_in_chart = make_chart_widget(widget)
|
self.clock_in_chart = make_chart_widget(widget)
|
||||||
layout.addWidget(self.clock_in_chart, 1)
|
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||||
|
theme='gray', icon='⏰'), 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
|
def _set_card_value(self, card, value: str) -> None:
|
||||||
|
"""build_stat_card로 만든 카드의 큰 숫자 라벨 업데이트.
|
||||||
|
|
||||||
|
카드 구조: QFrame > QHBoxLayout > [icon QLabel] [text QVBoxLayout > title, value, subtitle]
|
||||||
|
value는 두 번째 QLabel.
|
||||||
|
"""
|
||||||
|
# text_box는 outer hbox의 마지막 layout
|
||||||
|
outer = card.layout()
|
||||||
|
if outer is None or outer.count() == 0:
|
||||||
|
return
|
||||||
|
# text_box 찾기 (마지막 item, layout)
|
||||||
|
text_item = outer.itemAt(outer.count() - 1)
|
||||||
|
text_box = text_item.layout() if text_item else None
|
||||||
|
if text_box is None or text_box.count() < 2:
|
||||||
|
return
|
||||||
|
val_lbl = text_box.itemAt(1).widget() # 두 번째가 큰 숫자
|
||||||
|
if val_lbl is None:
|
||||||
|
return
|
||||||
|
# 큰 숫자 RichText 형식 유지
|
||||||
|
from ui.dark_components import CARD_THEMES
|
||||||
|
# tier color는 카드 자체에 알 방법이 없으니 기본 골드 톤
|
||||||
|
val_lbl.setText(
|
||||||
|
f"<span style='font-size: 18pt; font-weight: bold; color: #ffd24a;'>"
|
||||||
|
f"{value}</span>"
|
||||||
|
)
|
||||||
|
|
||||||
def load_stats(self):
|
def load_stats(self):
|
||||||
"""통계 로드"""
|
"""통계 로드"""
|
||||||
# 주간 통계
|
# 주간 통계
|
||||||
weekly_stats = self.db.get_weekly_stats()
|
weekly_stats = self.db.get_weekly_stats()
|
||||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||||
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
|
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
|
||||||
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 0)}일")
|
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}일")
|
||||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||||
self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간")
|
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간")
|
||||||
|
|
||||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||||
overtime_hours = overtime_minutes // 60
|
overtime_hours = overtime_minutes // 60
|
||||||
overtime_mins = overtime_minutes % 60
|
overtime_mins = overtime_minutes % 60
|
||||||
self.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
|
||||||
|
|
||||||
# 주간 차트
|
# 주간 차트
|
||||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||||
@@ -196,26 +243,29 @@ class StatsView(QDialog):
|
|||||||
(today - _td(days=6)).isoformat(), today.isoformat()
|
(today - _td(days=6)).isoformat(), today.isoformat()
|
||||||
)
|
)
|
||||||
if hasattr(self, 'weekly_chart'):
|
if hasattr(self, 'weekly_chart'):
|
||||||
|
# 도전과제 chart_hover 감지를 위해 db 참조 attach
|
||||||
|
self.weekly_chart._achievement_db = self.db
|
||||||
draw_daily_hours(self.weekly_chart, week_records)
|
draw_daily_hours(self.weekly_chart, week_records)
|
||||||
|
|
||||||
# 월간 통계
|
# 월간 통계
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||||
self.monthly_total_hours.setText(f"{total_hours:.1f}시간")
|
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
|
||||||
work_days = monthly_stats.get('work_days', 0) or 0
|
work_days = monthly_stats.get('work_days', 0) or 0
|
||||||
self.monthly_work_days.setText(f"{work_days}일")
|
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
||||||
|
|
||||||
if work_days > 0:
|
if work_days > 0:
|
||||||
avg = total_hours / work_days
|
avg = total_hours / work_days
|
||||||
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
|
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
|
||||||
else:
|
else:
|
||||||
self.monthly_avg_hours.setText("0시간")
|
self._set_card_value(self.monthly_avg_card, "0시간")
|
||||||
|
|
||||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||||
overtime_hours = overtime_minutes // 60
|
overtime_hours = overtime_minutes // 60
|
||||||
overtime_mins = overtime_minutes % 60
|
overtime_mins = overtime_minutes % 60
|
||||||
self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
self._set_card_value(self.monthly_ot_card,
|
||||||
|
f"{overtime_hours}시간 {overtime_mins}분")
|
||||||
|
|
||||||
# 월간 차트 (요일별 평균)
|
# 월간 차트 (요일별 평균)
|
||||||
if hasattr(self, 'monthly_chart'):
|
if hasattr(self, 'monthly_chart'):
|
||||||
|
|||||||
+6
-2
@@ -55,16 +55,18 @@ class TodaySummaryCard(QFrame):
|
|||||||
|
|
||||||
def show_summary(self, total_hours: float, lunch_minutes: int,
|
def show_summary(self, total_hours: float, lunch_minutes: int,
|
||||||
break_minutes: int, overtime_actual: int,
|
break_minutes: int, overtime_actual: int,
|
||||||
overtime_earned: int, salary_text: str = "") -> None:
|
overtime_earned: int, salary_text: str = "",
|
||||||
|
dinner_minutes: int = 0) -> None:
|
||||||
"""카드 내용 채우고 표시.
|
"""카드 내용 채우고 표시.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
total_hours: 총 근무시간(시간)
|
total_hours: 총 근무시간(시간)
|
||||||
lunch_minutes: 점심 시간(분)
|
lunch_minutes: 점심 시간(분)
|
||||||
break_minutes: 외출 시간(분)
|
break_minutes: 외출 시간(분, 식사 제외)
|
||||||
overtime_actual: 실제 연장근무(분)
|
overtime_actual: 실제 연장근무(분)
|
||||||
overtime_earned: 적립 연장근무(분)
|
overtime_earned: 적립 연장근무(분)
|
||||||
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
||||||
|
dinner_minutes: 저녁 시간(분), 0이면 표시 안 함
|
||||||
"""
|
"""
|
||||||
h = int(total_hours)
|
h = int(total_hours)
|
||||||
m = int((total_hours - h) * 60)
|
m = int((total_hours - h) * 60)
|
||||||
@@ -73,6 +75,8 @@ class TodaySummaryCard(QFrame):
|
|||||||
details = []
|
details = []
|
||||||
if lunch_minutes > 0:
|
if lunch_minutes > 0:
|
||||||
details.append(f"점심 {lunch_minutes}분")
|
details.append(f"점심 {lunch_minutes}분")
|
||||||
|
if dinner_minutes > 0:
|
||||||
|
details.append(f"저녁 {dinner_minutes}분")
|
||||||
if break_minutes > 0:
|
if break_minutes > 0:
|
||||||
details.append(f"외출 {break_minutes}분")
|
details.append(f"외출 {break_minutes}분")
|
||||||
if overtime_actual > 0:
|
if overtime_actual > 0:
|
||||||
|
|||||||
+95
-22
@@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
|
|||||||
3. new_exe → target_exe 이동
|
3. new_exe → target_exe 이동
|
||||||
4. target_exe 재실행 + 업데이터 자가 종료
|
4. target_exe 재실행 + 업데이터 자가 종료
|
||||||
실패 시 .bak 복원
|
실패 시 .bak 복원
|
||||||
|
|
||||||
|
빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임.
|
||||||
|
모든 진단 출력은 ~/.clockout_logs/updater.log 로 append.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
@@ -21,9 +24,30 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
|
||||||
|
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
"""진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
|
||||||
|
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
|
||||||
|
# stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
|
||||||
|
try:
|
||||||
|
print(line, end='', file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(_LOG_PATH, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(line)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def is_pid_running(pid: int) -> bool:
|
def is_pid_running(pid: int) -> bool:
|
||||||
"""Windows에서 PID 실행 중인지 확인."""
|
"""Windows에서 PID 실행 중인지 확인."""
|
||||||
if sys.platform != 'win32':
|
if sys.platform != 'win32':
|
||||||
@@ -62,42 +86,86 @@ def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def replace_file(new_path: Path, target_path: Path) -> Path | None:
|
def replace_file(new_path: Path, target_path: Path,
|
||||||
|
max_retries: int = 5) -> Path | None:
|
||||||
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
|
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
|
||||||
|
|
||||||
|
Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
|
||||||
|
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
|
||||||
|
있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
백업 파일 경로 (롤백용). 실패 시 None.
|
백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
|
||||||
"""
|
"""
|
||||||
backup = target_path.with_suffix(target_path.suffix + '.bak')
|
backup = target_path.with_suffix(target_path.suffix + '.bak')
|
||||||
try:
|
last_err: Exception | None = None
|
||||||
if backup.exists():
|
|
||||||
|
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
|
||||||
|
if backup.exists():
|
||||||
|
try:
|
||||||
backup.unlink()
|
backup.unlink()
|
||||||
if target_path.exists():
|
except OSError as e:
|
||||||
|
_log(f"[updater] old backup unlink failed (continuing): {e}")
|
||||||
|
|
||||||
|
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
if not target_path.exists():
|
||||||
|
break # 첫 설치 등 — target 없으면 그냥 다음 단계로
|
||||||
|
try:
|
||||||
shutil.move(str(target_path), str(backup))
|
shutil.move(str(target_path), str(backup))
|
||||||
shutil.move(str(new_path), str(target_path))
|
break
|
||||||
return backup
|
except OSError as e:
|
||||||
except OSError as e:
|
last_err = e
|
||||||
print(f"[updater] replace failed: {e}", file=sys.stderr)
|
wait = 0.3 * (2 ** attempt)
|
||||||
# 롤백 시도
|
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||||
if backup.exists() and not target_path.exists():
|
f"failed ({e}); waiting {wait:.1f}s")
|
||||||
try:
|
time.sleep(wait)
|
||||||
shutil.move(str(backup), str(target_path))
|
else:
|
||||||
except OSError:
|
# 모든 재시도 실패
|
||||||
pass
|
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 3단계: new → target 이동
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
shutil.move(str(new_path), str(target_path))
|
||||||
|
return backup
|
||||||
|
except OSError as e:
|
||||||
|
last_err = e
|
||||||
|
wait = 0.3 * (2 ** attempt)
|
||||||
|
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||||
|
f"failed ({e}); waiting {wait:.1f}s")
|
||||||
|
time.sleep(wait)
|
||||||
|
|
||||||
|
# new 이동 실패 → backup으로 롤백 시도
|
||||||
|
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
|
||||||
|
if backup.exists() and not target_path.exists():
|
||||||
|
try:
|
||||||
|
shutil.move(str(backup), str(target_path))
|
||||||
|
_log("[updater] rolled back from backup")
|
||||||
|
except OSError as e:
|
||||||
|
_log(f"[updater] rollback also failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def launch(exe_path: Path) -> bool:
|
def launch(exe_path: Path) -> bool:
|
||||||
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
||||||
try:
|
try:
|
||||||
if sys.platform == 'win32':
|
if sys.platform == 'win32':
|
||||||
|
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
|
||||||
|
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
|
||||||
DETACHED_PROCESS = 0x00000008
|
DETACHED_PROCESS = 0x00000008
|
||||||
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
|
CREATE_NO_WINDOW = 0x08000000
|
||||||
|
subprocess.Popen(
|
||||||
|
[str(exe_path)],
|
||||||
|
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
subprocess.Popen([str(exe_path)], close_fds=True)
|
subprocess.Popen([str(exe_path)], close_fds=True)
|
||||||
return True
|
return True
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
print(f"[updater] launch failed: {e}", file=sys.stderr)
|
_log(f"[updater] launch failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -109,29 +177,34 @@ def main() -> int:
|
|||||||
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
|
||||||
|
|
||||||
new_exe = Path(args.new).resolve()
|
new_exe = Path(args.new).resolve()
|
||||||
target_exe = Path(args.target).resolve()
|
target_exe = Path(args.target).resolve()
|
||||||
|
|
||||||
if not new_exe.exists():
|
if not new_exe.exists():
|
||||||
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
|
_log(f"[updater] new exe not found: {new_exe}")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
if not wait_for_exit(args.pid, timeout_sec=30):
|
if not wait_for_exit(args.pid, timeout_sec=30):
|
||||||
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
|
_log(f"[updater] timeout waiting for PID {args.pid}")
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
# Windows 파일 핸들 해제 시간 여유
|
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
||||||
|
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
backup = replace_file(new_exe, target_exe)
|
backup = replace_file(new_exe, target_exe)
|
||||||
if backup is None:
|
if backup is None:
|
||||||
|
_log("[updater] replace_file failed — aborting")
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
if args.no_launch:
|
if args.no_launch:
|
||||||
|
_log("[updater] --no-launch set, exiting after replace")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if not launch(target_exe):
|
if not launch(target_exe):
|
||||||
# 시작 실패 시 롤백
|
_log("[updater] launch failed — rolling back")
|
||||||
try:
|
try:
|
||||||
target_exe.unlink()
|
target_exe.unlink()
|
||||||
shutil.move(str(backup), str(target_exe))
|
shutil.move(str(backup), str(target_exe))
|
||||||
@@ -140,7 +213,7 @@ def main() -> int:
|
|||||||
pass
|
pass
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
|
_log("[updater] update complete, new app launched")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ exe = EXE(
|
|||||||
upx=True,
|
upx=True,
|
||||||
upx_exclude=[],
|
upx_exclude=[],
|
||||||
runtime_tmpdir=None,
|
runtime_tmpdir=None,
|
||||||
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
|
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
argv_emulation=False,
|
argv_emulation=False,
|
||||||
target_arch=None,
|
target_arch=None,
|
||||||
|
|||||||
+4
-2
@@ -71,5 +71,7 @@ def _rotate(directory: Path, keep: int) -> None:
|
|||||||
for old in files[keep:]:
|
for old in files[keep:]:
|
||||||
try:
|
try:
|
||||||
old.unlink()
|
old.unlink()
|
||||||
except OSError:
|
except OSError as e:
|
||||||
pass
|
# 회전 실패 시 로그만 — 다음 실행에 재시도. 누적 시 디스크 압박 가능.
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"backup rotation failed for {old}: {e}")
|
||||||
|
|||||||
+60
-5
@@ -20,24 +20,79 @@ USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
|
|||||||
|
|
||||||
|
|
||||||
def install_global_handler(db, app_version: str = 'unknown') -> None:
|
def install_global_handler(db, app_version: str = 'unknown') -> None:
|
||||||
"""sys.excepthook 등록. db는 crash_log 저장용."""
|
"""sys.excepthook 등록. db는 crash_log 저장용.
|
||||||
|
|
||||||
|
각 단계(log → dialog → 파일 fallback → original hook)는 모두 독립 try로 감싸
|
||||||
|
하나가 실패해도 다음 단계가 동작. 가장 최후 fallback은 stderr + 로컬 파일.
|
||||||
|
"""
|
||||||
original = sys.excepthook
|
original = sys.excepthook
|
||||||
|
|
||||||
def hook(exc_type, exc_value, exc_tb):
|
def hook(exc_type, exc_value, exc_tb):
|
||||||
if exc_type is KeyboardInterrupt:
|
if exc_type is KeyboardInterrupt:
|
||||||
original(exc_type, exc_value, exc_tb)
|
original(exc_type, exc_value, exc_tb)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 1단계: traceback 직렬화 (실패하면 minimal fallback)
|
||||||
try:
|
try:
|
||||||
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
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:
|
except Exception:
|
||||||
pass # 후킹 자체 실패는 silent
|
tb_str = f"{exc_type}: {exc_value} (traceback formatting failed)"
|
||||||
original(exc_type, exc_value, exc_tb)
|
|
||||||
|
type_name = getattr(exc_type, '__name__', str(exc_type))
|
||||||
|
msg = str(exc_value)
|
||||||
|
|
||||||
|
# 2단계: DB 로깅 (DB 잠금/디스크 풀 등으로 실패 가능 — 단계 분리)
|
||||||
|
log_ok = False
|
||||||
|
try:
|
||||||
|
_log_crash(db, type_name, msg, tb_str, app_version)
|
||||||
|
log_ok = True
|
||||||
|
except Exception as log_err:
|
||||||
|
_fallback_to_file(type_name, msg, tb_str, app_version,
|
||||||
|
f"DB log failed: {log_err}")
|
||||||
|
|
||||||
|
# 3단계: 다이얼로그 (PyQt 미초기화/이미 종료 등으로 실패 가능)
|
||||||
|
try:
|
||||||
|
_show_dialog(db, type_name, msg, tb_str, app_version)
|
||||||
|
except Exception as dlg_err:
|
||||||
|
# 다이얼로그 실패 시 stderr + 파일에 기록 (DB 로깅도 실패했으면 이게 유일한 흔적)
|
||||||
|
if not log_ok:
|
||||||
|
_fallback_to_file(type_name, msg, tb_str, app_version,
|
||||||
|
f"DB+dialog both failed; dialog err: {dlg_err}")
|
||||||
|
try:
|
||||||
|
print(f"\n[CRASH] {type_name}: {msg}\n{tb_str}", file=sys.stderr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4단계: 원래 hook도 호출 (콘솔 출력 + 종료)
|
||||||
|
try:
|
||||||
|
original(exc_type, exc_value, exc_tb)
|
||||||
|
except Exception:
|
||||||
|
pass # 마지막 단계라 더 할 게 없음
|
||||||
|
|
||||||
sys.excepthook = hook
|
sys.excepthook = hook
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_to_file(exc_type: str, message: str, tb: str,
|
||||||
|
version: str, reason: str) -> None:
|
||||||
|
"""DB 로깅이 실패한 경우 ~/.clockout_logs/crashes.log에 append.
|
||||||
|
|
||||||
|
Best-effort — 파일 쓰기 실패도 silent (이 시점엔 뭐 할 게 없음).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
log_dir = Path.home() / '.clockout_logs'
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file = log_dir / 'crashes.log'
|
||||||
|
with open(log_file, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(
|
||||||
|
f"\n=== {datetime.now().isoformat(timespec='seconds')} ===\n"
|
||||||
|
f"version={version} reason={reason}\n"
|
||||||
|
f"{exc_type}: {message}\n{tb}\n"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||||
"""crash_log 테이블에 기록."""
|
"""crash_log 테이블에 기록."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+50
-8
@@ -11,12 +11,15 @@ class CSVExporter:
|
|||||||
"""CSV 내보내기 클래스"""
|
"""CSV 내보내기 클래스"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def export_work_records(records: List[Dict], filename: str = None) -> str:
|
def export_work_records(records: List[Dict], filename: str = None,
|
||||||
|
db=None) -> str:
|
||||||
"""
|
"""
|
||||||
근무 기록을 CSV로 내보내기
|
근무 기록을 CSV로 내보내기 (사람이 읽는 한글 헤더 + 사용/미사용 표기).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
records: 근무 기록 리스트
|
records: 근무 기록 리스트
|
||||||
filename: 저장할 파일명 (None이면 자동 생성)
|
filename: 저장할 파일명 (None이면 자동 생성)
|
||||||
|
db: 점심/저녁 기본 분(minutes)을 표시용으로 읽을 Database 인스턴스 (옵션)
|
||||||
Returns:
|
Returns:
|
||||||
str: 저장된 파일 경로
|
str: 저장된 파일 경로
|
||||||
"""
|
"""
|
||||||
@@ -24,31 +27,70 @@ class CSVExporter:
|
|||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"work_records_{timestamp}.csv"
|
filename = f"work_records_{timestamp}.csv"
|
||||||
|
|
||||||
# CSV 파일 작성
|
# 표시용 기본 분 (옵션)
|
||||||
|
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
|
||||||
|
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
|
||||||
|
|
||||||
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'날짜', '출근시간', '퇴근시간', '점심시간',
|
'날짜', '출근시간', '퇴근시간',
|
||||||
'총근무시간', '연장근무(분)', '적립(분)', '메모'
|
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
|
||||||
|
'총근무시간', '연장근무(분)', '적립(분)', '메모',
|
||||||
]
|
]
|
||||||
|
|
||||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
|
|
||||||
for record in records:
|
for record in records:
|
||||||
|
lunch_on = bool(record.get('lunch_break'))
|
||||||
|
dinner_on = bool(record.get('dinner_break'))
|
||||||
row = {
|
row = {
|
||||||
'날짜': record.get('date', ''),
|
'날짜': record.get('date', ''),
|
||||||
'출근시간': record.get('clock_in', ''),
|
'출근시간': record.get('clock_in', ''),
|
||||||
'퇴근시간': record.get('clock_out', '미기록'),
|
'퇴근시간': record.get('clock_out', '미기록'),
|
||||||
'점심시간': '사용' if record.get('lunch_break') else '미사용',
|
'점심시간': '사용' if lunch_on else '미사용',
|
||||||
|
'점심(분)': lunch_default if lunch_on else 0,
|
||||||
|
'저녁시간': '사용' if dinner_on else '미사용',
|
||||||
|
'저녁(분)': dinner_default if dinner_on else 0,
|
||||||
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
|
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
|
||||||
'연장근무(분)': record.get('overtime_minutes', 0),
|
'연장근무(분)': record.get('overtime_minutes', 0),
|
||||||
'적립(분)': record.get('overtime_earned', 0),
|
'적립(분)': record.get('overtime_earned', 0),
|
||||||
'메모': record.get('memo', '')
|
'메모': record.get('memo', ''),
|
||||||
}
|
}
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_work_records_for_reimport(records: List[Dict], filename: str = None,
|
||||||
|
db=None) -> str:
|
||||||
|
"""csv_importer가 직접 다시 읽을 수 있는 round-trip 포맷으로 내보내기.
|
||||||
|
|
||||||
|
헤더: date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||||
|
"""
|
||||||
|
if filename is None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"work_records_reimport_{timestamp}.csv"
|
||||||
|
|
||||||
|
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
|
||||||
|
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
|
||||||
|
|
||||||
|
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||||
|
fieldnames = ['date', 'clock_in', 'clock_out',
|
||||||
|
'lunch_minutes', 'dinner_minutes', 'memo']
|
||||||
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
for record in records:
|
||||||
|
writer.writerow({
|
||||||
|
'date': record.get('date', ''),
|
||||||
|
'clock_in': record.get('clock_in', ''),
|
||||||
|
'clock_out': record.get('clock_out', '') or '',
|
||||||
|
'lunch_minutes': lunch_default if record.get('lunch_break') else 0,
|
||||||
|
'dinner_minutes': dinner_default if record.get('dinner_break') else 0,
|
||||||
|
'memo': record.get('memo', '') or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def export_overtime_summary(db, filename: str = None) -> str:
|
def export_overtime_summary(db, filename: str = None) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+42
-17
@@ -1,14 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
CSV 가져오기 — 우리 표준 포맷.
|
CSV 가져오기 — 우리 표준 포맷.
|
||||||
|
|
||||||
표준 포맷:
|
표준 포맷 (v2.7.1+):
|
||||||
date,clock_in,clock_out,lunch_minutes,memo
|
date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||||
2026-04-01,09:00:00,18:30:00,60,"메모"
|
2026-04-01,09:00:00,18:30:00,60,0,"메모"
|
||||||
|
|
||||||
- 헤더 첫 줄 필수
|
- 헤더 첫 줄 필수
|
||||||
- date: YYYY-MM-DD
|
- date: YYYY-MM-DD
|
||||||
- clock_in/out: HH:MM:SS 또는 HH:MM
|
- clock_in/out: HH:MM:SS 또는 HH:MM (clock_out이 clock_in보다 빠르면 익일로 간주)
|
||||||
- lunch_minutes: 정수 (0이면 점심 미포함)
|
- lunch_minutes: 정수 (0이면 점심 미포함)
|
||||||
|
- dinner_minutes: 정수 (옵션, 0/누락이면 저녁 미포함)
|
||||||
- memo: 선택 (따옴표 가능)
|
- memo: 선택 (따옴표 가능)
|
||||||
|
|
||||||
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
|
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
|
||||||
@@ -45,6 +46,20 @@ def parse_csv(path: str) -> List[Dict]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_minutes(raw: str, field_name: str) -> int:
|
||||||
|
"""0 이상 정수 파싱. 빈 값이면 0."""
|
||||||
|
s = (raw or '').strip()
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
v = int(s)
|
||||||
|
if v < 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"{field_name}는 0 이상 정수여야 함: '{raw}'")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
def _normalize_row(row: Dict) -> Dict:
|
def _normalize_row(row: Dict) -> Dict:
|
||||||
"""단일 행 검증 + 정규화."""
|
"""단일 행 검증 + 정규화."""
|
||||||
date_str = (row.get('date') or '').strip()
|
date_str = (row.get('date') or '').strip()
|
||||||
@@ -59,15 +74,8 @@ def _normalize_row(row: Dict) -> Dict:
|
|||||||
co_raw = (row.get('clock_out') or '').strip()
|
co_raw = (row.get('clock_out') or '').strip()
|
||||||
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
|
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
|
||||||
|
|
||||||
lunch = 0
|
lunch = _parse_minutes(row.get('lunch_minutes', ''), 'lunch_minutes')
|
||||||
lm = (row.get('lunch_minutes') or '').strip()
|
dinner = _parse_minutes(row.get('dinner_minutes', ''), 'dinner_minutes')
|
||||||
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()
|
memo = (row.get('memo') or '').strip()
|
||||||
return {
|
return {
|
||||||
@@ -75,6 +83,7 @@ def _normalize_row(row: Dict) -> Dict:
|
|||||||
'clock_in': ci,
|
'clock_in': ci,
|
||||||
'clock_out': co,
|
'clock_out': co,
|
||||||
'lunch_minutes': lunch,
|
'lunch_minutes': lunch,
|
||||||
|
'dinner_minutes': dinner,
|
||||||
'memo': memo,
|
'memo': memo,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +120,11 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
|||||||
|
|
||||||
added = updated = skipped = 0
|
added = updated = skipped = 0
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
from core.time_calculator import TimeCalculator
|
from core.time_calculator import TimeCalculator
|
||||||
work_minutes = db.get_work_minutes()
|
work_minutes = db.get_work_minutes()
|
||||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
|
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
|
||||||
|
dinner_default = db.get_setting_int('dinner_duration_minutes', 60)
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
existing = db.get_work_record(row['date'])
|
existing = db.get_work_record(row['date'])
|
||||||
@@ -131,6 +142,9 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
|||||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
updated += 1
|
updated += 1
|
||||||
@@ -141,16 +155,27 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
|||||||
if row.get('clock_out'):
|
if row.get('clock_out'):
|
||||||
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
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')
|
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)
|
if co_dt <= ci_dt:
|
||||||
include_lunch = (row.get('lunch_minutes') or 0) > 0
|
co_dt += timedelta(days=1)
|
||||||
|
lunch_min = row.get('lunch_minutes') or 0
|
||||||
|
dinner_min = row.get('dinner_minutes') or 0
|
||||||
|
calc = TimeCalculator(
|
||||||
|
work_minutes=work_minutes,
|
||||||
|
lunch_duration_minutes=lunch_min or lunch_default,
|
||||||
|
dinner_duration_minutes=dinner_min or dinner_default,
|
||||||
|
)
|
||||||
|
include_lunch = lunch_min > 0
|
||||||
|
include_dinner = dinner_min > 0
|
||||||
total = (co_dt - ci_dt).total_seconds() / 3600
|
total = (co_dt - ci_dt).total_seconds() / 3600
|
||||||
ot_actual, ot_earned = calc.calculate_overtime(
|
ot_actual, ot_earned = calc.calculate_overtime(
|
||||||
ci_dt, co_dt, include_lunch=include_lunch
|
ci_dt, co_dt, include_lunch=include_lunch, include_dinner=include_dinner,
|
||||||
)
|
)
|
||||||
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
|
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
|
||||||
if include_lunch:
|
if include_lunch:
|
||||||
db.update_lunch_break(row['date'], True)
|
db.update_lunch_break(row['date'], True)
|
||||||
|
if include_dinner:
|
||||||
|
db.update_dinner_break(row['date'], True)
|
||||||
if ot_earned > 0:
|
if ot_earned > 0:
|
||||||
db.add_overtime_earned(wid, ot_earned, row['date'])
|
db.add_overtime_earned(wid, ot_earned, row['date'])
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -21,6 +22,23 @@ COLOR_YELLOW = 0xFEE75C
|
|||||||
COLOR_PINK = 0xEB459E
|
COLOR_PINK = 0xEB459E
|
||||||
COLOR_ORANGE = 0xED4245
|
COLOR_ORANGE = 0xED4245
|
||||||
|
|
||||||
|
# Discord 웹훅 URL 패턴: https://(discord.com|discordapp.com|...)/api/webhooks/{ID:digits}/{TOKEN}
|
||||||
|
# canary./ptb. 서브도메인도 허용. PTB 모바일 앱이 종종 그 URL 발급.
|
||||||
|
_WEBHOOK_RE = re.compile(
|
||||||
|
r'^https://(?:(?:canary|ptb)\.)?discord(?:app)?\.com/api/webhooks/\d{17,20}/[\w-]{50,}$'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_webhook_url(url: str) -> bool:
|
||||||
|
"""입력된 URL이 Discord webhook 형식인지 검증.
|
||||||
|
|
||||||
|
형식만 확인 — 실제 도달성·토큰 유효성은 send_test()로 검증해야 함.
|
||||||
|
빈 문자열이나 공백은 False (비활성 상태로 간주).
|
||||||
|
"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
return False
|
||||||
|
return bool(_WEBHOOK_RE.match(url.strip()))
|
||||||
|
|
||||||
|
|
||||||
def send(webhook_url: str, title: str, description: str,
|
def send(webhook_url: str, title: str, description: str,
|
||||||
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
|
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
|
||||||
@@ -37,7 +55,7 @@ def send(webhook_url: str, title: str, description: str,
|
|||||||
Returns:
|
Returns:
|
||||||
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
|
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
|
||||||
"""
|
"""
|
||||||
if not webhook_url or not webhook_url.startswith('https://'):
|
if not is_valid_webhook_url(webhook_url):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
공공데이터포털 — 한국천문연구원 특일정보 OpenAPI 클라이언트.
|
||||||
|
|
||||||
|
엔드포인트: getRestDeInfo (국경일/공휴일 — 임시공휴일 포함)
|
||||||
|
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
|
||||||
|
|
||||||
|
`holidays` 패키지가 누락하는 임시공휴일·근로자의 날 등을
|
||||||
|
정부 공인 데이터로 보강하기 위해 사용.
|
||||||
|
|
||||||
|
설계:
|
||||||
|
- 네트워크 실패는 silent (None 반환) — 호출자가 fallback 처리
|
||||||
|
- API 키는 코드 내 박혀있으나 dev 본인 계정의 특일정보 API 한정 키
|
||||||
|
(50명 이내 사용 환경에서 일일 한도 1000회 충분)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
||||||
|
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||||
|
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
||||||
|
|
||||||
|
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
||||||
|
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
|
||||||
|
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
|
||||||
|
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 등)
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
'serviceKey': _SERVICE_KEY,
|
||||||
|
'solYear': str(year),
|
||||||
|
'_type': 'json',
|
||||||
|
'numOfRows': '100',
|
||||||
|
'pageNo': '1',
|
||||||
|
}
|
||||||
|
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError,
|
||||||
|
json.JSONDecodeError, OSError, TimeoutError):
|
||||||
|
return None
|
||||||
|
return _parse_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_response(data: Dict) -> Optional[List[Dict]]:
|
||||||
|
"""API 응답 JSON을 표준 형식으로 정규화.
|
||||||
|
|
||||||
|
API 응답 패턴:
|
||||||
|
- resultCode == '00' → 정상
|
||||||
|
- items.item: 단일 결과면 dict, 여러 개면 list
|
||||||
|
- items가 빈 문자열일 때 (totalCount=0)도 정상으로 간주
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = data.get('response') or {}
|
||||||
|
header = response.get('header') or {}
|
||||||
|
if header.get('resultCode') != '00':
|
||||||
|
return None
|
||||||
|
body = response.get('body') or {}
|
||||||
|
items_root = body.get('items')
|
||||||
|
if not items_root:
|
||||||
|
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
|
||||||
|
item = items_root.get('item') if isinstance(items_root, dict) else None
|
||||||
|
if item is None:
|
||||||
|
return []
|
||||||
|
if isinstance(item, dict):
|
||||||
|
item = [item]
|
||||||
|
out = []
|
||||||
|
for entry in item:
|
||||||
|
locdate = entry.get('locdate')
|
||||||
|
name = entry.get('dateName')
|
||||||
|
is_holiday = (entry.get('isHoliday') == 'Y')
|
||||||
|
if not locdate or not name:
|
||||||
|
continue
|
||||||
|
# locdate: 20260501 (int 또는 str)
|
||||||
|
ds = str(locdate)
|
||||||
|
if len(ds) != 8 or not ds.isdigit():
|
||||||
|
continue
|
||||||
|
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
|
||||||
|
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
|
||||||
|
return out
|
||||||
|
except (AttributeError, TypeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
|
||||||
|
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10
|
||||||
@@ -98,6 +98,10 @@ class SystemTrayIcon(QSystemTrayIcon):
|
|||||||
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
||||||
menu.addAction(cal_action)
|
menu.addAction(cal_action)
|
||||||
|
|
||||||
|
schedule_action = QAction("🗓️ 스케줄", self)
|
||||||
|
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
|
||||||
|
menu.addAction(schedule_action)
|
||||||
|
|
||||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
help_action = QAction("📖 " + tr('menu.help'), self)
|
||||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
||||||
menu.addAction(help_action)
|
menu.addAction(help_action)
|
||||||
|
|||||||
@@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
|
|||||||
|
|
||||||
pid = os.getpid()
|
pid = os.getpid()
|
||||||
try:
|
try:
|
||||||
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
|
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
|
||||||
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
|
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
|
||||||
|
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
DETACHED_PROCESS = 0x00000008
|
||||||
|
CREATE_NO_WINDOW = 0x08000000
|
||||||
|
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
|
||||||
|
else:
|
||||||
|
creationflags = 0
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
[str(updater_exe),
|
[str(updater_exe),
|
||||||
'--pid', str(pid),
|
'--pid', str(pid),
|
||||||
|
|||||||
Reference in New Issue
Block a user