Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5751460e3 | ||
|
|
e7e85dcf7b | ||
|
|
130c61ea62 | ||
|
|
5fb8655a47 | ||
|
|
da5f91984b | ||
|
|
3db4ed2351 | ||
|
|
97dd4e39f7 | ||
|
|
47296dd35b | ||
|
|
c98ca361cd | ||
|
|
d41e5cb921 | ||
|
|
c5df37ca57 | ||
|
|
ff71886fd7 | ||
|
|
6a17876af1 | ||
|
|
606da976a0 | ||
|
|
9ebf4ad961 |
163
AGENTS.md
163
AGENTS.md
@ -1,20 +1,40 @@
|
||||
# Project Conventions and Operational Gotchas
|
||||
|
||||
## 🛠️ Setup & Execution
|
||||
- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature.
|
||||
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
|
||||
- **Run:** `python main.py`
|
||||
- **Module-level tests:**
|
||||
- **Module-level smoke:**
|
||||
- Event monitoring: `python core/event_monitor.py`
|
||||
- Time calculation: `python core/time_calculator.py`
|
||||
- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release.
|
||||
- **Integration tests** (all should be green before release):
|
||||
- `python _integration_test.py` — business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+)
|
||||
- `python _i18n_gui_test.py` — ko/en switch on real widgets
|
||||
- `python _gui_smoke_test.py` — widget instantiation
|
||||
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
|
||||
|
||||
## 🗄️ Architecture Notes (Core Business Logic)
|
||||
- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs.
|
||||
|
||||
### Database (10+ tables in `database.db`)
|
||||
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
|
||||
|
||||
### Invariants
|
||||
- **`work_records.date` UNIQUE** — one row per workday.
|
||||
- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out.
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours).
|
||||
- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect.
|
||||
- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
|
||||
- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
|
||||
- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox).
|
||||
|
||||
### Settings system
|
||||
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
||||
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
||||
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
||||
|
||||
### i18n
|
||||
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
|
||||
- Sentence formatting via Python `str.format(**kwargs)`.
|
||||
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
|
||||
|
||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||
|
||||
@ -22,46 +42,135 @@
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
|
||||
|
||||
### 2. Hot-path caching
|
||||
`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`.
|
||||
`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`.
|
||||
|
||||
### 3. Time format separation
|
||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
||||
|
||||
### 4. Workday boundary
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover.
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic.
|
||||
|
||||
### 5. Migration idempotency
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query.
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.
|
||||
|
||||
### 6. Single-file deployment
|
||||
`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build.
|
||||
|
||||
### 7. Updater handoff
|
||||
`updater.py` is standalone (no PyQt). Args: `--pid <main_pid> --new <new_main.exe> --target <current_main.exe>`. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater.
|
||||
|
||||
## 🧩 Module Map
|
||||
|
||||
### `core/`
|
||||
- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`).
|
||||
- `time_calculator.py` — `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit).
|
||||
- `event_monitor.py` — Win Event IDs 6005/4624/6006.
|
||||
- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable.
|
||||
- `i18n.py` — `_DICT` ko/en + `_HELP_HTML` (6 tabs).
|
||||
- `salary.py` — `estimate_pay(records, hourly_wage, overtime_rate=1.5)`.
|
||||
- `settings_keys.py` — All setting keys as constants.
|
||||
- `version.py` — `__version__` single source of truth.
|
||||
|
||||
### `ui/`
|
||||
- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts.
|
||||
- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
|
||||
- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
|
||||
- `mini_widget.py` — always-on-top frameless.
|
||||
- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button.
|
||||
- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel).
|
||||
- `today_summary.py` — post-clockout card.
|
||||
- `goal_widget.py` — monthly progress bars (overtime cap, daily avg).
|
||||
- `meal_time_dialog.py` — lunch/dinner real start-end input.
|
||||
- `past_record_dialog.py` — calendar right-click "add past record".
|
||||
- `leave_calendar_view.py` — color-coded leave (green/yellow/purple).
|
||||
- `accessibility.py` — `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`.
|
||||
- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
|
||||
|
||||
### `ui/controllers/`
|
||||
- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
|
||||
- `auto_lunch.py` — toggles lunch after 4 hours since clock-in.
|
||||
- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays.
|
||||
|
||||
### `utils/`
|
||||
- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API.
|
||||
- `lock_detector.py` — `OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
|
||||
- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally.
|
||||
- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`.
|
||||
- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
|
||||
- `csv_exporter.py` — same format.
|
||||
- `crash_handler.py` — `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report.
|
||||
- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`.
|
||||
- `system_tray.py` — tray menu with i18n labels.
|
||||
- `time_format.py` — `format_hours_minutes(minutes)`.
|
||||
- `debug_log.py` — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`.
|
||||
- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver.
|
||||
|
||||
### Top-level
|
||||
- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow.
|
||||
- `updater.py` — Standalone PID wait + file replace + relaunch.
|
||||
- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`.
|
||||
- `updater.spec` — Standalone updater build.
|
||||
- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.
|
||||
|
||||
## ⚙️ Build Process
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean main.spec
|
||||
# Manual two-step
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
||||
|
||||
# Or one-shot
|
||||
.\release.ps1 v2.7.0
|
||||
```
|
||||
|
||||
- Output: `dist/main.exe` (console disabled, UPX compressed)
|
||||
- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`)
|
||||
- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first.
|
||||
- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env.
|
||||
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
||||
- `holidays` package only baked in if installed in build env.
|
||||
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
||||
|
||||
## 🚦 External Integrations
|
||||
|
||||
- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design.
|
||||
- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<version>`. Repo must be public.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
|
||||
- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only.
|
||||
- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
|
||||
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
|
||||
## 🐞 Past Incidents
|
||||
## 🐞 Past Incidents (do NOT re-introduce)
|
||||
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce.
|
||||
- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling.
|
||||
- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters.
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
|
||||
- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`.
|
||||
- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor).
|
||||
- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
|
||||
- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
|
||||
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
|
||||
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`.
|
||||
- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
|
||||
- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
|
||||
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
|
||||
|
||||
## 🌐 i18n Coverage Status
|
||||
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels.
|
||||
- **Partially translated**: settings_view sub-labels (입력란 placeholder 등), calendar_view detail labels.
|
||||
- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated.
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
|
||||
- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
|
||||
- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).
|
||||
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`.
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`.
|
||||
|
||||
## 🚢 Release Flow ([release.ps1](release.ps1))
|
||||
|
||||
```
|
||||
0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
|
||||
1. Bump core/version.py
|
||||
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
|
||||
3. PyInstaller (updater.spec → staging copy → main.spec)
|
||||
4. ZIP packaging (main.exe + updater.exe)
|
||||
5. Git commit (version.py + CHANGELOG.md) + tag + push
|
||||
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
|
||||
7. Asset upload (main.exe, updater.exe, ZIP)
|
||||
```
|
||||
|
||||
`--DryRun` previews without git push or API calls.
|
||||
|
||||
281
CHANGELOG.md
281
CHANGELOG.md
@ -4,6 +4,287 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.11.2] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
|
||||
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
|
||||
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
|
||||
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
|
||||
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
|
||||
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
|
||||
|
||||
## [2.11.1] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
|
||||
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget`이 `backend_qt5agg`를
|
||||
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
|
||||
폴백) + 실패 원인 로깅, `main.spec`에 `backend_qtagg`/`PyQt5.sip` 명시.
|
||||
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제** — `dark_components`와
|
||||
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
|
||||
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
|
||||
|
||||
## [2.11.0] — 2026-06-04
|
||||
|
||||
### Changed — UI 전면 다크 리디자인
|
||||
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
|
||||
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
|
||||
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
|
||||
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
|
||||
`main.spec`에 동봉
|
||||
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
|
||||
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
|
||||
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
|
||||
|
||||
### Added
|
||||
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
|
||||
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함)
|
||||
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
|
||||
(`Database.delete_overtime_earned`)
|
||||
|
||||
### Fixed
|
||||
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
|
||||
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
|
||||
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
|
||||
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
|
||||
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
|
||||
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
|
||||
|
||||
### Tests
|
||||
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
|
||||
|
||||
## [2.10.2] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
|
||||
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
|
||||
그대로 표시에 사용 → 초 정보 소실
|
||||
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
|
||||
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
|
||||
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
|
||||
|
||||
## [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
|
||||
|
||||
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
|
||||
- **i18n 사전 100% 커버리지** — `break_view`, `overtime_view`, `leave_view`,
|
||||
`clock_in_dialog` 의 내부 라벨/메시지/플레이스홀더까지 전부 ko/en 키화.
|
||||
새 키 50+개 추가 (`view.break.*`, `view.overtime.*`, `view.leave.*`, `dlg.*`).
|
||||
- **i18n 런타임 재번역** — 재시작 없이 메인 화면 즉시 언어 전환.
|
||||
- `ui/i18n_runtime.py`: `register(widget, key)` + `set_language_and_retranslate(lang)`
|
||||
- 위젯은 weakref로 보관되어 삭제 시 자동 정리
|
||||
- 메인 윈도우 타이틀/하단 메뉴 5개 버튼 등록 완료 (점진 확대)
|
||||
- 일부 다이얼로그는 여전히 재시작 필요 (다음 릴리스에서 점진 등록)
|
||||
- **MealController 분리** — `main_window.py` 에서 점심/저녁 토글·라벨 갱신 로직을
|
||||
`ui/controllers/meal_controller.py` 로 추출. 기존 `LockMonitor`/`AutoLunch`/
|
||||
`NotificationOrchestrator` 패턴 준수.
|
||||
|
||||
### Tests
|
||||
- 통합 테스트 +15 시나리오 (S36–S52): 온보딩 신규/기존, salary 추정, CSV 가져오기
|
||||
(skip/overwrite/overtime 적립까지), notification_log dedupe, meal_record,
|
||||
crash_log, updater semver 비교, Discord 입력 검증, goal/accessibility 키 등.
|
||||
- 신규 pytest 모듈 4종 — `test_salary.py`, `test_csv_importer.py`,
|
||||
`test_discord_webhook.py`(network mocked), `test_crash_handler.py`.
|
||||
- `test_i18n_runtime.py` — register/retranslate/post-callback/dead-widget 정리 검증.
|
||||
- 총 pytest 케이스: 90 → **122**, 통합 시나리오: 33 → **48**.
|
||||
|
||||
### Docs
|
||||
- `README.md` v2.4–v2.6 신기능 섹션 정리, 재번호.
|
||||
- `CLAUDE.md` v2.6+ 아키텍처 — 컨트롤러 모듈, 35+ 설정 키, 릴리스 플로우 반영.
|
||||
- `INSTALL.md` 최신 단일파일 배포 + 온보딩 + 디스코드 + 환경변수 정리.
|
||||
- `AGENTS.md` 10+ DB 테이블, 컨트롤러 맵, Past Incidents 갱신.
|
||||
|
||||
## [2.6.0] — 2026-04-30
|
||||
|
||||
### Added — Phase 4 (3종)
|
||||
- **글꼴 크기 조절** (100% / 125% / 150%) — 설정에서 즉시 반영
|
||||
- **고대비 모드** — 검정 배경 + 노란 텍스트 (시각약자/야간)
|
||||
- **코드 서명 인프라** — `release.ps1`에 Authenticode 서명 단계 추가 (옵션)
|
||||
- `$env:CODE_SIGN_CERT` (`.pfx` 경로) + `$env:CODE_SIGN_PASS` 환경변수 설정 시 자동 서명
|
||||
- signtool.exe 없거나 cert 미설정 시 자동 스킵
|
||||
- 코드 서명 인증서 확보 후 활성화하면 SmartScreen 경고 제거 가능
|
||||
|
||||
### Settings (신규)
|
||||
- `font_scale`, `high_contrast`
|
||||
|
||||
## [2.5.0] — 2026-04-30
|
||||
|
||||
### Added — Phase 3 (4종)
|
||||
- **주간 자동 리포트** — 월요일 첫 출근 시 (또는 첫 5분 tick) 지난주 요약 발송
|
||||
- 시스템 알림 + Discord push (옵션) 동시
|
||||
- `notification_log`로 중복 발송 방지
|
||||
- 항목: 총 근무·일평균·연장근무·가장 긴 날
|
||||
- **matplotlib 차트 호버 디테일** — 막대 위에 마우스 올리면 정확한 수치 툴팁
|
||||
- 일별 근무 시간 차트(주간 탭)에 적용
|
||||
- **출근 시각 분포 차트** — 패턴 분석 탭에 30분 단위 히스토그램
|
||||
- 평균 출근 시각 빨간 점선으로 표시
|
||||
- **휴가 캘린더 시각화** — 연차 관리 → "📅 캘린더 보기"
|
||||
- 사용 일자에 종일/반차/반반차별 색상 표시
|
||||
- 날짜 클릭 → 사용 내역 표시
|
||||
|
||||
### Fixed
|
||||
- `leave_view.py` setLayout 들여쓰기 회귀 수정
|
||||
|
||||
## [2.4.0] — 2026-04-30
|
||||
|
||||
### Added — Phase 2 (5종)
|
||||
- **점심/저녁 실제 시간 입력** — 점심/저녁 버튼 우클릭 → 시작·종료 시각 입력 다이얼로그
|
||||
- 자동 60분 대신 정확한 분 단위 기록 (`break_records.break_type='lunch'/'dinner'`)
|
||||
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
|
||||
- 비어있는 날짜 우클릭: "기록 추가" — 출/퇴근/점심/메모 입력
|
||||
- 기록 있는 날짜 우클릭: "편집"/"삭제"
|
||||
- **월간 목표 설정 + 진행률**
|
||||
- 설정 → 월 연장근무 상한 (시간/분) + 일 평균 근무 목표 (시간)
|
||||
- 통계 → 월간 탭에 진행률 게이지 (60%/100% 임계 시 색상 변경)
|
||||
- 0=비활성 (비활성 시 위젯 자체 숨김)
|
||||
- **CSV 가져오기** — 표준 포맷 `date,clock_in,clock_out,lunch_minutes,memo`
|
||||
- 충돌 정책: 덮어쓰기/건너뛰기/취소
|
||||
- **자동 Crash Report (Gitea Issues)**
|
||||
- 전역 예외 후킹 → crash_log 저장 + 사용자에게 다이얼로그
|
||||
- "복사" / "Gitea에 보고" (PAT 옵션) — issue 자동 생성
|
||||
|
||||
### Settings (신규 4개)
|
||||
- `goal_overtime_max_monthly`, `goal_avg_hours_daily`
|
||||
- `gitea_feedback_token`, `gitea_feedback_enabled`
|
||||
|
||||
## [2.3.3] — 2026-04-30
|
||||
|
||||
### Fixed
|
||||
|
||||
165
CLAUDE.md
165
CLAUDE.md
@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable).
|
||||
**Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases.
|
||||
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`.
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`.
|
||||
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md).
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Build and Run
|
||||
|
||||
@ -20,46 +20,80 @@ python main.py
|
||||
python core/event_monitor.py
|
||||
python core/time_calculator.py
|
||||
|
||||
# Production build → dist/main.exe
|
||||
# Production build → dist/main.exe (78MB, embeds updater.exe)
|
||||
python -m PyInstaller --clean updater.spec # build first — main.spec datas references it
|
||||
python -m PyInstaller --clean main.spec
|
||||
|
||||
# Integration tests (35 + 5 + view scenarios)
|
||||
python _integration_test.py
|
||||
python _i18n_gui_test.py
|
||||
python _gui_smoke_test.py
|
||||
```
|
||||
# Tests
|
||||
python _integration_test.py # business-logic scenarios
|
||||
python _i18n_gui_test.py # ko/en GUI verification
|
||||
python _gui_smoke_test.py # widget instantiation
|
||||
python -m pytest tests # unit tests
|
||||
|
||||
PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build.
|
||||
# Release (one-shot to Gitea)
|
||||
$env:GITEA_TOKEN = '<PAT>'
|
||||
.\release.ps1 v2.7.0
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core (`core/`)
|
||||
- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`.
|
||||
- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`.
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required.
|
||||
- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en.
|
||||
- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`.
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings.
|
||||
### core/
|
||||
- **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness.
|
||||
- **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property.
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
|
||||
- **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold.
|
||||
- **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0).
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
|
||||
- **[version.py](core/version.py)** — `__version__` single source of truth.
|
||||
|
||||
### UI (`ui/`)
|
||||
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R).
|
||||
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`.
|
||||
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button.
|
||||
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible.
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension).
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing.
|
||||
### ui/
|
||||
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run.
|
||||
- **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog.
|
||||
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import.
|
||||
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`.
|
||||
- **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
|
||||
- **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
|
||||
- **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input.
|
||||
- **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click).
|
||||
- **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar.
|
||||
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display.
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button.
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`.
|
||||
- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay.
|
||||
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n).
|
||||
|
||||
### Utils (`utils/`)
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy.
|
||||
- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked).
|
||||
- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`.
|
||||
- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production.
|
||||
### ui/controllers/
|
||||
- **[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.
|
||||
- **[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/
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
|
||||
- **[lock_detector.py](utils/lock_detector.py)** — `is_screen_locked()` via Win32 `OpenInputDesktop` + `GetUserObjectInformation`.
|
||||
- **[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.
|
||||
- **[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.
|
||||
- **[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.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n.
|
||||
|
||||
### Time-off accounting in `update_display()`
|
||||
### Top-level
|
||||
- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow.
|
||||
- **[updater.py](updater.py)** — Standalone helper. `--pid <main_pid> --new <new_exe> --target <target_exe>`. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback.
|
||||
- **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps).
|
||||
- **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it).
|
||||
- **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`.
|
||||
|
||||
## Time-off accounting in `update_display()`
|
||||
|
||||
Critical invariant — preserve in any change:
|
||||
```python
|
||||
@ -72,49 +106,56 @@ remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_min
|
||||
remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation
|
||||
```
|
||||
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`.
|
||||
|
||||
### Workday rollover
|
||||
|
||||
`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006).
|
||||
|
||||
## Database invariants
|
||||
|
||||
- `work_records.date` UNIQUE (one row/day).
|
||||
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label.
|
||||
- `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25).
|
||||
- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code.
|
||||
- `work_records.date` UNIQUE.
|
||||
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions.
|
||||
- `leave_records.days` is FLOAT (1.0/0.5/0.25).
|
||||
- Balance: `SUM(bank.earned) - SUM(usage.used)`.
|
||||
- `notification_log` for daily dedupe (channel+event_type+date).
|
||||
- `crash_log` for unhandled exceptions.
|
||||
|
||||
## Settings system
|
||||
|
||||
Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`:
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only)
|
||||
Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`:
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor)
|
||||
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
|
||||
|
||||
Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup.
|
||||
Migration sentinels prevent re-running.
|
||||
|
||||
## i18n
|
||||
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal.
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장.
|
||||
|
||||
Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text).
|
||||
Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched.
|
||||
|
||||
## Conventions and gotchas
|
||||
## Conventions
|
||||
|
||||
- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types.
|
||||
- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`.
|
||||
- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover.
|
||||
- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min.
|
||||
- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture.
|
||||
- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon).
|
||||
- **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict.
|
||||
- 24h `datetime` internal. 12h conversion only in `format_time()`.
|
||||
- 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min.
|
||||
- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
|
||||
- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
|
||||
|
||||
## Tests
|
||||
|
||||
- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets.
|
||||
- [_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`.
|
||||
- [_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_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
|
||||
|
||||
All three should be green before any release.
|
||||
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
|
||||
|
||||
All should be green before any release.
|
||||
|
||||
## Release flow
|
||||
|
||||
```bash
|
||||
# Edit core/version.py + CHANGELOG.md
|
||||
git add -A && git commit -m "v2.X.Y: ..."
|
||||
.\release.ps1 v2.X.Y
|
||||
```
|
||||
|
||||
Auto-handles: version bump check, pytest+integration tests, two-exe build, ZIP, git tag push, Gitea Release create, asset upload (main.exe + updater.exe + ZIP).
|
||||
|
||||
176
INSTALL.md
176
INSTALL.md
@ -1,24 +1,45 @@
|
||||
# 설치 가이드
|
||||
|
||||
## 1. Python 설치
|
||||
## 일반 사용자 — 빌드된 .exe로 설치 (권장)
|
||||
|
||||
소스 빌드 없이 즉시 사용하려면 Gitea Releases에서 받으세요.
|
||||
|
||||
1. https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator/releases
|
||||
2. 최신 릴리스의 **main.exe** (단일 파일) 다운로드
|
||||
3. 원하는 폴더에 두고 더블클릭으로 실행
|
||||
4. 첫 실행 시 5단계 온보딩 위저드가 안내
|
||||
|
||||
`main.exe` 안에 `updater.exe`가 내장되어 있어 첫 실행 시 같은 폴더로 자동 추출됩니다.
|
||||
이후 새 버전이 올라오면 앱이 알림 → 동의 → 자동 다운로드·교체·재시작합니다.
|
||||
|
||||
> 옵션: 직접 실행하지 않고 ZIP을 받으면 `main.exe + updater.exe`가 같이 들어 있습니다.
|
||||
|
||||
## 개발자 — 소스에서 실행
|
||||
|
||||
### 1. Python 설치
|
||||
|
||||
Python 3.9 이상이 필요합니다.
|
||||
|
||||
### Windows
|
||||
#### Windows
|
||||
1. https://www.python.org/downloads/ 방문
|
||||
2. "Download Python 3.x.x" 클릭
|
||||
3. 설치 시 **"Add Python to PATH"** 체크 필수!
|
||||
3. 설치 시 **"Add Python to PATH"** 체크 필수
|
||||
|
||||
확인:
|
||||
```bash
|
||||
python --version
|
||||
```
|
||||
|
||||
## 2. 프로젝트 다운로드
|
||||
### 2. 프로젝트 다운로드
|
||||
|
||||
프로젝트를 다운로드하거나 압축을 해제합니다.
|
||||
```bash
|
||||
git clone https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator.git
|
||||
cd Clock_out_Time_Calculator
|
||||
```
|
||||
|
||||
## 3. 패키지 설치
|
||||
또는 ZIP을 받아 압축 해제.
|
||||
|
||||
### 3. 패키지 설치
|
||||
|
||||
프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다.
|
||||
|
||||
@ -26,66 +47,26 @@ python --version
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 설치되는 패키지:
|
||||
1. **PyQt5** - GUI 프레임워크
|
||||
2. **pywin32** - Windows API 접근
|
||||
3. **python-dateutil** - 날짜 계산
|
||||
4. **matplotlib** - 그래프
|
||||
5. **plyer** - 알림 시스템
|
||||
6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함)
|
||||
#### 설치되는 패키지 (requirements.txt)
|
||||
1. **PyQt5** — GUI 프레임워크
|
||||
2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지)
|
||||
3. **python-dateutil** — 날짜 계산
|
||||
4. **matplotlib** — 통계 차트
|
||||
5. **plyer** — 시스템 알림
|
||||
6. **holidays** — 한국 공휴일 자동 등록 (음력 명절 포함)
|
||||
|
||||
## 4. 리소스 다운로드 (선택)
|
||||
|
||||
리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다.
|
||||
|
||||
```bash
|
||||
python download_resources.py
|
||||
```
|
||||
|
||||
이 스크립트는 무료 리소스 다운로드 링크를 안내합니다.
|
||||
|
||||
### 수동 다운로드:
|
||||
|
||||
#### 아이콘
|
||||
다음 사이트에서 다운로드:
|
||||
- **Flaticon**: https://www.flaticon.com/
|
||||
- **Material Design Icons**: https://materialdesignicons.com/
|
||||
- **Icons8**: https://icons8.com/
|
||||
|
||||
다운로드 후 `resources/icons/` 폴더에 저장
|
||||
|
||||
필요한 아이콘:
|
||||
- `app_icon.ico` (512x512)
|
||||
- `clock.png`, `timer.png`, `lunch.png`
|
||||
- `calendar.png`, `statistics.png`, `vacation.png`
|
||||
- `settings.png`, `notification.png`
|
||||
|
||||
#### 사운드
|
||||
다음 사이트에서 다운로드:
|
||||
- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천)
|
||||
- **Freesound**: https://freesound.org/
|
||||
- **Zapsplat**: https://www.zapsplat.com/
|
||||
|
||||
다운로드 후 `resources/sounds/` 폴더에 저장
|
||||
|
||||
필요한 사운드:
|
||||
- `clock_out_alarm.wav` - 퇴근시간 알림
|
||||
- `notification.wav` - 일반 알림
|
||||
- `success.wav` - 성공 효과음
|
||||
|
||||
## 5. 실행
|
||||
### 4. 실행
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 관리자 권한으로 실행 (권장)
|
||||
#### 관리자 권한으로 실행 (권장)
|
||||
|
||||
Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다.
|
||||
|
||||
1. **방법 1**: cmd를 관리자 권한으로 실행
|
||||
- Windows 키 + X
|
||||
- "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택
|
||||
- Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)"
|
||||
- 프로젝트 폴더로 이동: `cd "경로"`
|
||||
- `python main.py` 실행
|
||||
|
||||
@ -93,11 +74,39 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있
|
||||
- `python main.py`를 실행하는 배치 파일(.bat) 생성
|
||||
- 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크
|
||||
|
||||
## 6. 첫 실행
|
||||
### 5. 첫 실행
|
||||
|
||||
1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성
|
||||
2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지
|
||||
3. 감지된 시간이 출근시간으로 설정됨
|
||||
1. 실행 시 자동으로 데이터베이스(`database.db`) 생성
|
||||
2. 5단계 온보딩 위저드 표시
|
||||
- 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → Discord 웹훅(옵션)
|
||||
3. 위저드 완료 후 메인 화면 진입
|
||||
4. 이후 실행에서는 위저드 없이 바로 시작
|
||||
|
||||
### 6. 단축근무자 설정 (예: 7시간 30분)
|
||||
|
||||
온보딩에서 미설정한 경우:
|
||||
1. 설정(`Ctrl+,`) → 근무 시간 → **근무 패턴**
|
||||
2. "단축근무 7시간 30분 (점심 30분)" 또는 사용자 정의 선택
|
||||
3. 시·분 직접 입력 가능 (5분 단위)
|
||||
4. 저장 → 즉시 메인 화면 반영
|
||||
|
||||
## 클라우드 동기화 (여러 PC 공용)
|
||||
|
||||
OneDrive / Dropbox 폴더에 DB를 두면 자동 동기화됩니다 (WAL 모드).
|
||||
|
||||
1. OneDrive/Dropbox 안에 `database.db` 위치 결정
|
||||
2. 설정 → 데이터 관리 → DB 경로 → 변경
|
||||
3. 기존 DB를 새 위치로 복사 → 재시작
|
||||
4. 다른 PC에서도 같은 경로 지정하면 데이터 공유
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 변수 | 용도 |
|
||||
|------|------|
|
||||
| `CLOCKOUT_DEBUG=1` | `~/.clockout_logs/debug.log`에 디버그 로그 출력 |
|
||||
| `CLOCKOUT_DEBUG_DIR=경로` | 로그 저장 위치 변경 |
|
||||
| `GITEA_TOKEN` | 릴리스 발행 시 PAT (개발자용) |
|
||||
| `CODE_SIGN_CERT` / `CODE_SIGN_PASS` | Authenticode 인증서 경로/암호 (개발자용) |
|
||||
|
||||
## 문제 해결
|
||||
|
||||
@ -119,42 +128,57 @@ https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||
|
||||
### 이벤트 뷰어 접근 불가
|
||||
- 관리자 권한으로 실행
|
||||
- 또는 설정에서 수동 입력 모드 사용
|
||||
- 또는 메인 화면 출근시각 옆 ✏️ 버튼으로 수동 입력
|
||||
|
||||
### Discord 웹훅 "실패" 표시
|
||||
- v2.3.3 이전 버전에서 발생 — Cloudflare가 Python User-Agent 차단
|
||||
- 최신 버전으로 업데이트하면 해결 (브라우저 UA로 우회)
|
||||
|
||||
### 온보딩 위저드를 다시 보고 싶음
|
||||
- 도움말(F1) → "🚀 온보딩 다시 보기"
|
||||
|
||||
## 업그레이드
|
||||
|
||||
### .exe 사용자
|
||||
- 앱이 자동 감지 → 알림 → 동의 → 자동 처리
|
||||
- 수동 트리거: F5 또는 설정 → 데이터 관리 → "업데이트 확인"
|
||||
|
||||
### 소스 사용자
|
||||
```bash
|
||||
git pull
|
||||
pip install --upgrade -r requirements.txt
|
||||
```
|
||||
|
||||
## 제거
|
||||
|
||||
1. 프로젝트 폴더 삭제
|
||||
2. 패키지 제거 (선택):
|
||||
```bash
|
||||
pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer
|
||||
```
|
||||
1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스)
|
||||
2. 데이터 보존하려면 `database.db`만 별도 백업
|
||||
3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제)
|
||||
|
||||
## 프로덕션 빌드 (PyInstaller)
|
||||
|
||||
소스 없이 실행 파일만 배포하려면:
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB)
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터)
|
||||
# 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드)
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
|
||||
# updater.exe를 staging 폴더로 복사 (main.spec --clean 시 보호)
|
||||
mkdir -p build/staging
|
||||
cp dist/updater.exe build/staging/
|
||||
|
||||
# 메인 앱 빌드 (updater.exe 임베드 포함)
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB)
|
||||
```
|
||||
|
||||
배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요.
|
||||
자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다.
|
||||
또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화.
|
||||
|
||||
빌드 시 주의:
|
||||
- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행
|
||||
- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력
|
||||
- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경
|
||||
- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출)
|
||||
|
||||
## 다음 단계
|
||||
|
||||
설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요!
|
||||
설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요.
|
||||
개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요.
|
||||
|
||||
36
README.md
36
README.md
@ -45,9 +45,11 @@
|
||||
|
||||
### 7. 통계·분석
|
||||
- 주간/월간 요약 + matplotlib 차트
|
||||
- 일별 근무시간 + 연장 누적 막대 그래프
|
||||
- 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁)
|
||||
- 요일별 평균 근무시간
|
||||
- 근무 패턴 인사이트 (정적 통계 요약)
|
||||
- 출근 시각 분포 히스토그램 (30분 단위 + 평균선)
|
||||
- 근무 패턴 인사이트
|
||||
- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약)
|
||||
|
||||
### 8. 공휴일 관리
|
||||
- 한국 공휴일 자동 등록 (`holidays` 패키지)
|
||||
@ -69,13 +71,35 @@
|
||||
- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작
|
||||
- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거
|
||||
- 실패 시 자동 롤백
|
||||
- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출)
|
||||
|
||||
### 12. 다국어 지원 (i18n)
|
||||
- 한국어 / English 전환
|
||||
- 알림 메시지·UI 라벨 28개 카테고리
|
||||
### 12. 첫 실행 온보딩 위저드
|
||||
- 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시
|
||||
- 기존 사용자: 자동 완료 처리 + 도움말(F1) → "🚀 온보딩 다시 보기"
|
||||
|
||||
### 13. 사용자 친화 기능
|
||||
- **메인 화면 인라인 편집** — 출퇴근 시각 라벨 클릭으로 즉시 수정
|
||||
- **퇴근 후 "오늘 요약" 카드** — 총 근무·점심·외출·연장·추정급여 한눈에
|
||||
- **장시간 근무 휴식 권고** — 4시간 연속 근무 시 토스트 + Discord push
|
||||
- **점심/저녁 실제 시간 입력** — 버튼 우클릭 → 시작·종료 시각
|
||||
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
|
||||
- **월간 목표** — 연장근무 상한 / 일평균 목표 + 진행률 게이지
|
||||
- **CSV 가져오기** — 표준 포맷으로 타 도구에서 마이그레이션
|
||||
- **자동 Crash Report** — 예외 발생 시 Gitea Issues 자동 등록 (옵션)
|
||||
- **주간 자동 리포트** — 월요일 첫 출근 시 지난주 요약 push
|
||||
- **휴가 캘린더** — 종일/반차/반반차 색상 구분
|
||||
- **Discord 웹훅 알림** — 출퇴근/휴식권고/주간리포트 모바일 push (옵션, 서버 0)
|
||||
|
||||
### 14. 접근성
|
||||
- 글꼴 크기 100% / 125% / 150%
|
||||
- 고대비 모드 (검정 배경 + 노란 텍스트)
|
||||
|
||||
### 16. 다국어 지원 (i18n)
|
||||
- 한국어 / English 전환 (재시작 또는 즉시 갱신)
|
||||
- 알림 메시지·UI 라벨 30+ 카테고리
|
||||
- HelpView 6개 탭 ko/en HTML 콘텐츠
|
||||
|
||||
### 13. 단축키
|
||||
### 17. 단축키
|
||||
- `Ctrl+O` 출/퇴근 토글
|
||||
- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글
|
||||
- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말
|
||||
|
||||
@ -15,6 +15,11 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
|
||||
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
|
||||
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
|
||||
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
|
||||
|
||||
PASS = []
|
||||
FAIL = []
|
||||
WARN = []
|
||||
@ -485,6 +490,302 @@ def s35_lock():
|
||||
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
|
||||
# ============================================================
|
||||
|
||||
@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
|
||||
def s36_onboarding_new():
|
||||
db = fresh_db('s36')
|
||||
assert db.get_setting('onboarding_completed') == 'false'
|
||||
|
||||
|
||||
@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
|
||||
def s37_onboarding_existing():
|
||||
db = fresh_db('s37')
|
||||
# work_record 1건 추가 후 마이그레이션 재실행
|
||||
today = date.today().isoformat()
|
||||
db.add_work_record(today, '09:00:00', is_manual=True)
|
||||
# 다시 호출 (init_database에서 호출되는 헬퍼)
|
||||
db.migrate_v23_onboarding_for_existing()
|
||||
assert db.get_setting('onboarding_completed') == 'true'
|
||||
|
||||
|
||||
@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
|
||||
def s38_salary_zero_wage():
|
||||
from core.salary import estimate_pay
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
|
||||
assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0
|
||||
|
||||
|
||||
@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
|
||||
def s39_salary_basic():
|
||||
from core.salary import estimate_pay
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 8.0, 'overtime_minutes': 30}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=1.5,
|
||||
)
|
||||
# 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
|
||||
# 연장 = 0.5h × 10000 × 1.5 = 7500
|
||||
assert abs(out['base'] - 75000) < 0.01, out['base']
|
||||
assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
|
||||
assert abs(out['total'] - 82500) < 0.01
|
||||
|
||||
|
||||
@case("S40. salary.format_won: 콤마 + '원' 접미사")
|
||||
def s40_format_won():
|
||||
from core.salary import format_won
|
||||
assert format_won(0) == '0원'
|
||||
assert format_won(1234567) == '1,234,567원'
|
||||
assert format_won(82500.4) == '82,500원'
|
||||
|
||||
|
||||
@case("S41. CSV 가져오기: 표준 포맷 파싱")
|
||||
def s41_csv_parse():
|
||||
from utils.csv_importer import parse_csv
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,첫째날\n")
|
||||
f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
|
||||
rows = parse_csv(p)
|
||||
os.remove(p)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS)
|
||||
assert rows[0]['lunch_minutes'] == 60
|
||||
assert rows[1]['lunch_minutes'] == 30
|
||||
assert rows[1]['memo'] == '단축'
|
||||
|
||||
|
||||
@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
|
||||
def s42_csv_invalid():
|
||||
from utils.csv_importer import parse_csv
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("not-a-date,09:00,18:00,60,\n")
|
||||
try:
|
||||
parse_csv(p)
|
||||
assert False, "should have raised"
|
||||
except ValueError:
|
||||
pass
|
||||
finally:
|
||||
os.remove(p)
|
||||
|
||||
|
||||
@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
|
||||
def s43_csv_skip():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s43')
|
||||
# 기존 레코드 1건
|
||||
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
|
||||
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,중복\n")
|
||||
f.write("2026-04-02,09:00,18:00,60,신규\n")
|
||||
|
||||
rows = parse_csv(p)
|
||||
added, updated, skipped = import_records(db, rows, on_conflict='skip')
|
||||
os.remove(p)
|
||||
assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
|
||||
# 기존 레코드 보존 확인 (08:30 그대로)
|
||||
rec = db.get_work_record('2026-04-01')
|
||||
assert rec['clock_in'] == '08:30:00'
|
||||
|
||||
|
||||
@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
|
||||
def s44_csv_overwrite():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s44')
|
||||
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
|
||||
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")
|
||||
|
||||
rows = parse_csv(p)
|
||||
added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
|
||||
os.remove(p)
|
||||
assert updated == 1 and added == 0
|
||||
rec = db.get_work_record('2026-04-01')
|
||||
assert rec['clock_in'] == '09:00:00'
|
||||
|
||||
|
||||
@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
|
||||
def s45_notification_dedupe():
|
||||
db = fresh_db('s45')
|
||||
assert not db.has_notification_today('discord', 'weekly_report')
|
||||
db.log_notification('discord', 'weekly_report', payload='test', success=True)
|
||||
assert db.has_notification_today('discord', 'weekly_report')
|
||||
# 다른 event_type은 별개
|
||||
assert not db.has_notification_today('discord', 'clock_in')
|
||||
|
||||
|
||||
@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
|
||||
def s46_meal_record():
|
||||
db = fresh_db('s46')
|
||||
today = date.today().isoformat()
|
||||
# 오늘이 아닌 날짜로 add (work_record 미존재 OK)
|
||||
db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
|
||||
db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
|
||||
# get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
|
||||
('2026-04-01',))
|
||||
assert cur.fetchone()[0] == 60
|
||||
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
|
||||
('2026-04-01',))
|
||||
assert cur.fetchone()[0] == 30
|
||||
conn.close()
|
||||
|
||||
|
||||
@case("S47. crash_log 자동 생성 + 기록")
|
||||
def s47_crash_log():
|
||||
from utils.crash_handler import _log_crash
|
||||
db = fresh_db('s47')
|
||||
_log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
|
||||
assert cur.fetchone()[0] == 1
|
||||
cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
|
||||
msg, ver = cur.fetchone()
|
||||
assert msg == 'sample message' and ver == 'v2.6.0'
|
||||
conn.close()
|
||||
|
||||
|
||||
@case("S48. updater_client.is_newer: 정확한 semver 비교")
|
||||
def s48_updater_compare():
|
||||
from utils.updater_client import is_newer
|
||||
assert is_newer('v2.7.0', '2.6.0')
|
||||
assert is_newer('2.6.1', 'v2.6.0')
|
||||
assert not is_newer('v2.6.0', 'v2.6.0')
|
||||
assert not is_newer('v2.5.0', 'v2.6.0')
|
||||
assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교
|
||||
|
||||
|
||||
@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
|
||||
def s49_discord_empty():
|
||||
from utils.discord_webhook import send, send_test
|
||||
assert send('', 't', 'd') is False
|
||||
assert send('http://invalid', 't', 'd') is False # https:// 아님
|
||||
assert send_test('') is False
|
||||
|
||||
|
||||
@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
|
||||
def s50_goals():
|
||||
db = fresh_db('s50')
|
||||
# 기본값 확인 (0)
|
||||
assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
|
||||
assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
|
||||
# 활성화
|
||||
db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
|
||||
assert db.get_setting_int('goal_overtime_max_monthly') == 1200
|
||||
assert db.get_setting_int('goal_avg_hours_daily') == 8
|
||||
|
||||
|
||||
@case("S51. 글꼴 크기 / 고대비 설정 키")
|
||||
def s51_accessibility_keys():
|
||||
db = fresh_db('s51')
|
||||
# 기본값
|
||||
assert db.get_setting_float('font_scale', 1.0) == 1.0
|
||||
assert db.get_setting_bool('high_contrast', False) is False
|
||||
# 변경
|
||||
db.save_settings({'font_scale': 1.5, 'high_contrast': True})
|
||||
assert db.get_setting_float('font_scale') == 1.5
|
||||
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 적립까지 정상 동작")
|
||||
def s52_csv_overtime():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s52')
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
# 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
|
||||
f.write("2026-04-01,09:00,19:30,60,연장근무\n")
|
||||
rows = parse_csv(p)
|
||||
added, _, _ = import_records(db, rows, on_conflict='skip')
|
||||
os.remove(p)
|
||||
assert added == 1
|
||||
bal = db.get_total_overtime_balance()
|
||||
assert bal == 90, f"overtime balance: {bal}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Run all
|
||||
# ============================================================
|
||||
@ -520,6 +821,28 @@ def main():
|
||||
s33_short_overtime()
|
||||
s34_format()
|
||||
s35_lock()
|
||||
s36_onboarding_new()
|
||||
s37_onboarding_existing()
|
||||
s38_salary_zero_wage()
|
||||
s39_salary_basic()
|
||||
s40_format_won()
|
||||
s41_csv_parse()
|
||||
s42_csv_invalid()
|
||||
s43_csv_skip()
|
||||
s44_csv_overwrite()
|
||||
s45_notification_dedupe()
|
||||
s46_meal_record()
|
||||
s47_crash_log()
|
||||
s48_updater_compare()
|
||||
s49_discord_empty()
|
||||
s50_goals()
|
||||
s51_accessibility_keys()
|
||||
s52a_holiday_hotpath()
|
||||
s52b_planned_leave()
|
||||
s52c_recurring_leave()
|
||||
s52d_effective()
|
||||
s52e_full_day()
|
||||
s52_csv_overtime()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
|
||||
1216
core/achievements.py
Normal file
1216
core/achievements.py
Normal file
File diff suppressed because it is too large
Load Diff
1157
core/database.py
1157
core/database.py
File diff suppressed because it is too large
Load Diff
298
core/i18n.py
298
core/i18n.py
@ -21,14 +21,14 @@ _DICT = {
|
||||
'menu.help': '도움말',
|
||||
'menu.settings': '설정',
|
||||
'btn.clock_out': '퇴근하기',
|
||||
'btn.clock_out_cancel': '🔄 퇴근 취소',
|
||||
'btn.clock_out_cancel': '퇴근 취소',
|
||||
'btn.lunch_add': '점심시간 추가',
|
||||
'btn.lunch_applied': '점심시간 (적용됨)',
|
||||
'btn.dinner_add': '저녁시간 추가',
|
||||
'btn.dinner_applied': '저녁시간 (적용됨)',
|
||||
'btn.break_out': '🚪 외출 시작',
|
||||
'btn.break_in': '↩️ 복귀',
|
||||
'btn.save': '💾 저장',
|
||||
'btn.break_out': '외출 시작',
|
||||
'btn.break_in': '복귀',
|
||||
'btn.save': '저장',
|
||||
'btn.close': '닫기',
|
||||
'btn.apply': '적용',
|
||||
'btn.cancel': '취소',
|
||||
@ -40,10 +40,10 @@ _DICT = {
|
||||
|
||||
# === 윈도우/다이얼로그 제목 ===
|
||||
'window.main_title': '퇴근시간 계산기',
|
||||
'window.settings': '⚙️ 설정',
|
||||
'window.help': '📖 사용 설명서',
|
||||
'window.stats': '📊 근무 통계',
|
||||
'window.calendar': '📅 캘린더',
|
||||
'window.settings': '설정',
|
||||
'window.help': '사용 설명서',
|
||||
'window.stats': '근무 통계',
|
||||
'window.calendar': '캘린더',
|
||||
'window.mini_widget': '퇴근시간',
|
||||
'window.clock_in_dialog': '출근 시간',
|
||||
'window.break_view': '외출 관리',
|
||||
@ -82,6 +82,24 @@ _DICT = {
|
||||
'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!',
|
||||
'notif.lunch_reminder.title': '🍱 점심시간 등록',
|
||||
'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.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!',
|
||||
'notif.overtime_threshold.title': '💰 연장근무 적립 알림',
|
||||
@ -103,11 +121,12 @@ _DICT = {
|
||||
'msg.no_record.body': '오늘 출근 기록이 없습니다.',
|
||||
'msg.confirm_delete.title': '삭제 확인',
|
||||
'msg.no_data.title': '데이터 없음',
|
||||
'msg.manual_added': '수동 추가',
|
||||
|
||||
# === 트레이 ===
|
||||
'tray.open': '프로그램 열기',
|
||||
'tray.mini_widget': '📌 미니 위젯',
|
||||
'tray.toggle_lunch': '🍱 점심시간 토글',
|
||||
'tray.mini_widget': '미니 위젯',
|
||||
'tray.toggle_lunch': '점심시간 토글',
|
||||
'tray.quit': '종료',
|
||||
'tray.tooltip_remaining': '퇴근까지: {time}',
|
||||
'tray.tooltip_overtime': '추가 근무 중: {time}',
|
||||
@ -147,12 +166,110 @@ _DICT = {
|
||||
'cal.edit_record': '기록 편집',
|
||||
|
||||
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
|
||||
'help.tab_intro': '👋 시작하기',
|
||||
'help.tab_work_hours': '🕘 근무시간',
|
||||
'help.tab_overtime': '🏦 연장근무',
|
||||
'help.tab_leave': '🌴 연차/휴가',
|
||||
'help.tab_break': '🚪 외출/저녁',
|
||||
'help.tab_faq': '❓ 자주 묻는 질문',
|
||||
'help.tab_intro': '시작하기',
|
||||
'help.tab_work_hours': '근무시간',
|
||||
'help.tab_overtime': '연장근무',
|
||||
'help.tab_leave': '연차/휴가',
|
||||
'help.tab_break': '외출/저녁',
|
||||
'help.tab_faq': '자주 묻는 질문',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
|
||||
'dlg.clock_in.label': '출근시간:',
|
||||
'dlg.clock_in.quick': '빠른 선택:',
|
||||
'dlg.clock_in.btn_now': '현재',
|
||||
|
||||
# === break_view ===
|
||||
'dlg.break.edit_title': '외출 기록 수정',
|
||||
'dlg.break.out_label': '외출 시간:',
|
||||
'dlg.break.in_label': '복귀 시간:',
|
||||
'dlg.break.reason_label': '사유:',
|
||||
'view.break.today_title': '오늘의 외출 기록',
|
||||
'view.break.col_out': '외출 시간',
|
||||
'view.break.col_in': '복귀 시간',
|
||||
'view.break.col_duration': '소요 시간',
|
||||
'view.break.col_reason': '사유',
|
||||
'view.break.in_progress': '진행중',
|
||||
'view.break.total_zero': '총 외출 시간: 0분',
|
||||
'view.break.total_fmt': '총 외출 시간: {h}시간 {m}분',
|
||||
'view.break.total_min_only': '총 외출 시간: {m}분',
|
||||
'view.break.duration_fmt': '{h}시간 {m}분',
|
||||
'view.break.duration_min_only': '{m}분',
|
||||
'view.break.delete_confirm': '이 외출 기록을 삭제하시겠습니까?',
|
||||
'btn.refresh': '새로고침',
|
||||
'btn.edit_short': '수정',
|
||||
'btn.delete_short': '삭제',
|
||||
|
||||
# === overtime_view ===
|
||||
'view.overtime.title': '연장근무 내역',
|
||||
'view.overtime.balance_zero': '잔액: 0분',
|
||||
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
|
||||
'view.overtime.earned_group': '적립 내역',
|
||||
'view.overtime.used_group': '사용 내역',
|
||||
'view.overtime.col_date': '날짜',
|
||||
'view.overtime.col_earned': '적립',
|
||||
'view.overtime.col_used': '사용',
|
||||
'view.overtime.col_memo': '메모',
|
||||
'view.overtime.col_reason': '사유',
|
||||
'view.overtime.btn_add_earned': '수동 적립',
|
||||
'view.overtime.btn_add_used': '수동 사용',
|
||||
'view.overtime.menu_delete': '삭제',
|
||||
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
|
||||
'view.overtime.delete_earned_confirm_body': '다음 적립 기록을 삭제하시겠습니까?\n\n날짜: {date}\n적립: {time}\n\n삭제 시 잔액에서 차감됩니다.',
|
||||
'view.overtime.manual_earned_title': '추가근무 수동 적립',
|
||||
'view.overtime.manual_used_title': '추가근무 수동 사용',
|
||||
'view.overtime.field_date': '날짜:',
|
||||
'view.overtime.field_time': '시간:',
|
||||
'view.overtime.field_memo': '메모:',
|
||||
'view.overtime.field_reason': '사유:',
|
||||
'view.overtime.unit_hour_suffix': '시간',
|
||||
'view.overtime.minute_0': '0분',
|
||||
'view.overtime.minute_30': '30분',
|
||||
'view.overtime.placeholder_memo': '선택사항',
|
||||
'view.overtime.placeholder_reason': '예: 개인 사유',
|
||||
'view.overtime.zero_add_error': '0분은 추가할 수 없습니다.',
|
||||
'view.overtime.zero_use_error': '0분은 사용할 수 없습니다.',
|
||||
'view.overtime.balance_short_title': '잔액 부족',
|
||||
'view.overtime.balance_short_body': '사용 가능한 시간이 부족합니다.\n\n요청: {req_h}시간 {req_m}분\n잔액: {bal_h}시간 {bal_m}분',
|
||||
'view.overtime.saved_earned': '{h}시간 {m}분이 적립되었습니다.',
|
||||
'view.overtime.saved_used': '{h}시간 {m}분이 사용 처리되었습니다.',
|
||||
|
||||
# === leave_view ===
|
||||
'view.leave.title': '연차 관리',
|
||||
'view.leave.balance_zero': '잔여: 0일',
|
||||
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
|
||||
'view.leave.btn_set_balance': '잔여 설정',
|
||||
'view.leave.used_group': '사용 내역',
|
||||
'view.leave.col_date': '날짜',
|
||||
'view.leave.col_type': '구분',
|
||||
'view.leave.col_used': '사용',
|
||||
'view.leave.col_reason': '사유',
|
||||
'view.leave.btn_add': '연차 사용 추가',
|
||||
'view.leave.btn_calendar': '캘린더 보기',
|
||||
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
|
||||
'view.leave.set_title': '연차 시간 설정',
|
||||
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
|
||||
'view.leave.set_done_title': '설정 완료',
|
||||
'view.leave.set_done_body': '연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다.',
|
||||
'view.leave.add_title': '연차 사용 기록 추가',
|
||||
'view.leave.field_date': '날짜:',
|
||||
'view.leave.field_type': '구분:',
|
||||
'view.leave.field_hours': '시간:',
|
||||
'view.leave.field_reason': '사유:',
|
||||
'view.leave.type_annual': '연차',
|
||||
'view.leave.type_half': '반차',
|
||||
'view.leave.type_quarter': '반반차',
|
||||
'view.leave.type_hourly': '시간',
|
||||
'view.leave.placeholder_reason': '예) 개인 사유, 병원 방문 등',
|
||||
'view.leave.note_auto_deduct': '※ 잔여 연차가 자동 차감됩니다.',
|
||||
'view.leave.short_title': '잔여 연차 부족',
|
||||
'view.leave.short_body': '잔여 연차가 부족합니다.\n현재 잔여: {balance}일\n사용 요청: {req}일',
|
||||
'view.leave.confirm_title': '연차 사용 기록 추가',
|
||||
'view.leave.confirm_body': '날짜: {date}\n구분: {type}\n사용: {days}일 ({hours}시간)\n사유: {reason}\n\n이 기록을 추가하시겠습니까?',
|
||||
'view.leave.added_title': '추가 완료',
|
||||
'view.leave.added_body': '{days}일 ({hours}시간)의 연차 사용이 기록되었습니다.',
|
||||
'view.leave.error_title': '오류',
|
||||
'view.leave.error_body': '연차 기록 추가 중 오류가 발생했습니다:\n{err}',
|
||||
},
|
||||
'en': {
|
||||
# === Menu/Buttons ===
|
||||
@ -162,14 +279,14 @@ _DICT = {
|
||||
'menu.help': 'Help',
|
||||
'menu.settings': 'Settings',
|
||||
'btn.clock_out': 'Clock Out',
|
||||
'btn.clock_out_cancel': '🔄 Cancel Clock-out',
|
||||
'btn.clock_out_cancel': 'Cancel Clock-out',
|
||||
'btn.lunch_add': 'Add Lunch',
|
||||
'btn.lunch_applied': 'Lunch (Applied)',
|
||||
'btn.dinner_add': 'Add Dinner',
|
||||
'btn.dinner_applied': 'Dinner (Applied)',
|
||||
'btn.break_out': '🚪 Start Break',
|
||||
'btn.break_in': '↩️ Return',
|
||||
'btn.save': '💾 Save',
|
||||
'btn.break_out': 'Start Break',
|
||||
'btn.break_in': 'Return',
|
||||
'btn.save': 'Save',
|
||||
'btn.close': 'Close',
|
||||
'btn.apply': 'Apply',
|
||||
'btn.cancel': 'Cancel',
|
||||
@ -181,10 +298,10 @@ _DICT = {
|
||||
|
||||
# === Windows ===
|
||||
'window.main_title': 'Clock-out Time Calculator',
|
||||
'window.settings': '⚙️ Settings',
|
||||
'window.help': '📖 User Guide',
|
||||
'window.stats': '📊 Statistics',
|
||||
'window.calendar': '📅 Calendar',
|
||||
'window.settings': 'Settings',
|
||||
'window.help': 'User Guide',
|
||||
'window.stats': 'Statistics',
|
||||
'window.calendar': 'Calendar',
|
||||
'window.mini_widget': 'Clock-out',
|
||||
'window.clock_in_dialog': 'Clock-in Time',
|
||||
'window.break_view': 'Break Management',
|
||||
@ -223,6 +340,24 @@ _DICT = {
|
||||
'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!",
|
||||
'notif.lunch_reminder.title': '🍱 Lunch Reminder',
|
||||
'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.body': "{time_str} of overtime will be banked today!",
|
||||
'notif.overtime_threshold.title': '💰 Overtime Balance High',
|
||||
@ -244,11 +379,12 @@ _DICT = {
|
||||
'msg.no_record.body': 'No clock-in record for today.',
|
||||
'msg.confirm_delete.title': 'Confirm Delete',
|
||||
'msg.no_data.title': 'No Data',
|
||||
'msg.manual_added': 'Manual',
|
||||
|
||||
# === Tray ===
|
||||
'tray.open': 'Open Program',
|
||||
'tray.mini_widget': '📌 Mini Widget',
|
||||
'tray.toggle_lunch': '🍱 Toggle Lunch',
|
||||
'tray.mini_widget': 'Mini Widget',
|
||||
'tray.toggle_lunch': 'Toggle Lunch',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.tooltip_remaining': 'Until clock-out: {time}',
|
||||
'tray.tooltip_overtime': 'Overtime: {time}',
|
||||
@ -288,12 +424,110 @@ _DICT = {
|
||||
'cal.edit_record': 'Edit record',
|
||||
|
||||
# === HelpView ===
|
||||
'help.tab_intro': '👋 Getting Started',
|
||||
'help.tab_work_hours': '🕘 Work Hours',
|
||||
'help.tab_overtime': '🏦 Overtime',
|
||||
'help.tab_leave': '🌴 Leave',
|
||||
'help.tab_break': '🚪 Break/Dinner',
|
||||
'help.tab_faq': '❓ FAQ',
|
||||
'help.tab_intro': 'Getting Started',
|
||||
'help.tab_work_hours': 'Work Hours',
|
||||
'help.tab_overtime': 'Overtime',
|
||||
'help.tab_leave': 'Leave',
|
||||
'help.tab_break': 'Break/Dinner',
|
||||
'help.tab_faq': 'FAQ',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': "Enter today's clock-in time",
|
||||
'dlg.clock_in.label': 'Clock-in time:',
|
||||
'dlg.clock_in.quick': 'Quick pick:',
|
||||
'dlg.clock_in.btn_now': 'Now',
|
||||
|
||||
# === break_view ===
|
||||
'dlg.break.edit_title': 'Edit Break Record',
|
||||
'dlg.break.out_label': 'Break out:',
|
||||
'dlg.break.in_label': 'Return:',
|
||||
'dlg.break.reason_label': 'Reason:',
|
||||
'view.break.today_title': "Today's Break Records",
|
||||
'view.break.col_out': 'Break out',
|
||||
'view.break.col_in': 'Return',
|
||||
'view.break.col_duration': 'Duration',
|
||||
'view.break.col_reason': 'Reason',
|
||||
'view.break.in_progress': 'In progress',
|
||||
'view.break.total_zero': 'Total break: 0 min',
|
||||
'view.break.total_fmt': 'Total break: {h}h {m}m',
|
||||
'view.break.total_min_only': 'Total break: {m} min',
|
||||
'view.break.duration_fmt': '{h}h {m}m',
|
||||
'view.break.duration_min_only': '{m} min',
|
||||
'view.break.delete_confirm': 'Delete this break record?',
|
||||
'btn.refresh': 'Refresh',
|
||||
'btn.edit_short': 'Edit',
|
||||
'btn.delete_short': 'Delete',
|
||||
|
||||
# === overtime_view ===
|
||||
'view.overtime.title': 'Overtime History',
|
||||
'view.overtime.balance_zero': 'Balance: 0 min',
|
||||
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
|
||||
'view.overtime.earned_group': 'Earned',
|
||||
'view.overtime.used_group': 'Used',
|
||||
'view.overtime.col_date': 'Date',
|
||||
'view.overtime.col_earned': 'Earned',
|
||||
'view.overtime.col_used': 'Used',
|
||||
'view.overtime.col_memo': 'Memo',
|
||||
'view.overtime.col_reason': 'Reason',
|
||||
'view.overtime.btn_add_earned': 'Manual Earn',
|
||||
'view.overtime.btn_add_used': 'Manual Use',
|
||||
'view.overtime.menu_delete': 'Delete',
|
||||
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
|
||||
'view.overtime.delete_earned_confirm_body': 'Delete this accrual record?\n\nDate: {date}\nEarned: {time}\n\nThe balance will be reduced accordingly.',
|
||||
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
|
||||
'view.overtime.manual_used_title': 'Manual Overtime Use',
|
||||
'view.overtime.field_date': 'Date:',
|
||||
'view.overtime.field_time': 'Time:',
|
||||
'view.overtime.field_memo': 'Memo:',
|
||||
'view.overtime.field_reason': 'Reason:',
|
||||
'view.overtime.unit_hour_suffix': 'h',
|
||||
'view.overtime.minute_0': '0 min',
|
||||
'view.overtime.minute_30': '30 min',
|
||||
'view.overtime.placeholder_memo': 'Optional',
|
||||
'view.overtime.placeholder_reason': 'e.g., personal',
|
||||
'view.overtime.zero_add_error': 'Cannot add 0 minutes.',
|
||||
'view.overtime.zero_use_error': 'Cannot use 0 minutes.',
|
||||
'view.overtime.balance_short_title': 'Insufficient Balance',
|
||||
'view.overtime.balance_short_body': 'Not enough balance.\n\nRequested: {req_h}h {req_m}m\nBalance: {bal_h}h {bal_m}m',
|
||||
'view.overtime.saved_earned': '{h}h {m}m banked.',
|
||||
'view.overtime.saved_used': '{h}h {m}m used.',
|
||||
|
||||
# === leave_view ===
|
||||
'view.leave.title': 'Leave Management',
|
||||
'view.leave.balance_zero': 'Balance: 0 days',
|
||||
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
|
||||
'view.leave.btn_set_balance': 'Set Balance',
|
||||
'view.leave.used_group': 'Used',
|
||||
'view.leave.col_date': 'Date',
|
||||
'view.leave.col_type': 'Type',
|
||||
'view.leave.col_used': 'Used',
|
||||
'view.leave.col_reason': 'Reason',
|
||||
'view.leave.btn_add': 'Add Leave Usage',
|
||||
'view.leave.btn_calendar': 'Calendar',
|
||||
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
|
||||
'view.leave.set_title': 'Set Leave Hours',
|
||||
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',
|
||||
'view.leave.set_done_title': 'Saved',
|
||||
'view.leave.set_done_body': 'Leave balance set to {days} days ({hours}h).',
|
||||
'view.leave.add_title': 'Add Leave Usage',
|
||||
'view.leave.field_date': 'Date:',
|
||||
'view.leave.field_type': 'Type:',
|
||||
'view.leave.field_hours': 'Hours:',
|
||||
'view.leave.field_reason': 'Reason:',
|
||||
'view.leave.type_annual': 'Annual',
|
||||
'view.leave.type_half': 'Half',
|
||||
'view.leave.type_quarter': 'Quarter',
|
||||
'view.leave.type_hourly': 'Hourly',
|
||||
'view.leave.placeholder_reason': 'e.g., personal, medical',
|
||||
'view.leave.note_auto_deduct': '※ Leave balance is auto-deducted.',
|
||||
'view.leave.short_title': 'Insufficient Leave',
|
||||
'view.leave.short_body': 'Not enough leave.\nCurrent: {balance} days\nRequested: {req} days',
|
||||
'view.leave.confirm_title': 'Add Leave Usage',
|
||||
'view.leave.confirm_body': 'Date: {date}\nType: {type}\nUsed: {days} days ({hours}h)\nReason: {reason}\n\nAdd this record?',
|
||||
'view.leave.added_title': 'Added',
|
||||
'view.leave.added_body': '{days} days ({hours}h) of leave usage recorded.',
|
||||
'view.leave.error_title': 'Error',
|
||||
'view.leave.error_body': 'Failed to add leave record:\n{err}',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -7,11 +7,25 @@ from typing import Optional
|
||||
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""알림 시스템 클래스"""
|
||||
|
||||
@ -27,6 +41,7 @@ class Notifier(QObject):
|
||||
# 알림 상태 추적
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_dinner = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
@ -85,46 +100,52 @@ class Notifier(QObject):
|
||||
|
||||
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
|
||||
current_time: Optional[datetime] = None):
|
||||
"""
|
||||
점심시간 등록 알림
|
||||
Args:
|
||||
clock_in_time: 출근 시간
|
||||
lunch_enabled: 점심시간 등록 여부
|
||||
current_time: 현재 시간
|
||||
"""
|
||||
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
if not self._enabled(NOTIF_LUNCH):
|
||||
return
|
||||
|
||||
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
|
||||
if lunch_enabled or self.notified_lunch:
|
||||
return
|
||||
|
||||
# 출근 후 4시간 경과 (점심시간으로 추정)
|
||||
time_since_clock_in = current_time - clock_in_time
|
||||
if time_since_clock_in.total_seconds() >= 4 * 3600:
|
||||
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
|
||||
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.lunch_reminder.title'),
|
||||
tr('notif.lunch_reminder.body'),
|
||||
)
|
||||
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):
|
||||
"""
|
||||
연장근무 적립 알림
|
||||
Args:
|
||||
overtime_minutes: 예상 연장근무 시간 (분)
|
||||
"""
|
||||
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
if overtime_minutes >= 30 and not self.notified_overtime:
|
||||
hours = overtime_minutes // 60
|
||||
mins = overtime_minutes % 60
|
||||
|
||||
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
|
||||
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
|
||||
if overtime_minutes >= unit and not self.notified_overtime:
|
||||
from utils.time_format import format_hours_minutes
|
||||
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
|
||||
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_earning.title'),
|
||||
tr('notif.overtime_earning.body', time_str=time_str),
|
||||
@ -132,10 +153,11 @@ class Notifier(QObject):
|
||||
self.notified_overtime = True
|
||||
|
||||
def notify_overtime_threshold(self, total_overtime_hours: float):
|
||||
"""연장근무 누적 알림 (20시간 이상)"""
|
||||
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
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(
|
||||
tr('notif.overtime_threshold.title'),
|
||||
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
|
||||
@ -143,10 +165,11 @@ class Notifier(QObject):
|
||||
self.notified_threshold = True
|
||||
|
||||
def notify_health_warning(self, consecutive_overtime_days: int):
|
||||
"""건강 경고 (연속 연장근무 일수)"""
|
||||
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
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(
|
||||
tr('notif.health.title'),
|
||||
tr('notif.health.body', days=consecutive_overtime_days),
|
||||
@ -154,10 +177,11 @@ class Notifier(QObject):
|
||||
self.notified_health = True
|
||||
|
||||
def notify_weekly_hours(self, total_hours: float):
|
||||
"""주 52시간 경고"""
|
||||
"""주 X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
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(
|
||||
tr('notif.weekly_52.title'),
|
||||
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):
|
||||
"""장시간 연속 근무 휴식 알림.
|
||||
|
||||
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
|
||||
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
|
||||
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
|
||||
"""
|
||||
if current_time is None:
|
||||
@ -178,16 +202,12 @@ class Notifier(QObject):
|
||||
return
|
||||
if self.db.has_notification_today('system', 'health_break'):
|
||||
return
|
||||
try:
|
||||
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
|
||||
except (ValueError, TypeError):
|
||||
threshold_hours = 4
|
||||
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
|
||||
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
|
||||
if elapsed_sec >= threshold_hours * 3600:
|
||||
from core.i18n import tr
|
||||
self.notification_signal.emit(
|
||||
tr('notif.health_break.title') if False else "🌿 휴식 권고",
|
||||
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
|
||||
tr('notif.health_break.title'),
|
||||
tr('notif.health_break.body', hours=threshold_hours),
|
||||
)
|
||||
self.db.log_notification('system', 'health_break')
|
||||
|
||||
@ -195,6 +215,7 @@ class Notifier(QObject):
|
||||
"""알림 상태 리셋 (날짜 변경 시)"""
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_dinner = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
|
||||
153
core/recurring_leaves.py
Normal file
153
core/recurring_leaves.py
Normal file
@ -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'
|
||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||
NOTIF_LUNCH = 'notification_lunch'
|
||||
NOTIF_DINNER = 'notification_dinner'
|
||||
NOTIF_OVERTIME = 'notification_overtime'
|
||||
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_DAYS = 'annual_leave_days'
|
||||
@ -37,6 +45,8 @@ THEME = 'theme'
|
||||
TIME_FORMAT = 'time_format'
|
||||
LANGUAGE = 'language'
|
||||
OVERTIME_UNIT = 'overtime_unit'
|
||||
FONT_SCALE = 'font_scale' # '1.0' / '1.25' / '1.5'
|
||||
HIGH_CONTRAST = 'high_contrast'
|
||||
|
||||
# 통합/외부
|
||||
DB_PATH_OVERRIDE = 'db_path_override'
|
||||
@ -44,6 +54,10 @@ DB_PATH_OVERRIDE = 'db_path_override'
|
||||
# 백업
|
||||
LAST_BACKUP_DATE = 'last_backup_date'
|
||||
|
||||
# Crash Report (Gitea Issues 통합 — 옵션)
|
||||
GITEA_FEEDBACK_TOKEN = 'gitea_feedback_token' # PAT (저장소 issue 쓰기 권한)
|
||||
GITEA_FEEDBACK_ENABLED = 'gitea_feedback_enabled'
|
||||
|
||||
# === v2.3.0 신규 ===
|
||||
# 온보딩
|
||||
ONBOARDING_COMPLETED = 'onboarding_completed'
|
||||
@ -57,6 +71,10 @@ OVERTIME_RATE = 'overtime_rate' # 1.5
|
||||
HEALTH_BREAK_ENABLED = 'health_break_enabled'
|
||||
HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4
|
||||
|
||||
# 목표
|
||||
GOAL_OVERTIME_MAX_MONTHLY = 'goal_overtime_max_monthly' # 월 연장근무 상한 (분)
|
||||
GOAL_AVG_HOURS_DAILY = 'goal_avg_hours_daily' # 일평균 목표 (시간, float)
|
||||
|
||||
# Discord 웹훅
|
||||
DISCORD_WEBHOOK_URL = 'discord_webhook_url'
|
||||
DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in'
|
||||
@ -66,3 +84,22 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
|
||||
# 마이그레이션 sentinel
|
||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||
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)
|
||||
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:
|
||||
"""
|
||||
주말 여부 확인
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.3.3'
|
||||
__version__ = '2.11.2'
|
||||
|
||||
BIN
font/NanumSquareB.otf
Normal file
BIN
font/NanumSquareB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareB.ttf
Normal file
BIN
font/NanumSquareB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.otf
Normal file
BIN
font/NanumSquareEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.ttf
Normal file
BIN
font/NanumSquareEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.otf
Normal file
BIN
font/NanumSquareL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.ttf
Normal file
BIN
font/NanumSquareL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acB.otf
Normal file
BIN
font/NanumSquareOTF_acB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acEB.otf
Normal file
BIN
font/NanumSquareOTF_acEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acL.otf
Normal file
BIN
font/NanumSquareOTF_acL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acR.otf
Normal file
BIN
font/NanumSquareOTF_acR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.otf
Normal file
BIN
font/NanumSquareR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.ttf
Normal file
BIN
font/NanumSquareR.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acB.ttf
Normal file
BIN
font/NanumSquare_acB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acEB.ttf
Normal file
BIN
font/NanumSquare_acEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acL.ttf
Normal file
BIN
font/NanumSquare_acL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acR.ttf
Normal file
BIN
font/NanumSquare_acR.ttf
Normal file
Binary file not shown.
18
main.py
18
main.py
@ -96,8 +96,9 @@ def main():
|
||||
)
|
||||
return 1
|
||||
|
||||
# 폰트 설정
|
||||
app.setFont(QFont("Segoe UI", 9))
|
||||
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
|
||||
from utils.font_loader import apply_app_font
|
||||
apply_app_font(app, 9)
|
||||
|
||||
# 필수 패키지 확인
|
||||
if not check_requirements():
|
||||
@ -128,6 +129,15 @@ def main():
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"backup failed: {e}")
|
||||
|
||||
# 전역 예외 후킹 (crash report)
|
||||
try:
|
||||
from utils.crash_handler import install_global_handler
|
||||
from core.version import __version__
|
||||
install_global_handler(db, app_version=__version__)
|
||||
except Exception as e:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"crash handler install failed: {e}")
|
||||
|
||||
# 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시
|
||||
try:
|
||||
from ui.onboarding_view import maybe_show_onboarding
|
||||
@ -136,9 +146,9 @@ def main():
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"onboarding skipped: {e}")
|
||||
|
||||
# 메인 윈도우 생성 및 표시
|
||||
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
|
||||
try:
|
||||
window = MainWindow()
|
||||
window = MainWindow(db=db)
|
||||
|
||||
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
|
||||
def on_new_connection():
|
||||
|
||||
19
main.spec
19
main.spec
@ -14,20 +14,35 @@ if os.path.exists(_staged):
|
||||
elif os.path.exists(_fallback):
|
||||
_extra_datas.append((_fallback, '.'))
|
||||
|
||||
# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
|
||||
_font_files = [
|
||||
'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
|
||||
'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
|
||||
]
|
||||
_font_datas = [
|
||||
(os.path.join('font', f), 'font')
|
||||
for f in _font_files if os.path.exists(os.path.join('font', f))
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas,
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
|
||||
hiddenimports=[
|
||||
'holidays', 'holidays.countries.south_korea',
|
||||
'win32evtlog', 'win32evtlogutil',
|
||||
'matplotlib.backends.backend_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
|
||||
'matplotlib.backends.backend_qt5agg',
|
||||
'PyQt5.QtSvg',
|
||||
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
|
||||
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
|
||||
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
|
||||
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
21
release.ps1
21
release.ps1
@ -141,6 +141,27 @@ $mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB)
|
||||
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
|
||||
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
|
||||
|
||||
# Optional: Authenticode signing if cert available (env vars)
|
||||
# Set CODE_SIGN_CERT (path to .pfx) and CODE_SIGN_PASS to enable
|
||||
if ($env:CODE_SIGN_CERT -and (Test-Path $env:CODE_SIGN_CERT)) {
|
||||
Info "Code signing exes (Authenticode)..."
|
||||
$signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if (-not $signtool) {
|
||||
Info " signtool.exe not found in PATH — skipping signature"
|
||||
} else {
|
||||
$tsUrl = if ($env:CODE_SIGN_TIMESTAMP) { $env:CODE_SIGN_TIMESTAMP } else { 'http://timestamp.digicert.com' }
|
||||
foreach ($exe in 'dist/main.exe', 'dist/updater.exe') {
|
||||
$args = @('sign', '/f', $env:CODE_SIGN_CERT)
|
||||
if ($env:CODE_SIGN_PASS) { $args += '/p'; $args += $env:CODE_SIGN_PASS }
|
||||
$args += '/tr'; $args += $tsUrl; $args += '/td'; $args += 'sha256'
|
||||
$args += '/fd'; $args += 'sha256'; $args += $exe
|
||||
$rc = Invoke-Native signtool $args
|
||||
if ($rc -ne 0) { Info " WARN: sign failed for $exe (exit $rc)" }
|
||||
else { OkMsg " signed $exe" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ====== 4. ZIP ======
|
||||
Step "4/7 ZIP packaging"
|
||||
$zipPath = "dist/ClockOutCalculator-$Version.zip"
|
||||
|
||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""
|
||||
pytest 공통 설정.
|
||||
|
||||
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
110
tests/test_crash_handler.py
Normal file
110
tests/test_crash_handler.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
utils.crash_handler 단위 테스트.
|
||||
|
||||
GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from utils.crash_handler import _log_crash, _send_to_gitea
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db')
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
d = Database(p)
|
||||
yield d
|
||||
try:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestLogCrash:
|
||||
def test_creates_table_and_inserts(self, db):
|
||||
_log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT exception_type, message, app_version FROM crash_log")
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 'TestExc'
|
||||
assert row[1] == 'msg'
|
||||
assert row[2] == 'v2.6.0'
|
||||
|
||||
def test_table_idempotent_creation(self, db):
|
||||
# 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS)
|
||||
_log_crash(db, 'A', 'a', 't', 'v1')
|
||||
_log_crash(db, 'B', 'b', 't', 'v1')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM crash_log")
|
||||
count = cur.fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 2
|
||||
|
||||
def test_silent_on_db_error(self, db):
|
||||
# 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음)
|
||||
broken = MagicMock()
|
||||
broken.get_connection.side_effect = RuntimeError('boom')
|
||||
# raise되면 안 됨
|
||||
_log_crash(broken, 'X', 'x', 'tb', 'v')
|
||||
|
||||
|
||||
class TestSendToGitea:
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 201
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = _send_to_gitea('fake_token', 'title', 'body')
|
||||
assert ok is True
|
||||
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
# PAT 헤더 확인
|
||||
assert req.headers.get('Authorization') == 'token fake_token'
|
||||
# User-Agent 위장
|
||||
assert 'Mozilla' in req.headers.get('User-agent', '')
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_4xx_returns_false(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 401
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
assert _send_to_gitea('bad', 't', 'b') is False
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_network_error(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert _send_to_gitea('t', 't', 'b') is False
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_payload_json(self, mock_urlopen):
|
||||
import json as _json
|
||||
resp = MagicMock()
|
||||
resp.status = 201
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
_send_to_gitea('tok', 'TITLE', 'BODY')
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = _json.loads(req.data.decode('utf-8'))
|
||||
assert body['title'] == 'TITLE'
|
||||
assert body['body'] == 'BODY'
|
||||
127
tests/test_csv_importer.py
Normal file
127
tests/test_csv_importer.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""
|
||||
utils.csv_importer 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
||||
|
||||
|
||||
class TestNormalizeTime:
|
||||
def test_hh_mm_to_hh_mm_ss(self):
|
||||
assert _normalize_time('09:00', 'clock_in') == '09:00:00'
|
||||
|
||||
def test_hh_mm_ss_unchanged(self):
|
||||
assert _normalize_time('09:00:00', 'clock_in') == '09:00:00'
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('', 'clock_in')
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('foo', 'clock_in')
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('25:00', 'clock_in')
|
||||
|
||||
|
||||
class TestNormalizeRow:
|
||||
def test_basic_row(self):
|
||||
row = {
|
||||
'date': '2026-04-01',
|
||||
'clock_in': '09:00',
|
||||
'clock_out': '18:00',
|
||||
'lunch_minutes': '60',
|
||||
'memo': '메모',
|
||||
}
|
||||
out = _normalize_row(row)
|
||||
assert out['date'] == '2026-04-01'
|
||||
assert out['clock_in'] == '09:00:00'
|
||||
assert out['clock_out'] == '18:00:00'
|
||||
assert out['lunch_minutes'] == 60
|
||||
assert out['memo'] == '메모'
|
||||
|
||||
def test_optional_clock_out(self):
|
||||
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '0', 'memo': ''}
|
||||
out = _normalize_row(row)
|
||||
assert out['clock_out'] is None
|
||||
|
||||
def test_invalid_date(self):
|
||||
row = {'date': 'not-a-date', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '0', 'memo': ''}
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_row(row)
|
||||
|
||||
def test_negative_lunch_minutes(self):
|
||||
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '-30', 'memo': ''}
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_row(row)
|
||||
|
||||
|
||||
class TestParseCsv:
|
||||
def _write(self, content: str) -> str:
|
||||
f = tempfile.NamedTemporaryFile('w', encoding='utf-8',
|
||||
delete=False, suffix='.csv', newline='')
|
||||
f.write(content)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
def test_valid_csv(self):
|
||||
path = self._write(
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,첫째날\n"
|
||||
"2026-04-02,09:30:00,17:30:00,30,단축\n"
|
||||
)
|
||||
try:
|
||||
rows = parse_csv(path)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['lunch_minutes'] == 60
|
||||
assert rows[1]['memo'] == '단축'
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_utf8_bom(self):
|
||||
# 엑셀 저장본 호환
|
||||
path = self._write('\ufeff' +
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,첫째날\n"
|
||||
)
|
||||
try:
|
||||
rows = parse_csv(path)
|
||||
assert len(rows) == 1
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_missing_required_header(self):
|
||||
path = self._write("date,memo\n2026-04-01,foo\n")
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_csv(path)
|
||||
assert 'clock_in' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_file_not_found(self):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_csv('/nonexistent/file.csv')
|
||||
|
||||
def test_line_number_in_error(self):
|
||||
path = self._write(
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,ok\n"
|
||||
"bad-date,09:00,18:00,60,broken\n"
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_csv(path)
|
||||
assert '줄 3' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
|
||||
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
||||
overtime_minutes=120, overtime_earned=120)
|
||||
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')
|
||||
|
||||
129
tests/test_discord_webhook.py
Normal file
129
tests/test_discord_webhook.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""
|
||||
utils.discord_webhook 단위 테스트.
|
||||
|
||||
네트워크 호출은 mock — 실제 Discord API는 절대 건드리지 않음.
|
||||
"""
|
||||
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.discord_webhook import (
|
||||
send, send_test, send_clock_in, send_clock_out, send_health_warning,
|
||||
USER_AGENT, COLOR_GREEN, COLOR_BLUE,
|
||||
)
|
||||
|
||||
|
||||
class TestSendInputValidation:
|
||||
def test_empty_url_returns_false(self):
|
||||
assert send('', 'title', 'desc') is False
|
||||
|
||||
def test_non_https_url_returns_false(self):
|
||||
# 보안상 http:// 거부
|
||||
assert send('http://example.com/webhook', 'title', 'desc') is False
|
||||
assert send('ftp://example.com', 'title', 'desc') is False
|
||||
|
||||
def test_none_url_returns_false(self):
|
||||
assert send(None, 'title', 'desc') is False
|
||||
|
||||
|
||||
class TestSendNetwork:
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 204
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc')
|
||||
assert ok is True
|
||||
|
||||
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.headers.get('User-agent') == USER_AGENT
|
||||
assert 'Mozilla' in USER_AGENT # 봇으로 인식 안 되도록
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_4xx_returns_false(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 403
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_network_error_returns_false(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_timeout_returns_false(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = TimeoutError('slow')
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
|
||||
class TestPayloadShape:
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_payload_contains_embed(self, mock_urlopen):
|
||||
import json as _json
|
||||
resp = MagicMock()
|
||||
resp.status = 204
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 'TITLE', 'DESC',
|
||||
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = _json.loads(req.data.decode('utf-8'))
|
||||
assert 'embeds' in body
|
||||
assert body['embeds'][0]['title'] == 'TITLE'
|
||||
assert body['embeds'][0]['description'] == 'DESC'
|
||||
assert body['embeds'][0]['color'] == COLOR_GREEN
|
||||
assert body['embeds'][0]['fields'] == [{"name": "f", "value": "v"}]
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_test(self, mock_send):
|
||||
mock_send.return_value = True
|
||||
send_test('https://discord.com/x')
|
||||
assert mock_send.called
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert kwargs.get('color') == COLOR_GREEN
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_clock_in(self, mock_send):
|
||||
send_clock_in('https://discord.com/x', '09:00')
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert '09:00' in kwargs.get('description', '')
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_clock_out_no_overtime(self, mock_send):
|
||||
send_clock_out('https://discord.com/x', '18:00', 8.0, 0, 0)
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
# 연장 없음 → 파란색
|
||||
assert kwargs.get('color') == COLOR_BLUE
|
||||
# 필드: 총 근무시간만
|
||||
assert len(kwargs.get('fields', [])) == 1
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_clock_out_with_overtime(self, mock_send):
|
||||
send_clock_out('https://discord.com/x', '19:30', 9.5, 90, 90)
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
# 연장 있음 → 노란색
|
||||
assert kwargs.get('color') != COLOR_BLUE
|
||||
assert len(kwargs.get('fields', [])) == 2
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_health_warning(self, mock_send):
|
||||
send_health_warning('https://discord.com/x', 4.5)
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert '4.5' in kwargs.get('description', '')
|
||||
166
tests/test_holiday_api.py
Normal file
166
tests/test_holiday_api.py
Normal file
@ -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
|
||||
106
tests/test_i18n_runtime.py
Normal file
106
tests/test_i18n_runtime.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""
|
||||
ui.i18n_runtime 단위 테스트.
|
||||
|
||||
QApplication이 필요해서 offscreen으로.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def qapp():
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def i18n():
|
||||
from core import i18n
|
||||
from ui import i18n_runtime
|
||||
saved_lang = i18n.get_language()
|
||||
yield i18n_runtime
|
||||
i18n_runtime.clear()
|
||||
i18n.set_language(saved_lang)
|
||||
|
||||
|
||||
def test_register_applies_initial_text(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save')
|
||||
assert label.text() == '저장'
|
||||
|
||||
|
||||
def test_retranslate_after_language_change(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.close')
|
||||
assert label.text() == '닫기'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == 'Close'
|
||||
|
||||
i18n.set_language_and_retranslate('ko')
|
||||
assert label.text() == '닫기'
|
||||
|
||||
|
||||
def test_setter_kwarg_for_window_title(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QDialog
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
dlg = QDialog()
|
||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||
assert dlg.windowTitle() == '설정'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert dlg.windowTitle() == 'Settings'
|
||||
|
||||
|
||||
def test_post_callback_applied(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||
assert label.text() == '[저장]'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == '[Save]'
|
||||
|
||||
|
||||
def test_dead_widget_pruned(qapp, i18n):
|
||||
"""삭제된 위젯은 retranslate에서 자동 제외 (RuntimeError 안 남)."""
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.cancel')
|
||||
label.deleteLater()
|
||||
label = None # weakref 끊기
|
||||
|
||||
# Qt 이벤트 처리 한 번 강제로 (deleteLater 처리)
|
||||
qapp.processEvents()
|
||||
|
||||
# 죽은 위젯이 있어도 예외 없이 실행돼야 함
|
||||
i18n.set_language_and_retranslate('en')
|
||||
i18n.set_language_and_retranslate('ko')
|
||||
|
||||
|
||||
def test_kwargs_format(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
# 'tray.tooltip_remaining': '퇴근까지: {time}'
|
||||
i18n.register(label, 'tray.tooltip_remaining', kwargs={'time': '01:23'})
|
||||
assert '01:23' in label.text()
|
||||
72
tests/test_overtime_accrual_guard.py
Normal file
72
tests/test_overtime_accrual_guard.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""연장근무 자동 적립 가드 테스트.
|
||||
|
||||
auto_overtime(자동 적립)가 OFF면, 자동 퇴근 경로(근무일 경계 롤오버 등)에서도
|
||||
은행 적립을 하지 않아야 한다 — clock_out() 대화상자에서 '아니오'를 고른 것과 동일한 의미.
|
||||
|
||||
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
|
||||
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, time as dtime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.time_calculator import TimeCalculator
|
||||
from ui.main_window import MainWindow
|
||||
|
||||
|
||||
def _rollover_balance(db, monkeypatch):
|
||||
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
monkeypatch.setattr(QMessageBox, 'information',
|
||||
staticmethod(lambda *a, **k: QMessageBox.Ok))
|
||||
|
||||
today = datetime.now().date()
|
||||
y = today - timedelta(days=1)
|
||||
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
|
||||
|
||||
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
|
||||
w.db = db
|
||||
w.time_calc = TimeCalculator(work_minutes=480)
|
||||
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
|
||||
w.is_clocked_in = True
|
||||
w.midnight_rollover_handled = False
|
||||
w.is_on_break = False
|
||||
w.lunch_break_enabled = False
|
||||
w.dinner_break_enabled = False
|
||||
w.load_today_data = lambda: None # tail UI refresh stub
|
||||
w.update_overtime_balance = lambda: None # tail UI refresh stub
|
||||
|
||||
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
|
||||
return db.get_total_overtime_balance()
|
||||
|
||||
|
||||
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
|
||||
db = Database(str(tmp_path / 'off.db'))
|
||||
db.set_setting('auto_overtime', 'false')
|
||||
assert _rollover_balance(db, monkeypatch) == 0
|
||||
|
||||
|
||||
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
|
||||
db = Database(str(tmp_path / 'on.db'))
|
||||
db.set_setting('auto_overtime', 'true')
|
||||
assert _rollover_balance(db, monkeypatch) > 0
|
||||
|
||||
|
||||
def test_delete_overtime_earned_reduces_balance(tmp_path):
|
||||
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
|
||||
from datetime import date
|
||||
db = Database(str(tmp_path / 'del.db'))
|
||||
today = date.today().isoformat()
|
||||
db.add_overtime_earned(None, 90, today)
|
||||
assert db.get_total_overtime_balance() == 90
|
||||
|
||||
bank_id = db.get_connection().execute(
|
||||
'SELECT id FROM overtime_bank').fetchone()[0]
|
||||
assert db.delete_overtime_earned(bank_id) is True
|
||||
assert db.get_total_overtime_balance() == 0
|
||||
|
||||
# 없는 id 삭제는 False
|
||||
assert db.delete_overtime_earned(999999) is False
|
||||
153
tests/test_recurring_leaves.py
Normal file
153
tests/test_recurring_leaves.py
Normal file
@ -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
tests/test_salary.py
Normal file
98
tests/test_salary.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""
|
||||
core.salary 단위 테스트 — 포괄임금제 외 시급 추정.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.salary import estimate_pay, format_won
|
||||
|
||||
|
||||
class TestEstimatePay:
|
||||
def test_zero_wage_returns_zero(self):
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_negative_wage_returns_zero(self):
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 0}], -1000)
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_empty_records(self):
|
||||
out = estimate_pay([], 10000)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_basic_8h_no_overtime(self):
|
||||
# 8h 정규 × 10000 = 80000
|
||||
out = estimate_pay([{'total_hours': 8.0, 'overtime_minutes': 0}], 10000)
|
||||
assert out['base'] == 80000
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 80000
|
||||
|
||||
def test_8h_with_30min_overtime(self):
|
||||
# 정규 = 7.5h × 10000 = 75000
|
||||
# 연장 = 0.5h × 10000 × 1.5 = 7500
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 8.0, 'overtime_minutes': 30}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=1.5,
|
||||
)
|
||||
assert out['base'] == pytest.approx(75000)
|
||||
assert out['overtime'] == pytest.approx(7500)
|
||||
assert out['total'] == pytest.approx(82500)
|
||||
|
||||
def test_custom_overtime_rate(self):
|
||||
# 연장 = 1h × 10000 × 2.0 = 20000
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 9.0, 'overtime_minutes': 60}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=2.0,
|
||||
)
|
||||
assert out['overtime'] == pytest.approx(20000)
|
||||
assert out['base'] == pytest.approx(80000)
|
||||
|
||||
def test_aggregated_multiple_records(self):
|
||||
records = [
|
||||
{'total_hours': 8.0, 'overtime_minutes': 0},
|
||||
{'total_hours': 9.0, 'overtime_minutes': 60},
|
||||
{'total_hours': 8.5, 'overtime_minutes': 30},
|
||||
]
|
||||
out = estimate_pay(records, hourly_wage=10000)
|
||||
# base_hours = 8 + 8 + 8 = 24h
|
||||
# overtime_hours = 0 + 1 + 0.5 = 1.5h
|
||||
assert out['base_hours'] == pytest.approx(24.0)
|
||||
assert out['overtime_hours'] == pytest.approx(1.5)
|
||||
assert out['base'] == pytest.approx(240000)
|
||||
assert out['overtime'] == pytest.approx(22500) # 1.5 * 10000 * 1.5
|
||||
|
||||
def test_missing_keys_default_zero(self):
|
||||
out = estimate_pay([{}], 10000)
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_overtime_minutes_zero_when_negative_total(self):
|
||||
# total - overtime이 음수가 되면 base는 0으로 클램프
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 0.3, 'overtime_minutes': 60}], # 0.3h - 1h = -0.7
|
||||
hourly_wage=10000,
|
||||
)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == pytest.approx(15000)
|
||||
|
||||
|
||||
class TestFormatWon:
|
||||
@pytest.mark.parametrize("amount,expected", [
|
||||
(0, '0원'),
|
||||
(1000, '1,000원'),
|
||||
(1234567, '1,234,567원'),
|
||||
(999, '999원'),
|
||||
(82500.4, '82,500원'), # round
|
||||
(82500.6, '82,501원'),
|
||||
])
|
||||
def test_format(self, amount, expected):
|
||||
assert format_won(amount) == expected
|
||||
@ -98,3 +98,77 @@ class TestDayType:
|
||||
mon = datetime(2026, 5, 4)
|
||||
assert not calc.is_weekend(mon)
|
||||
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
|
||||
|
||||
82
ui/accessibility.py
Normal file
82
ui/accessibility.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""
|
||||
접근성 — 글꼴 크기 / 고대비 모드 적용.
|
||||
|
||||
QApplication 글로벌 폰트 + 추가 QSS 오버레이.
|
||||
설정 변경 즉시 반영 (재시작 불필요).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
|
||||
# 고대비 QSS — 검정 배경 + 노란 텍스트 + 굵은 테두리
|
||||
HIGH_CONTRAST_QSS = """
|
||||
* {
|
||||
background-color: #000000;
|
||||
color: #FFEB3B;
|
||||
border-color: #FFEB3B;
|
||||
}
|
||||
QPushButton, QLineEdit, QSpinBox, QComboBox, QTextEdit, QTableWidget, QGroupBox {
|
||||
border: 2px solid #FFEB3B;
|
||||
background-color: #000000;
|
||||
color: #FFEB3B;
|
||||
}
|
||||
QPushButton:hover { background-color: #333333; }
|
||||
QPushButton:pressed { background-color: #FFEB3B; color: #000000; }
|
||||
QPushButton:disabled { color: #888; border-color: #888; }
|
||||
QGroupBox::title { color: #FFEB3B; padding: 0 4px; }
|
||||
QProgressBar { border: 2px solid #FFEB3B; }
|
||||
QProgressBar::chunk { background-color: #FFEB3B; }
|
||||
QToolTip { background-color: #000; color: #FFEB3B; border: 2px solid #FFEB3B; }
|
||||
"""
|
||||
|
||||
|
||||
def apply_font_scale(scale: float) -> None:
|
||||
"""전역 글꼴 크기 배율 적용 (1.0 = 기본, 1.25 = 125%, 1.5 = 150%)."""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
base = app.font()
|
||||
if base.pointSize() > 0:
|
||||
# 기존 배율 무시하고 새 배율로 (기본 9pt 가정)
|
||||
base_pt = 9
|
||||
base.setPointSize(int(round(base_pt * scale)))
|
||||
else:
|
||||
base_px = 12
|
||||
base.setPixelSize(int(round(base_px * scale)))
|
||||
app.setFont(base)
|
||||
|
||||
|
||||
def apply_high_contrast(enabled: bool, base_qss: str = "") -> None:
|
||||
"""고대비 모드 ON/OFF. base_qss는 평소 테마 QSS (OFF 시 복원용)."""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
if enabled:
|
||||
app.setStyleSheet(base_qss + "\n" + HIGH_CONTRAST_QSS)
|
||||
else:
|
||||
app.setStyleSheet(base_qss)
|
||||
|
||||
|
||||
def apply_from_settings(db) -> None:
|
||||
"""db에서 font_scale + high_contrast 읽어 적용."""
|
||||
try:
|
||||
scale = float(db.get_setting('font_scale', '1.0') or 1.0)
|
||||
except (ValueError, TypeError):
|
||||
scale = 1.0
|
||||
scale = max(0.8, min(2.0, scale))
|
||||
apply_font_scale(scale)
|
||||
|
||||
enabled = db.get_setting('high_contrast', 'false').lower() == 'true'
|
||||
# base_qss는 main_window에서 apply_theme() 호출 직후 적용되므로,
|
||||
# 여기서는 현재 styleSheet 그대로 두고 high_contrast만 추가.
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
current = app.styleSheet() or ""
|
||||
# 기존에 추가된 HIGH_CONTRAST_QSS 제거
|
||||
base = current.replace(HIGH_CONTRAST_QSS, "").rstrip() + "\n"
|
||||
if enabled:
|
||||
app.setStyleSheet(base + HIGH_CONTRAST_QSS)
|
||||
else:
|
||||
app.setStyleSheet(base.rstrip())
|
||||
453
ui/achievements_view.py
Normal file
453
ui/achievements_view.py
Normal file
@ -0,0 +1,453 @@
|
||||
"""
|
||||
도전과제 뷰 — 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
|
||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||
|
||||
|
||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||
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(f"QDialog {{ background: {tc('bg')}; }}")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
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(button_qss('default'))
|
||||
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(f"""
|
||||
QFrame {{
|
||||
background: {tc('panel')};
|
||||
border: 1px solid {tc('border')};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
||||
""")
|
||||
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)
|
||||
|
||||
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
|
||||
if _is_dark():
|
||||
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
|
||||
else:
|
||||
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
|
||||
|
||||
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
|
||||
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
|
||||
big.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet(f"color: {tc('border')};")
|
||||
num_row.addWidget(spacer)
|
||||
|
||||
secret_lbl = QLabel(
|
||||
f"<div style='line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 시크릿</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {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: {tc('text_dim')};'>달성률</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||
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(f"""
|
||||
QProgressBar {{
|
||||
background: {tc('panel2')};
|
||||
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(scroll_qss())
|
||||
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(
|
||||
f"color: {tc('text_faint')}; 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'])
|
||||
|
||||
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
|
||||
light = not _is_dark()
|
||||
if is_locked_secret:
|
||||
if light:
|
||||
bg_top = bg_bot = tc('panel'); border = tc('border')
|
||||
else:
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
|
||||
text_color = tc('text_faint')
|
||||
elif light:
|
||||
bg_top = bg_bot = tc('panel')
|
||||
border = theme['border_strong'] if is_earned else theme['border']
|
||||
text_color = tc('text') if is_earned else tc('text_dim')
|
||||
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: {tc('text') if is_earned else tc('text_dim')}; "
|
||||
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'] if _is_dark() else tc('text_dim')}; "
|
||||
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
|
||||
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: {tc('text_dim')}; 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'] if _is_dark() else tc('text')}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
|
||||
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)' if _is_dark() else tc('panel2')};
|
||||
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'] if _is_dark() else tc('text_dim')}; 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 tabs_qss(ACCENT_GOLD)
|
||||
@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("외출 기록 수정")
|
||||
self.setWindowTitle(tr('dlg.break.edit_title'))
|
||||
self.setFixedSize(380, 180)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 외출 시간
|
||||
out_layout = QHBoxLayout()
|
||||
out_label = QLabel("외출 시간:")
|
||||
out_label = QLabel(tr('dlg.break.out_label'))
|
||||
out_label.setFixedWidth(80)
|
||||
self.out_time_edit = QTimeEdit()
|
||||
self.out_time_edit.setDisplayFormat("HH:mm:ss")
|
||||
@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 복귀 시간
|
||||
in_layout = QHBoxLayout()
|
||||
in_label = QLabel("복귀 시간:")
|
||||
in_label = QLabel(tr('dlg.break.in_label'))
|
||||
in_label.setFixedWidth(80)
|
||||
self.in_time_edit = QTimeEdit()
|
||||
self.in_time_edit.setDisplayFormat("HH:mm:ss")
|
||||
@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
reason_layout = QHBoxLayout()
|
||||
reason_label = QLabel("사유:")
|
||||
reason_label = QLabel(tr('dlg.break.reason_label'))
|
||||
reason_label.setFixedWidth(80)
|
||||
self.reason_edit = QLineEdit()
|
||||
reason_layout.addWidget(reason_label)
|
||||
@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
cancel_button = QPushButton("취소")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
|
||||
save_button.clicked.connect(self.accept)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
@ -128,7 +128,7 @@ class BreakView(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("오늘의 외출 기록")
|
||||
title = QLabel(tr('view.break.today_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
@ -136,7 +136,13 @@ class BreakView(QDialog):
|
||||
# 외출 리스트 테이블
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""])
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr('view.break.col_out'),
|
||||
tr('view.break.col_in'),
|
||||
tr('view.break.col_duration'),
|
||||
tr('view.break.col_reason'),
|
||||
"",
|
||||
])
|
||||
|
||||
# 테이블 설정
|
||||
header = self.table.horizontalHeader()
|
||||
@ -152,7 +158,7 @@ class BreakView(QDialog):
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# 총 외출 시간 표시
|
||||
self.total_label = QLabel("총 외출 시간: 0분")
|
||||
self.total_label = QLabel(tr('view.break.total_zero'))
|
||||
self.total_label.setObjectName("section_title")
|
||||
self.total_label.setAlignment(Qt.AlignRight)
|
||||
layout.addWidget(self.total_label)
|
||||
@ -160,8 +166,8 @@ class BreakView(QDialog):
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.refresh_button = QPushButton("새로고침")
|
||||
close_button = QPushButton("닫기")
|
||||
self.refresh_button = QPushButton(tr('btn.refresh'))
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
|
||||
self.refresh_button.clicked.connect(self.load_break_records)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@ -190,7 +196,7 @@ class BreakView(QDialog):
|
||||
if break_in:
|
||||
self.table.setItem(i, 1, QTableWidgetItem(break_in))
|
||||
else:
|
||||
item = QTableWidgetItem("진행중")
|
||||
item = QTableWidgetItem(tr('view.break.in_progress'))
|
||||
item.setForeground(Qt.red)
|
||||
self.table.setItem(i, 1, item)
|
||||
|
||||
@ -199,7 +205,10 @@ class BreakView(QDialog):
|
||||
if total_minutes:
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분"
|
||||
if hours > 0:
|
||||
duration_str = tr('view.break.duration_fmt', h=hours, m=minutes)
|
||||
else:
|
||||
duration_str = tr('view.break.duration_min_only', m=minutes)
|
||||
self.table.setItem(i, 2, QTableWidgetItem(duration_str))
|
||||
else:
|
||||
self.table.setItem(i, 2, QTableWidgetItem("-"))
|
||||
@ -214,8 +223,8 @@ class BreakView(QDialog):
|
||||
action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
action_layout.setSpacing(5)
|
||||
|
||||
edit_button = QPushButton("수정")
|
||||
delete_button = QPushButton("삭제")
|
||||
edit_button = QPushButton(tr('btn.edit_short'))
|
||||
delete_button = QPushButton(tr('btn.delete_short'))
|
||||
|
||||
edit_button.setFixedSize(50, 25)
|
||||
delete_button.setFixedSize(50, 25)
|
||||
@ -237,9 +246,9 @@ class BreakView(QDialog):
|
||||
minutes = total_minutes % 60
|
||||
|
||||
if hours > 0:
|
||||
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분")
|
||||
self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
|
||||
else:
|
||||
self.total_label.setText(f"총 외출 시간: {minutes}분")
|
||||
self.total_label.setText(tr('view.break.total_min_only', m=minutes))
|
||||
|
||||
def edit_record(self, record_id):
|
||||
"""외출 기록 수정"""
|
||||
@ -277,8 +286,8 @@ class BreakView(QDialog):
|
||||
"""외출 기록 삭제"""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"삭제 확인",
|
||||
"이 외출 기록을 삭제하시겠습니까?",
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.break.delete_confirm'),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
|
||||
@ -47,15 +47,19 @@ class CalendarView(QDialog):
|
||||
self.calendar.setMinimumHeight(280)
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self.date_selected)
|
||||
# 우클릭 컨텍스트 메뉴 (과거 일자 수동 추가)
|
||||
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.calendar.customContextMenuRequested.connect(self._show_date_context)
|
||||
layout.addWidget(self.calendar, 1)
|
||||
|
||||
# 범례
|
||||
legend_layout = QHBoxLayout()
|
||||
legend_layout.setSpacing(12)
|
||||
legend_layout.addWidget(QLabel("🟢 정상"))
|
||||
legend_layout.addWidget(QLabel("🔴 연장"))
|
||||
legend_layout.addWidget(QLabel("🟡 휴가"))
|
||||
legend_layout.addWidget(QLabel("⚪ 없음"))
|
||||
for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
|
||||
('#FAB005', '휴가'), ('#6C6E73', '없음')]:
|
||||
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
_item.setTextFormat(Qt.RichText)
|
||||
legend_layout.addWidget(_item)
|
||||
legend_layout.addStretch()
|
||||
layout.addLayout(legend_layout)
|
||||
|
||||
@ -74,13 +78,13 @@ class CalendarView(QDialog):
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6)
|
||||
|
||||
self.edit_time_button = QPushButton("✏️ 시간 수정")
|
||||
self.edit_time_button = QPushButton("시간 수정")
|
||||
self.edit_time_button.setObjectName("btn_primary")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||
button_layout.addWidget(self.edit_time_button)
|
||||
|
||||
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
|
||||
self.delete_record_button = QPushButton("기록 삭제")
|
||||
self.delete_record_button.setObjectName("btn_danger")
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||
@ -101,7 +105,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
|
||||
self.save_memo_button = QPushButton("💾 메모 저장")
|
||||
self.save_memo_button = QPushButton("메모 저장")
|
||||
self.save_memo_button.setObjectName("btn_primary")
|
||||
self.save_memo_button.setEnabled(False)
|
||||
self.save_memo_button.clicked.connect(self.save_memo)
|
||||
@ -152,6 +156,109 @@ class CalendarView(QDialog):
|
||||
|
||||
self.calendar.setDateTextFormat(qdate, fmt)
|
||||
|
||||
def _show_date_context(self, pos):
|
||||
"""캘린더 우클릭 메뉴 — 과거 일자 추가/편집/삭제."""
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
qdate = self.calendar.selectedDate()
|
||||
date_str = qdate.toString('yyyy-MM-dd')
|
||||
existing = self.db.get_work_record(date_str)
|
||||
|
||||
menu = QMenu(self)
|
||||
edit_action = delete_action = add_action = None
|
||||
if existing:
|
||||
edit_action = menu.addAction(f"{date_str} 편집")
|
||||
delete_action = menu.addAction(f"{date_str} 삭제")
|
||||
else:
|
||||
add_action = menu.addAction(f"{date_str} 기록 추가")
|
||||
|
||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||
if action is None:
|
||||
return
|
||||
|
||||
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||
if action == edit_action:
|
||||
self._open_edit_dialog(date_str)
|
||||
elif action == delete_action:
|
||||
self._delete_record(date_str)
|
||||
elif action == add_action:
|
||||
self._add_past_record(date_str)
|
||||
|
||||
def _add_past_record(self, date_str: str):
|
||||
"""과거 일자 수동 추가."""
|
||||
from ui.past_record_dialog import PastRecordDialog
|
||||
dialog = PastRecordDialog(self, date_str)
|
||||
if dialog.exec_() != QDialog.Accepted:
|
||||
return
|
||||
data = dialog.get_data()
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
|
||||
if data.get('clock_out'):
|
||||
# 총 시간/연장근무 계산
|
||||
from datetime import datetime as _dt
|
||||
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||
from core.time_calculator import TimeCalculator
|
||||
wm = self.db.get_work_minutes()
|
||||
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
|
||||
calc = TimeCalculator(work_minutes=wm, lunch_duration_minutes=lunch)
|
||||
total = (co - ci).total_seconds() / 3600
|
||||
ot_actual, ot_earned = calc.calculate_overtime(
|
||||
ci, co,
|
||||
include_lunch=data.get('lunch', False),
|
||||
include_dinner=data.get('dinner', False),
|
||||
)
|
||||
self.db.update_clock_out(date_str, data['clock_out'], total, ot_actual, ot_earned)
|
||||
if data.get('lunch'):
|
||||
self.db.update_lunch_break(date_str, True)
|
||||
if data.get('dinner'):
|
||||
self.db.update_dinner_break(date_str, True)
|
||||
if ot_earned > 0:
|
||||
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||
self._refresh_calendar()
|
||||
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
|
||||
|
||||
def _open_edit_dialog(self, date_str: str):
|
||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||
from PyQt5.QtCore import QDate
|
||||
y, m, d = date_str.split('-')
|
||||
self.calendar.setSelectedDate(QDate(int(y), int(m), int(d)))
|
||||
self.date_selected(self.calendar.selectedDate())
|
||||
# 사용자가 화면 하단에 표시된 "✏️ 시간 수정" 버튼 클릭하면 편집
|
||||
|
||||
def _delete_record(self, date_str: str):
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (date_str,))
|
||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (date_str,))
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||
conn.commit()
|
||||
self._refresh_calendar()
|
||||
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
QMessageBox.critical(self, "오류", str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _refresh_calendar(self):
|
||||
"""캘린더 마킹 갱신."""
|
||||
if hasattr(self, 'load_calendar_data'):
|
||||
self.load_calendar_data()
|
||||
elif hasattr(self, 'load_records'):
|
||||
self.load_records()
|
||||
|
||||
def date_selected(self, qdate):
|
||||
"""날짜 선택 시"""
|
||||
selected_date = qdate.toPyDate()
|
||||
@ -163,7 +270,7 @@ class CalendarView(QDialog):
|
||||
|
||||
if record:
|
||||
# 상세 정보 표시
|
||||
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail = f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail += f"출근: {record['clock_in']}\n"
|
||||
|
||||
if record.get('clock_out'):
|
||||
@ -199,7 +306,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||
self.save_memo_button.setEnabled(True)
|
||||
else:
|
||||
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.detail_text.setText(f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.memo_edit.setPlainText('')
|
||||
@ -302,7 +409,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
||||
title = QLabel(f"{self.date_str} 출퇴근 시간 수정")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
layout.addWidget(title)
|
||||
|
||||
|
||||
@ -10,14 +10,67 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
try:
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
import matplotlib
|
||||
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
from matplotlib.figure import Figure
|
||||
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
|
||||
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
|
||||
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
|
||||
try:
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||
except Exception:
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
_MPL = True
|
||||
except ImportError:
|
||||
except Exception as _mpl_err:
|
||||
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
|
||||
_MPL = False
|
||||
try:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
|
||||
# 막대/선은 데이터 구분용 고정 색.
|
||||
_CHART_BG = '#25262B'
|
||||
_CHART_GRID = '#2C2E33'
|
||||
_CHART_TEXT = '#909296'
|
||||
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
|
||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
|
||||
_CHART_AVG_LINE = '#51CF66' # green
|
||||
|
||||
|
||||
def _refresh_chart_colors() -> None:
|
||||
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
|
||||
global _CHART_BG, _CHART_GRID, _CHART_TEXT
|
||||
try:
|
||||
from ui.styles import ThemeColors
|
||||
_CHART_BG = ThemeColors.get('bg_secondary')
|
||||
_CHART_GRID = ThemeColors.get('border_subtle')
|
||||
_CHART_TEXT = ThemeColors.get('text_secondary')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
|
||||
_refresh_chart_colors()
|
||||
fig.patch.set_facecolor(_CHART_BG)
|
||||
|
||||
|
||||
class _Fallback(QWidget):
|
||||
@ -28,7 +81,7 @@ class _Fallback(QWidget):
|
||||
label = QLabel(message)
|
||||
label.setAlignment(Qt.AlignCenter)
|
||||
label.setWordWrap(True)
|
||||
label.setStyleSheet("color: #888; padding: 20px;")
|
||||
label.setStyleSheet("color: #909296; padding: 20px;")
|
||||
layout.addWidget(label)
|
||||
self.setLayout(layout)
|
||||
|
||||
@ -37,11 +90,14 @@ def make_chart_widget(parent=None) -> QWidget:
|
||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||
if not _MPL:
|
||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||
_refresh_chart_colors()
|
||||
widget = QWidget(parent)
|
||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||
layout = QVBoxLayout()
|
||||
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.setStyleSheet(f"background: {_CHART_BG};")
|
||||
layout.addWidget(canvas)
|
||||
widget.setLayout(layout)
|
||||
widget._figure = fig
|
||||
@ -50,34 +106,134 @@ def make_chart_widget(parent=None) -> QWidget:
|
||||
|
||||
|
||||
def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
"""일별 근무시간 막대 그래프.
|
||||
|
||||
Args:
|
||||
widget: make_chart_widget()로 만든 위젯
|
||||
records: [{date, total_hours, overtime_minutes}, ...]
|
||||
"""
|
||||
"""일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁)."""
|
||||
if not getattr(widget, '_figure', None):
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
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()
|
||||
return
|
||||
|
||||
dates = [r['date'][5:] for r in records] # MM-DD만
|
||||
full_dates = [r['date'] for r in records]
|
||||
hours = [r.get('total_hours', 0) or 0 for r in records]
|
||||
overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records]
|
||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
ax.bar(dates, base, label='정상', color='#4a90e2')
|
||||
ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
||||
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
||||
color=_CHART_BAR_OVERTIME)
|
||||
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.grid(axis='y', alpha=0.3)
|
||||
_apply_dark_axes(ax)
|
||||
|
||||
# 호버 annotation 설정
|
||||
annot = ax.annotate(
|
||||
"", xy=(0, 0), xytext=(15, 15),
|
||||
textcoords="offset points",
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
|
||||
alpha=0.95),
|
||||
color="white", fontsize=9,
|
||||
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
|
||||
)
|
||||
annot.set_visible(False)
|
||||
|
||||
def on_hover(event):
|
||||
if event.inaxes != ax:
|
||||
if annot.get_visible():
|
||||
annot.set_visible(False)
|
||||
widget._canvas.draw_idle()
|
||||
return
|
||||
for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')):
|
||||
for i, bar in enumerate(bars):
|
||||
if bar.contains(event)[0]:
|
||||
h = hours[i]; ot = overtimes[i]
|
||||
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
||||
if ot > 0:
|
||||
text += f"\n연장 +{ot:.1f}h"
|
||||
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
||||
annot.set_text(text)
|
||||
annot.set_visible(True)
|
||||
widget._canvas.draw_idle()
|
||||
# 도전과제 #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
|
||||
if annot.get_visible():
|
||||
annot.set_visible(False)
|
||||
widget._canvas.draw_idle()
|
||||
|
||||
widget._canvas.mpl_connect("motion_notify_event", on_hover)
|
||||
widget._canvas.draw()
|
||||
|
||||
|
||||
def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
"""출근 시각 분포 히스토그램 (30분 빈)."""
|
||||
if not getattr(widget, '_figure', None):
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
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
|
||||
|
||||
minutes_list = []
|
||||
for r in records:
|
||||
ci = r.get('clock_in')
|
||||
if not ci:
|
||||
continue
|
||||
parts = ci.split(':')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
minutes_list.append(int(parts[0]) * 60 + int(parts[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not minutes_list:
|
||||
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
|
||||
|
||||
bin_size = 30
|
||||
min_m = (min(minutes_list) // bin_size) * bin_size
|
||||
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
|
||||
bins = list(range(min_m, max_m + bin_size, bin_size))
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||
edgecolor=_CHART_BG, linewidth=1)
|
||||
avg = sum(minutes_list) / len(minutes_list)
|
||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||
rotation=45, fontsize=8)
|
||||
ax.set_ylabel('일수')
|
||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
|
||||
@ -87,6 +243,7 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
|
||||
from datetime import datetime as _dt
|
||||
weekday_totals = [0.0] * 7
|
||||
@ -103,9 +260,8 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
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.set_ylabel('평균 시간')
|
||||
ax.set_title('요일별 평균 근무시간')
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 안내 문구
|
||||
info_label = QLabel("오늘의 출근시간을 입력해주세요")
|
||||
info_label = QLabel(tr('dlg.clock_in.prompt'))
|
||||
info_label.setObjectName("field_label")
|
||||
info_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# 시간 입력
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel("출근시간:")
|
||||
time_label = QLabel(tr('dlg.clock_in.label'))
|
||||
time_label.setObjectName("field_label")
|
||||
|
||||
self.time_edit = QTimeEdit()
|
||||
@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
|
||||
|
||||
# 빠른 선택 버튼
|
||||
quick_layout = QHBoxLayout()
|
||||
quick_label = QLabel("빠른 선택:")
|
||||
quick_label = QLabel(tr('dlg.clock_in.quick'))
|
||||
quick_label.setObjectName("field_label")
|
||||
|
||||
btn_8am = QPushButton("08:00")
|
||||
btn_9am = QPushButton("09:00")
|
||||
btn_10am = QPushButton("10:00")
|
||||
btn_now = QPushButton("현재")
|
||||
btn_now = QPushButton(tr('dlg.clock_in.btn_now'))
|
||||
|
||||
for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
|
||||
btn.setMinimumHeight(30)
|
||||
@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
ok_button = QPushButton("확인")
|
||||
ok_button = QPushButton(tr('btn.confirm'))
|
||||
ok_button.setObjectName("btn_primary")
|
||||
ok_button.setMinimumHeight(40)
|
||||
ok_button.clicked.connect(self.accept)
|
||||
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.setMinimumHeight(40)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
|
||||
@ -17,12 +17,17 @@ class LockMonitor:
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
self.last_locked: bool = False
|
||||
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
|
||||
|
||||
def tick(self) -> None:
|
||||
try:
|
||||
from utils.lock_detector import 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
|
||||
|
||||
was_locked = self.last_locked
|
||||
|
||||
56
ui/controllers/meal_controller.py
Normal file
56
ui/controllers/meal_controller.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""
|
||||
점심/저녁 토글 컨트롤러.
|
||||
|
||||
main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status /
|
||||
update_dinner_status 가 합쳐져 있던 것을 분리. 1Hz hot path 외 사용자 액션 응답.
|
||||
|
||||
단위 테스트가 가능하도록 window 의존성을 명시적으로 받음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class MealController:
|
||||
"""점심/저녁 토글 + 상태 라벨 갱신."""
|
||||
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
|
||||
# -------- 토글 --------
|
||||
def toggle_lunch(self) -> None:
|
||||
w = self.window
|
||||
w.lunch_break_enabled = w.lunch_button.isChecked()
|
||||
self.refresh_lunch_label()
|
||||
|
||||
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
|
||||
if w.lunch_break_enabled:
|
||||
w.auto_lunch_applied_today = True
|
||||
|
||||
if w.is_clocked_in:
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.update_lunch_break(today, w.lunch_break_enabled)
|
||||
|
||||
def toggle_dinner(self) -> None:
|
||||
w = self.window
|
||||
w.dinner_break_enabled = w.dinner_button.isChecked()
|
||||
self.refresh_dinner_label()
|
||||
|
||||
if w.is_clocked_in:
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.update_dinner_break(today, w.dinner_break_enabled)
|
||||
|
||||
# -------- 라벨 --------
|
||||
def refresh_lunch_label(self) -> None:
|
||||
w = self.window
|
||||
w.lunch_button.setText(
|
||||
tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add')
|
||||
)
|
||||
|
||||
def refresh_dinner_label(self) -> None:
|
||||
w = self.window
|
||||
w.dinner_button.setText(
|
||||
tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add')
|
||||
)
|
||||
@ -2,11 +2,24 @@
|
||||
알림 오케스트레이션.
|
||||
|
||||
5분 가드로 건강/주간/누적 임계 알림을 throttle.
|
||||
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
|
||||
notifier.py의 알림 메서드를 적절한 시점에 호출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
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:
|
||||
"""update_display() 1Hz tick에서 호출."""
|
||||
@ -17,35 +30,152 @@ class NotificationOrchestrator:
|
||||
self.notifier = window.notifier
|
||||
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
|
||||
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
||||
def maybe_send_weekly_report(self, now: datetime) -> None:
|
||||
"""월요일 첫 update_display 호출 시 지난주 요약 발송 (시스템 + Discord).
|
||||
|
||||
notification_log로 중복 가드. 월요일이 아니거나 이미 보냈으면 no-op.
|
||||
"""
|
||||
if now.weekday() != 0: # 0=월요일
|
||||
return
|
||||
if self.db.has_notification_today('system', 'weekly_report'):
|
||||
return
|
||||
# 지난주 데이터 (월~일)
|
||||
from datetime import timedelta as _td
|
||||
last_mon = now.date() - _td(days=7)
|
||||
last_sun = now.date() - _td(days=1)
|
||||
records = self.db.get_work_records_by_range(last_mon.isoformat(), last_sun.isoformat())
|
||||
closed = [r for r in records if r.get('clock_out')]
|
||||
if not closed:
|
||||
return # 지난주 기록 없음
|
||||
total_h = sum((r.get('total_hours') or 0) for r in closed)
|
||||
ot_total = sum((r.get('overtime_minutes') or 0) for r in closed)
|
||||
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||
avg_h = total_h / len(closed) if closed else 0
|
||||
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
||||
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
||||
|
||||
title = "📊 지난주 요약"
|
||||
body = (f"기간: {last_mon} ~ {last_sun}\n"
|
||||
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
|
||||
f"일 평균: {avg_h:.1f}시간\n"
|
||||
f"연장근무: {ot_h}시간 {ot_m}분\n"
|
||||
f"가장 긴 날: {longest_str}")
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
self.db.log_notification('system', 'weekly_report')
|
||||
|
||||
# Discord 도 옵션 활성 시 push
|
||||
if self.db.get_setting('discord_notif_clock_out', 'true').lower() == 'true':
|
||||
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||
if url:
|
||||
try:
|
||||
from utils.discord_webhook import send, COLOR_BLUE
|
||||
fields = [
|
||||
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
|
||||
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
|
||||
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
|
||||
{"name": "가장 긴 날", "value": longest_str, "inline": False},
|
||||
]
|
||||
ok = send(url, "📊 지난주 요약",
|
||||
f"기간: {last_mon} ~ {last_sun}",
|
||||
color=COLOR_BLUE, fields=fields)
|
||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||
except Exception as e:
|
||||
dlog(f"discord weekly_report failed: {e}")
|
||||
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
|
||||
is_holiday: bool = False) -> None:
|
||||
n = self.notifier
|
||||
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
||||
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
|
||||
if not is_holiday:
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
|
||||
n.check_lunch_reminder(self.window.clock_in_time,
|
||||
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:
|
||||
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:
|
||||
self._last_5min_bucket = now.minute
|
||||
|
||||
# 월요일 첫 출근 시 지난주 리포트
|
||||
self.maybe_send_weekly_report(now)
|
||||
|
||||
# 휴식 권고 (장시간 연속 근무)
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
||||
|
||||
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
|
||||
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)
|
||||
self._discord_health(consecutive, break_minutes, now)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# 도전과제 평가 (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:
|
||||
"""건강 경고 Discord push (옵션)."""
|
||||
if self.db.has_notification_today('discord', 'health'):
|
||||
@ -60,5 +190,5 @@ class NotificationOrchestrator:
|
||||
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
||||
ok = discord_webhook.send_health_warning(url, elapsed)
|
||||
self.db.log_notification('discord', 'health', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"discord health push failed: {e}")
|
||||
|
||||
456
ui/dark_components.py
Normal file
456
ui/dark_components.py
Normal file
@ -0,0 +1,456 @@
|
||||
"""
|
||||
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
||||
|
||||
핵심 원칙:
|
||||
- 다이얼로그 배경: #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
|
||||
|
||||
|
||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
||||
DARK_BG = '#1A1B1E'
|
||||
DARK_PANEL = '#25262B'
|
||||
DARK_PANEL_2 = '#2C2E33'
|
||||
DARK_BORDER = '#2C2E33'
|
||||
DARK_BORDER_STRONG = '#373A40'
|
||||
DARK_TEXT = '#E9ECEF'
|
||||
DARK_TEXT_DIM = '#909296'
|
||||
DARK_TEXT_FAINT = '#6C6E73'
|
||||
|
||||
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||
ACCENT_GOLD = '#ffd24a'
|
||||
ACCENT_BLUE = '#4DABF7'
|
||||
ACCENT_CYAN = '#4adef0'
|
||||
ACCENT_PINK = '#ff90b8'
|
||||
ACCENT_GREEN = '#51CF66'
|
||||
ACCENT_ORANGE = '#fcd34d'
|
||||
ACCENT_RED = '#FA5252'
|
||||
|
||||
# 카드 테마 (등급/상태별)
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 테마 연동 ──────────────────────────────────────────────────
|
||||
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
||||
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
|
||||
|
||||
def _pal() -> dict:
|
||||
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
|
||||
from ui.styles import ThemeColors
|
||||
g = ThemeColors.get
|
||||
return {
|
||||
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
|
||||
'border': g('border_subtle'), 'border_strong': g('border_default'),
|
||||
'text': g('text_primary'), 'text_dim': g('text_secondary'),
|
||||
'text_faint': g('text_tertiary'),
|
||||
'blue': g('accent_primary'), 'green': g('accent_success'),
|
||||
'red': g('accent_danger'),
|
||||
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
|
||||
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
|
||||
}
|
||||
|
||||
|
||||
def _is_dark() -> bool:
|
||||
from ui.styles import ThemeColors, DARK_COLORS
|
||||
return ThemeColors.current is DARK_COLORS
|
||||
|
||||
|
||||
def tc(role: str) -> str:
|
||||
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
|
||||
return _pal().get(role, '#FF00FF')
|
||||
|
||||
|
||||
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
def dialog_qss() -> str:
|
||||
"""다이얼로그 전체 배경 (현재 테마)."""
|
||||
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = None) -> str:
|
||||
p = _pal()
|
||||
if accent is None:
|
||||
accent = p['blue']
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {p['panel2']};
|
||||
color: {p['text_dim']};
|
||||
padding: 9px 18px;
|
||||
border: 1px solid {p['border']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {p['panel']};
|
||||
color: {accent};
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid {accent};
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
background: {p['border_strong']};
|
||||
color: {p['text']};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scroll_qss() -> str:
|
||||
p = _pal()
|
||||
return f"""
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
QScrollBar:vertical {{
|
||||
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
def button_qss(variant: str = 'default') -> str:
|
||||
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
||||
p = _pal()
|
||||
if variant == 'primary':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['blue']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['blue_hover']}; }}
|
||||
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
|
||||
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['green']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['green_hover']}; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['red']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['red_hover']}; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {p['text_dim']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
||||
border-color: {p['blue']}; }}
|
||||
"""
|
||||
# default
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['panel2']}; color: {p['text']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['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: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||
"""
|
||||
p = _pal()
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {p['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: {p['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: {p['text_dim']};'>"
|
||||
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'])
|
||||
p = _pal()
|
||||
dark = _is_dark()
|
||||
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
|
||||
if dark:
|
||||
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||
card_border = t['border']
|
||||
label_color = t['text']
|
||||
value_color = t['border_strong']
|
||||
else:
|
||||
card_bg = p['panel']
|
||||
card_border = p['border']
|
||||
label_color = p['text']
|
||||
value_color = p['text']
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(16, 12, 16, 12)
|
||||
outer.setSpacing(12)
|
||||
|
||||
if icon:
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setMinimumWidth(48)
|
||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
|
||||
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
|
||||
else:
|
||||
# 이모지/텍스트 폴백 (구버전 호환)
|
||||
icon_lbl.setText(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
outer.addWidget(icon_lbl)
|
||||
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(2)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 9.5pt; color: {p['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: {value_color};'>"
|
||||
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: {p['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'])
|
||||
p = _pal()
|
||||
if _is_dark():
|
||||
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||
card_border = t['border']
|
||||
label_color = t['text']
|
||||
else:
|
||||
card_bg = p['panel']
|
||||
card_border = p['border']
|
||||
label_color = p['text']
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 14)
|
||||
layout.setSpacing(8)
|
||||
|
||||
head = QHBoxLayout()
|
||||
if icon:
|
||||
i = QLabel()
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
|
||||
else:
|
||||
i.setText(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: {p['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 = None) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
||||
lbl = QLabel(text)
|
||||
if color is None:
|
||||
color = _pal()['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
|
||||
100
ui/goal_widget.py
Normal file
100
ui/goal_widget.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""
|
||||
목표 진행률 위젯.
|
||||
|
||||
월 연장근무 상한 + 일평균 목표를 stats_view 또는 메인에 표시.
|
||||
설정값이 0이면 비활성 (위젯 자체 hide).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, date
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
class GoalWidget(QWidget):
|
||||
"""월간 목표 진행률 표시."""
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title = QLabel("이번 달 목표")
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
self.ot_label = QLabel("연장근무:")
|
||||
self.ot_label.setFixedWidth(100)
|
||||
self.ot_bar = QProgressBar()
|
||||
self.ot_bar.setTextVisible(True)
|
||||
self.ot_bar.setFixedHeight(18)
|
||||
ot_row.addWidget(self.ot_label)
|
||||
ot_row.addWidget(self.ot_bar, 1)
|
||||
layout.addLayout(ot_row)
|
||||
|
||||
# 일평균
|
||||
avg_row = QHBoxLayout()
|
||||
self.avg_label = QLabel("일평균:")
|
||||
self.avg_label.setFixedWidth(100)
|
||||
self.avg_bar = QProgressBar()
|
||||
self.avg_bar.setTextVisible(True)
|
||||
self.avg_bar.setFixedHeight(18)
|
||||
avg_row.addWidget(self.avg_label)
|
||||
avg_row.addWidget(self.avg_bar, 1)
|
||||
layout.addLayout(avg_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def refresh(self):
|
||||
"""현재 설정값과 이번 달 통계로 진행률 갱신. 0=비활성 시 row 숨김."""
|
||||
try:
|
||||
ot_target = int(self.db.get_setting('goal_overtime_max_monthly', '0') or 0)
|
||||
avg_target = float(self.db.get_setting('goal_avg_hours_daily', '0') or 0)
|
||||
except (ValueError, TypeError):
|
||||
ot_target, avg_target = 0, 0.0
|
||||
|
||||
# 둘 다 비활성이면 위젯 자체 숨김
|
||||
if ot_target <= 0 and avg_target <= 0:
|
||||
self.setVisible(False)
|
||||
return
|
||||
self.setVisible(True)
|
||||
|
||||
now = datetime.now()
|
||||
stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
ot_total = (stats.get('total_overtime_minutes') or 0)
|
||||
total_h = stats.get('total_hours') or 0
|
||||
work_days = stats.get('work_days') or 1
|
||||
|
||||
# 연장근무 상한 (낮을수록 좋음)
|
||||
if ot_target > 0:
|
||||
self.ot_label.setVisible(True)
|
||||
self.ot_bar.setVisible(True)
|
||||
self.ot_bar.setMaximum(ot_target)
|
||||
self.ot_bar.setValue(min(ot_total, ot_target))
|
||||
ratio = ot_total / ot_target if ot_target else 0
|
||||
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||
tg_h, tg_m = ot_target // 60, ot_target % 60
|
||||
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
|
||||
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
|
||||
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.ot_label.setVisible(False)
|
||||
self.ot_bar.setVisible(False)
|
||||
|
||||
# 일평균 (목표 시간보다 적으면 좋음)
|
||||
if avg_target > 0:
|
||||
self.avg_label.setVisible(True)
|
||||
self.avg_bar.setVisible(True)
|
||||
avg = total_h / work_days if work_days else 0
|
||||
self.avg_bar.setMaximum(int(avg_target * 100))
|
||||
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
|
||||
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
||||
ratio = avg / avg_target if avg_target else 0
|
||||
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
|
||||
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.avg_label.setVisible(False)
|
||||
self.avg_bar.setVisible(False)
|
||||
132
ui/help_view.py
132
ui/help_view.py
@ -2,6 +2,7 @@
|
||||
사용 설명 가이드 창.
|
||||
|
||||
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
|
||||
도전과제/통계 다이얼로그와 동일한 다크 톤.
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QWidget, QTabWidget, QTextBrowser)
|
||||
@ -9,6 +10,7 @@ from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr, tr_html
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
|
||||
|
||||
|
||||
class HelpView(QDialog):
|
||||
@ -28,42 +30,47 @@ class HelpView(QDialog):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(tr('window.help'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(680, 720)
|
||||
|
||||
self.setMinimumSize(720, 720)
|
||||
self.resize(820, 760)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
main_layout.setContentsMargins(20, 16, 20, 14)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# 다크 타이틀
|
||||
title = QLabel(tr('window.help'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
main_layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setDocumentMode(True)
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
for html_key, tab_label_key in self._TABS:
|
||||
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.setContentsMargins(20, 10, 20, 20)
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽)
|
||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(40)
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton("온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
button_layout.addWidget(onboarding_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setObjectName("btn_primary")
|
||||
close_button.setMinimumHeight(40)
|
||||
close_button.setMinimumHeight(36)
|
||||
close_button.setMinimumWidth(120)
|
||||
close_button.setStyleSheet(button_qss('primary'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
@ -78,15 +85,106 @@ class HelpView(QDialog):
|
||||
|
||||
def _make_tab(self, html: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background: {tc('panel')};")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
browser = QTextBrowser()
|
||||
browser.setOpenExternalLinks(False)
|
||||
browser.setHtml(html)
|
||||
# HTML 내부에 다크 톤 스타일 주입
|
||||
styled_html = self._inject_dark_styles(html)
|
||||
browser.setHtml(styled_html)
|
||||
browser.setStyleSheet(f"""
|
||||
QTextBrowser {{
|
||||
background: {tc('panel')};
|
||||
color: {tc('text')};
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 10.5pt;
|
||||
selection-background-color: {tc('blue')};
|
||||
selection-color: #ffffff;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: {tc('panel')}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
""")
|
||||
layout.addWidget(browser)
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
def _inject_dark_styles(self, html: str) -> str:
|
||||
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
|
||||
text = tc('text')
|
||||
dim = tc('text_dim')
|
||||
blue = tc('blue')
|
||||
green = tc('green')
|
||||
panel2 = tc('panel2')
|
||||
border = tc('border')
|
||||
css = f"""
|
||||
<style>
|
||||
body, p, li {{
|
||||
color: {text};
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
h1, h2, h3, h4 {{
|
||||
color: {blue};
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: {blue}; }}
|
||||
h4 {{ font-size: 11pt; color: {green}; }}
|
||||
b, strong {{ color: {text}; }}
|
||||
code {{
|
||||
background: {panel2};
|
||||
color: {blue};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
pre {{
|
||||
background: {panel2};
|
||||
border: 1px solid {border};
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: {text};
|
||||
}}
|
||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
a {{ color: {blue}; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||
th {{
|
||||
background: {panel2};
|
||||
color: {text};
|
||||
padding: 8px 12px;
|
||||
border: 1px solid {border};
|
||||
text-align: left;
|
||||
}}
|
||||
td {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid {border};
|
||||
color: {text};
|
||||
}}
|
||||
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
|
||||
blockquote {{
|
||||
border-left: 3px solid {blue};
|
||||
margin-left: 0;
|
||||
padding: 4px 16px;
|
||||
color: {dim};
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
return css + html
|
||||
|
||||
|
||||
# 단독 실행 테스트
|
||||
if __name__ == "__main__":
|
||||
|
||||
94
ui/i18n_runtime.py
Normal file
94
ui/i18n_runtime.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""
|
||||
런타임 i18n 재번역 — 재시작 없이 언어 전환.
|
||||
|
||||
사용법:
|
||||
from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate
|
||||
|
||||
label = QLabel(tr('label.foo'))
|
||||
register(label, 'label.foo') # 약한 참조로 등록
|
||||
|
||||
button = QPushButton()
|
||||
register(button, 'btn.bar', kwargs={'name': 'X'})
|
||||
|
||||
# 그룹박스 제목 등 setter가 다른 경우
|
||||
register(group, 'group.work_time', setter='setTitle')
|
||||
|
||||
# 윈도우 제목
|
||||
register(dialog, 'window.foo', setter='setWindowTitle')
|
||||
|
||||
언어 변경:
|
||||
set_language_and_retranslate('en')
|
||||
|
||||
각 widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는
|
||||
키는 kwargs를 함께 등록하면 retranslate 시 같은 인자로 재계산.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import weakref
|
||||
from typing import Any, Callable, List, Tuple, Optional
|
||||
|
||||
from core.i18n import tr, set_language as _set_language
|
||||
|
||||
# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트
|
||||
# weakref가 죽으면 다음 retranslate 시 정리.
|
||||
_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = []
|
||||
|
||||
|
||||
def register(widget: Any, key: str, *, setter: str = 'setText',
|
||||
kwargs: Optional[dict] = None,
|
||||
post: Optional[Callable[[str], str]] = None) -> None:
|
||||
"""위젯을 retranslate 대상으로 등록.
|
||||
|
||||
Args:
|
||||
widget: PyQt 위젯 (setText/setTitle/setWindowTitle 등 지원)
|
||||
key: i18n 키
|
||||
setter: 호출할 메서드명 (기본 setText)
|
||||
kwargs: tr()에 전달할 format 인자 (정적인 경우만)
|
||||
post: 번역 후 한 번 더 가공할 콜백 — 예: 이모지 prefix
|
||||
"""
|
||||
# 약한 참조 — 위젯 삭제 시 자동 GC
|
||||
try:
|
||||
ref = weakref.ref(widget)
|
||||
except TypeError:
|
||||
# weakref 미지원 객체는 retranslate 불가
|
||||
return
|
||||
_registry.append((ref, key, setter, kwargs or {}, post))
|
||||
# 초기 적용
|
||||
_apply(widget, key, setter, kwargs or {}, post)
|
||||
|
||||
|
||||
def retranslate_all() -> None:
|
||||
"""모든 등록된 위젯에 현재 언어로 텍스트 재적용."""
|
||||
global _registry
|
||||
alive = []
|
||||
for ref, key, setter, kw, post in _registry:
|
||||
widget = ref()
|
||||
if widget is None:
|
||||
continue # 죽은 위젯은 빼버림
|
||||
try:
|
||||
_apply(widget, key, setter, kw, post)
|
||||
alive.append((ref, key, setter, kw, post))
|
||||
except RuntimeError:
|
||||
# Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스
|
||||
continue
|
||||
_registry = alive
|
||||
|
||||
|
||||
def set_language_and_retranslate(lang: str) -> None:
|
||||
"""언어 전환 + 즉시 재번역."""
|
||||
_set_language(lang)
|
||||
retranslate_all()
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""레지스트리 비우기 (테스트용)."""
|
||||
_registry.clear()
|
||||
|
||||
|
||||
def _apply(widget: Any, key: str, setter: str, kw: dict,
|
||||
post: Optional[Callable[[str], str]]) -> None:
|
||||
text = tr(key, **kw)
|
||||
if post:
|
||||
text = post(text)
|
||||
fn = getattr(widget, setter, None)
|
||||
if callable(fn):
|
||||
fn(text)
|
||||
82
ui/icons.py
Normal file
82
ui/icons.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
|
||||
|
||||
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
|
||||
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환 시
|
||||
재호출하면 자동으로 재틴팅된다.
|
||||
|
||||
사용:
|
||||
from ui.icons import get_icon
|
||||
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
|
||||
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QByteArray, QRectF, Qt
|
||||
from PyQt5.QtGui import QIcon, QPixmap, QPainter
|
||||
from PyQt5.QtSvg import QSvgRenderer
|
||||
|
||||
from ui.styles import ThemeColors
|
||||
|
||||
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
|
||||
_PATHS = {
|
||||
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
|
||||
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
|
||||
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
}
|
||||
|
||||
_SVG_TMPL = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||
'fill="none" stroke="{color}" stroke-width="2" '
|
||||
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
|
||||
)
|
||||
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
|
||||
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
|
||||
if color is None:
|
||||
color = ThemeColors.get('text_secondary')
|
||||
key = (name, color, size)
|
||||
cached = _cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
paths = _PATHS.get(name)
|
||||
if paths is None:
|
||||
return QIcon()
|
||||
|
||||
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
|
||||
renderer = QSvgRenderer(QByteArray(svg))
|
||||
|
||||
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
|
||||
pm = QPixmap(size * dpr, size * dpr)
|
||||
pm.fill(Qt.transparent)
|
||||
painter = QPainter(pm)
|
||||
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||
painter.end()
|
||||
pm.setDevicePixelRatio(dpr)
|
||||
|
||||
icon = QIcon(pm)
|
||||
_cache[key] = icon
|
||||
return icon
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
|
||||
_cache.clear()
|
||||
120
ui/leave_calendar_view.py
Normal file
120
ui/leave_calendar_view.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""
|
||||
연차 사용 캘린더 시각화.
|
||||
|
||||
QCalendarWidget에 사용 연차일을 색칠로 표시.
|
||||
- 1.0일: 진한 색
|
||||
- 0.5일(반차): 중간 색
|
||||
- 0.25일(반반차): 옅은 색
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QCalendarWidget)
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class LeaveCalendarView(QDialog):
|
||||
"""연차 캘린더 시각화."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("연차 캘린더")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._mark_dates()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 헤더: 잔여 + 범례
|
||||
header = QHBoxLayout()
|
||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||
used = total - balance
|
||||
title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
layout.addLayout(header)
|
||||
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
legend = QHBoxLayout()
|
||||
for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
|
||||
('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
|
||||
('#748FFC', '종일+예정')]:
|
||||
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
l.setStyleSheet("padding: 2px 6px;")
|
||||
legend.addWidget(l)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
|
||||
# 캘린더
|
||||
self.calendar = QCalendarWidget()
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self._on_date_click)
|
||||
layout.addWidget(self.calendar, 1)
|
||||
|
||||
# 선택 일자 정보
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
|
||||
layout.addWidget(self.detail_label)
|
||||
|
||||
# 닫기 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _mark_dates(self):
|
||||
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
|
||||
from datetime import date as _date
|
||||
today = _date.today()
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
for r in records:
|
||||
try:
|
||||
d = datetime.strptime(r['date'], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
days = float(r.get('days') or 0)
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
|
||||
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
|
||||
else:
|
||||
# 과거/오늘 = 사용 완료 색상
|
||||
if days >= 1.0:
|
||||
color = QColor("#4caf50")
|
||||
elif days >= 0.5:
|
||||
color = QColor("#ffc107")
|
||||
else:
|
||||
color = QColor("#9c27b0")
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setBackground(QBrush(color))
|
||||
fmt.setForeground(QBrush(QColor("white")))
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
|
||||
def _on_date_click(self, qdate):
|
||||
date_str = qdate.toString('yyyy-MM-dd')
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
match = [r for r in records if r['date'] == date_str]
|
||||
if not match:
|
||||
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
|
||||
return
|
||||
parts = []
|
||||
for r in match:
|
||||
t = r.get('leave_type', 'annual')
|
||||
d = float(r.get('days') or 0)
|
||||
memo = r.get('memo') or ''
|
||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
||||
self.detail_label.setText(f"{date_str}: " + ", ".join(parts))
|
||||
154
ui/leave_view.py
154
ui/leave_view.py
@ -40,27 +40,32 @@ class LeaveView(QDialog):
|
||||
|
||||
# 제목 + 잔액 + 설정 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("연차 관리")
|
||||
title = QLabel(tr('view.leave.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
self.balance_label = QLabel("잔여: 0일")
|
||||
self.balance_label = QLabel(tr('view.leave.balance_zero'))
|
||||
self.balance_label.setObjectName("badge_leave")
|
||||
header_layout.addWidget(self.balance_label)
|
||||
set_balance_button = QPushButton("잔여 설정")
|
||||
set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
|
||||
set_balance_button.clicked.connect(self.set_balance)
|
||||
header_layout.addWidget(set_balance_button)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 사용 내역
|
||||
used_group = QGroupBox("📤 사용 내역")
|
||||
used_group = QGroupBox(tr('view.leave.used_group'))
|
||||
used_layout = QVBoxLayout()
|
||||
used_layout.setSpacing(4)
|
||||
used_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.used_table = QTableWidget()
|
||||
self.used_table.setColumnCount(4)
|
||||
self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"])
|
||||
self.used_table.setHorizontalHeaderLabels([
|
||||
tr('view.leave.col_date'),
|
||||
tr('view.leave.col_type'),
|
||||
tr('view.leave.col_used'),
|
||||
tr('view.leave.col_reason'),
|
||||
])
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
@ -77,22 +82,45 @@ class LeaveView(QDialog):
|
||||
|
||||
# 버튼들
|
||||
button_layout = QHBoxLayout()
|
||||
add_leave_button = QPushButton("➕ 연차 사용 추가")
|
||||
add_leave_button = QPushButton(tr('view.leave.btn_add'))
|
||||
add_leave_button.clicked.connect(self.add_leave_record)
|
||||
button_layout.addWidget(add_leave_button)
|
||||
close_button = QPushButton("닫기")
|
||||
|
||||
cal_button = QPushButton(tr('view.leave.btn_calendar'))
|
||||
cal_button.clicked.connect(self._show_calendar)
|
||||
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.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _show_calendar(self):
|
||||
from ui.leave_calendar_view import LeaveCalendarView
|
||||
dlg = LeaveCalendarView(self, self.db)
|
||||
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):
|
||||
"""데이터 로드"""
|
||||
# 잔액 업데이트
|
||||
balance = self.db.get_leave_balance()
|
||||
hours = balance * 8
|
||||
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
|
||||
self.balance_label.setText(tr('view.leave.balance_fmt',
|
||||
days=balance, hours=hours))
|
||||
|
||||
# 사용 내역 로드 (잔액 조정 제외)
|
||||
records = self.db.get_leave_records(exclude_bulk=True)
|
||||
@ -118,7 +146,7 @@ class LeaveView(QDialog):
|
||||
days_str = f"{days}일"
|
||||
days_item = QTableWidgetItem(days_str)
|
||||
days_item.setTextAlignment(Qt.AlignCenter)
|
||||
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
|
||||
memo_item = QTableWidgetItem(record['memo'] or "")
|
||||
|
||||
@ -134,7 +162,7 @@ class LeaveView(QDialog):
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction("삭제", self)
|
||||
delete_action = QAction(tr('btn.delete_short'), self)
|
||||
delete_action.triggered.connect(self.delete_leave_record)
|
||||
menu.addAction(delete_action)
|
||||
menu.exec_(self.used_table.viewport().mapToGlobal(position))
|
||||
@ -154,11 +182,10 @@ class LeaveView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"삭제 확인",
|
||||
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
|
||||
f"날짜: {date_item.text()}\n"
|
||||
f"구분: {type_item.text()}\n"
|
||||
f"사용: {days_item.text()}",
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.leave.delete_confirm_body',
|
||||
date=date_item.text(), type=type_item.text(),
|
||||
days=days_item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -173,13 +200,12 @@ class LeaveView(QDialog):
|
||||
|
||||
hours, ok = QInputDialog.getDouble(
|
||||
self,
|
||||
"연차 시간 설정",
|
||||
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
|
||||
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
|
||||
tr('view.leave.set_title'),
|
||||
tr('view.leave.set_prompt'),
|
||||
current_hours,
|
||||
0.0,
|
||||
999.0,
|
||||
1 # 소수점 첫째자리까지 (0.5 단위)
|
||||
1
|
||||
)
|
||||
|
||||
if ok:
|
||||
@ -190,8 +216,8 @@ class LeaveView(QDialog):
|
||||
self.db.set_leave_balance(days)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"설정 완료",
|
||||
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
|
||||
tr('view.leave.set_done_title'),
|
||||
tr('view.leave.set_done_body', days=days, hours=hours)
|
||||
)
|
||||
self.load_data()
|
||||
|
||||
@ -213,7 +239,7 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("연차 사용 기록 추가")
|
||||
self.setWindowTitle(tr('view.leave.add_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -222,30 +248,32 @@ class AddLeaveDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("연차 사용 기록 추가")
|
||||
title = QLabel(tr('view.leave.add_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 날짜 + 구분 한 줄
|
||||
row1 = QHBoxLayout()
|
||||
date_label = QLabel("날짜:")
|
||||
date_label = QLabel(tr('view.leave.field_date'))
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(40)
|
||||
self.date_edit = QDateEdit()
|
||||
self.date_edit.setDate(QDate.currentDate())
|
||||
self.date_edit.setCalendarPopup(True)
|
||||
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
|
||||
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
|
||||
row1.addWidget(date_label)
|
||||
row1.addWidget(self.date_edit)
|
||||
row1.addSpacing(8)
|
||||
type_label = QLabel("구분:")
|
||||
type_label = QLabel(tr('view.leave.field_type'))
|
||||
type_label.setObjectName("field_label")
|
||||
type_label.setFixedWidth(40)
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.addItem("연차", "annual")
|
||||
self.type_combo.addItem("반차", "half")
|
||||
self.type_combo.addItem("반반차", "quarter")
|
||||
self.type_combo.addItem("시간", "hourly")
|
||||
self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
|
||||
self.type_combo.addItem(tr('view.leave.type_half'), "half")
|
||||
self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
|
||||
self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
|
||||
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
|
||||
row1.addWidget(type_label)
|
||||
row1.addWidget(self.type_combo)
|
||||
@ -253,14 +281,14 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
# 사용 시간 (시간 연차용)
|
||||
hours_layout = QHBoxLayout()
|
||||
hours_label = QLabel("시간:")
|
||||
hours_label = QLabel(tr('view.leave.field_hours'))
|
||||
hours_label.setObjectName("field_label")
|
||||
hours_label.setFixedWidth(40)
|
||||
self.hours_spin = QDoubleSpinBox()
|
||||
self.hours_spin.setRange(0.5, 8.0)
|
||||
self.hours_spin.setSingleStep(0.5)
|
||||
self.hours_spin.setValue(1.0)
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.hours_spin.setSuffix(' ' + tr('label.unit_hour'))
|
||||
self.hours_spin.setEnabled(False)
|
||||
hours_layout.addWidget(hours_label)
|
||||
hours_layout.addWidget(self.hours_spin)
|
||||
@ -268,27 +296,27 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
memo_layout = QHBoxLayout()
|
||||
memo_label = QLabel("사유:")
|
||||
memo_label = QLabel(tr('view.leave.field_reason'))
|
||||
memo_label.setObjectName("field_label")
|
||||
memo_label.setFixedWidth(40)
|
||||
self.memo_input = QLineEdit()
|
||||
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
|
||||
self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
|
||||
memo_layout.addWidget(memo_label)
|
||||
memo_layout.addWidget(self.memo_input)
|
||||
layout.addLayout(memo_layout)
|
||||
|
||||
# 안내
|
||||
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
|
||||
info_label = QLabel(tr('view.leave.note_auto_deduct'))
|
||||
info_label.setObjectName("note_text")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save_record)
|
||||
button_layout.addWidget(save_button)
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
@ -326,8 +354,40 @@ class AddLeaveDialog(QDialog):
|
||||
if current_balance < days:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"잔여 연차 부족",
|
||||
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
|
||||
tr('view.leave.short_title'),
|
||||
tr('view.leave.short_body', balance=current_balance, req=days)
|
||||
)
|
||||
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
|
||||
|
||||
@ -335,12 +395,10 @@ class AddLeaveDialog(QDialog):
|
||||
hours = days * 8
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"연차 사용 기록 추가",
|
||||
f"날짜: {date}\n"
|
||||
f"구분: {leave_type_name}\n"
|
||||
f"사용: {days}일 ({hours}시간)\n"
|
||||
f"사유: {memo if memo else '(없음)'}\n\n"
|
||||
f"이 기록을 추가하시겠습니까?",
|
||||
tr('view.leave.confirm_title'),
|
||||
tr('view.leave.confirm_body',
|
||||
date=date, type=leave_type_name, days=days, hours=hours,
|
||||
reason=(memo if memo else '-')),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -350,15 +408,15 @@ class AddLeaveDialog(QDialog):
|
||||
self.db.use_leave(days, date, leave_type_name, memo)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"추가 완료",
|
||||
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
|
||||
tr('view.leave.added_title'),
|
||||
tr('view.leave.added_body', days=days, hours=hours)
|
||||
)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"오류",
|
||||
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
|
||||
tr('view.leave.error_title'),
|
||||
tr('view.leave.error_body', err=str(e))
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
176
ui/meal_time_dialog.py
Normal file
176
ui/meal_time_dialog.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
점심/저녁 실제 시간 입력 다이얼로그.
|
||||
|
||||
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
||||
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
||||
|
||||
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
|
||||
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QMessageBox)
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class MealTimeDialog(QDialog):
|
||||
"""점심/저녁 실제 시작·종료 시간 입력.
|
||||
|
||||
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)
|
||||
self.meal_type = meal_type
|
||||
self._clock_in = clock_in_time
|
||||
self._clock_out = clock_out_time
|
||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 260)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
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.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||
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.addWidget(QLabel("시작:"))
|
||||
self.start_edit = QTimeEdit()
|
||||
self.start_edit.setDisplayFormat("HH:mm")
|
||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||
start_row.addWidget(self.start_edit)
|
||||
start_row.addStretch()
|
||||
layout.addLayout(start_row)
|
||||
|
||||
# 종료
|
||||
end_row = QHBoxLayout()
|
||||
end_row.addWidget(QLabel("종료:"))
|
||||
self.end_edit = QTimeEdit()
|
||||
self.end_edit.setDisplayFormat("HH:mm")
|
||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||
end_row.addWidget(self.end_edit)
|
||||
end_row.addStretch()
|
||||
layout.addLayout(end_row)
|
||||
|
||||
# 미리보기 라벨
|
||||
self.preview = QLabel("")
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
|
||||
layout.addWidget(self.preview)
|
||||
self._update_preview()
|
||||
self.start_edit.timeChanged.connect(self._update_preview)
|
||||
self.end_edit.timeChanged.connect(self._update_preview)
|
||||
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
# -------- 내부 시각 계산 --------
|
||||
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
|
||||
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
|
||||
|
||||
자정 경계 처리:
|
||||
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:
|
||||
end_dt += timedelta(days=1)
|
||||
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
||||
return start_dt, end_dt, minutes
|
||||
|
||||
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: #FA5252;")
|
||||
else:
|
||||
self.preview.setText(f"총 {minutes}분")
|
||||
self.preview.setStyleSheet("color: #51CF66; 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]:
|
||||
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
||||
s = self.start_edit.time().toPyTime()
|
||||
e = self.end_edit.time().toPyTime()
|
||||
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
||||
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
||||
_, _, minutes = self._resolve_meal_window()
|
||||
return start_str, end_str, minutes
|
||||
@ -41,7 +41,7 @@ class MiniWidget(QWidget):
|
||||
|
||||
self.title_label = QLabel(tr('label.remaining'))
|
||||
self.title_label.setAlignment(Qt.AlignCenter)
|
||||
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
|
||||
self.time_label = QLabel("--:--:--")
|
||||
self.time_label.setAlignment(Qt.AlignCenter)
|
||||
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
|
||||
layout.addWidget(self.time_label)
|
||||
self.setLayout(layout)
|
||||
|
||||
# 기본 스타일 (테마 무관 가독성 유지)
|
||||
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
|
||||
self.setStyleSheet("""
|
||||
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
|
||||
QLabel { color: #fff; }
|
||||
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
|
||||
QLabel { color: #E9ECEF; background: transparent; }
|
||||
""")
|
||||
|
||||
apply_dark_titlebar(self)
|
||||
@ -63,11 +63,12 @@ class MiniWidget(QWidget):
|
||||
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
||||
self.time_label.setText(remaining_str)
|
||||
if remaining_str.startswith('+'):
|
||||
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
|
||||
self.title_label.setText(tr('label.overtime_progress'))
|
||||
self.time_label.setStyleSheet("color: #ff6b6b;")
|
||||
self.time_label.setStyleSheet("color: #51CF66;")
|
||||
else:
|
||||
self.title_label.setText(tr('label.remaining'))
|
||||
self.time_label.setStyleSheet("color: #fff;")
|
||||
self.time_label.setStyleSheet("color: #E9ECEF;")
|
||||
|
||||
# 드래그 이동
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
@ -90,6 +91,17 @@ class MiniWidget(QWidget):
|
||||
def contextMenuEvent(self, event):
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
menu = QMenu(self)
|
||||
# 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
|
||||
# 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
|
||||
qss = self.parent_window.styleSheet() if self.parent_window else ''
|
||||
if not qss:
|
||||
try:
|
||||
from ui.styles import get_theme
|
||||
qss = get_theme('dark')
|
||||
except Exception:
|
||||
qss = ''
|
||||
if qss:
|
||||
menu.setStyleSheet(qss)
|
||||
open_main = menu.addAction("메인 창 열기")
|
||||
close_mini = menu.addAction("미니 위젯 닫기")
|
||||
action = menu.exec_(event.globalPos())
|
||||
|
||||
@ -12,23 +12,27 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
|
||||
QMessageBox, QGroupBox)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
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 = [
|
||||
("표준 8시간 (점심 60분)", 480, 60),
|
||||
("단축근무 7시간 30분 (점심 30분)", 450, 30),
|
||||
("단축근무 7시간 (점심 60분)", 420, 60),
|
||||
("단축근무 6시간 (점심 30분)", 360, 30),
|
||||
("반일 4시간 (점심 0분)", 240, 0),
|
||||
('onboarding.preset.standard_8h', 480, 60, 0),
|
||||
('onboarding.preset.short_7h30m', 450, 30, 0),
|
||||
('onboarding.preset.short_7h', 420, 60, 0),
|
||||
('onboarding.preset.short_6h', 360, 30, 0),
|
||||
('onboarding.preset.half_4h', 240, 0, 0),
|
||||
]
|
||||
|
||||
|
||||
class WelcomePage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("👋 환영합니다!")
|
||||
self.setTitle("환영합니다!")
|
||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||
layout = QVBoxLayout()
|
||||
intro = QLabel(
|
||||
@ -47,59 +51,83 @@ class WelcomePage(QWizardPage):
|
||||
class WorkPatternPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🕘 근무 패턴")
|
||||
self.setTitle("근무 패턴")
|
||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.button_group = QButtonGroup(self)
|
||||
for i, (label, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(label)
|
||||
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(tr(key))
|
||||
self.button_group.addButton(rb, i)
|
||||
layout.addWidget(rb)
|
||||
if i == 0:
|
||||
rb.setChecked(True)
|
||||
|
||||
# 사용자 정의
|
||||
custom_box = QGroupBox("사용자 정의")
|
||||
custom_layout = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton("직접 입력:")
|
||||
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
|
||||
custom_layout = QVBoxLayout()
|
||||
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.hours_spin = QSpinBox()
|
||||
self.hours_spin.setRange(0, 12)
|
||||
self.hours_spin.setValue(8)
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.hours_spin.setSuffix(suffix_h)
|
||||
self.minutes_spin = QSpinBox()
|
||||
self.minutes_spin.setRange(0, 59)
|
||||
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.setRange(0, 120)
|
||||
self.lunch_spin.setSingleStep(5)
|
||||
self.lunch_spin.setValue(60)
|
||||
self.lunch_spin.setPrefix("점심 ")
|
||||
self.lunch_spin.setSuffix(" 분")
|
||||
custom_layout.addWidget(self.custom_radio)
|
||||
custom_layout.addWidget(self.hours_spin)
|
||||
custom_layout.addWidget(self.minutes_spin)
|
||||
custom_layout.addWidget(self.lunch_spin)
|
||||
custom_layout.addStretch()
|
||||
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
|
||||
self.lunch_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin = QSpinBox()
|
||||
self.dinner_spin.setRange(0, 120)
|
||||
self.dinner_spin.setSingleStep(5)
|
||||
self.dinner_spin.setValue(0)
|
||||
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)
|
||||
layout.addWidget(custom_box)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_minutes(self):
|
||||
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
|
||||
idx = self.button_group.checkedId()
|
||||
if 0 <= idx < len(WORK_PRESETS):
|
||||
_, wm, lm = WORK_PRESETS[idx]
|
||||
return wm, lm
|
||||
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
|
||||
_, wm, lm, dm = WORK_PRESETS[idx]
|
||||
return wm, lm, dm
|
||||
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
|
||||
self.lunch_spin.value(),
|
||||
self.dinner_spin.value())
|
||||
|
||||
|
||||
class ClockInDetectionPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("⏰ 출근 시간 감지 방식")
|
||||
self.setTitle("출근 시간 감지 방식")
|
||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@ -111,10 +139,10 @@ class ClockInDetectionPage(QWizardPage):
|
||||
layout.addWidget(opt)
|
||||
|
||||
info = QLabel(
|
||||
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
"\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #888; padding: 8px;")
|
||||
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
layout.addStretch()
|
||||
@ -131,7 +159,7 @@ class ClockInDetectionPage(QWizardPage):
|
||||
class LeaveSalaryPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
|
||||
self.setTitle("연차 + 급여 (옵션)")
|
||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@ -190,7 +218,7 @@ class LeaveSalaryPage(QWizardPage):
|
||||
class DiscordPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("💬 Discord 알림 (선택)")
|
||||
self.setTitle("Discord 알림 (선택)")
|
||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@ -208,7 +236,7 @@ class DiscordPage(QWizardPage):
|
||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||
"3. 위 입력란에 붙여넣기"
|
||||
)
|
||||
guide.setStyleSheet("color: #888; padding: 6px;")
|
||||
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||
guide.setWordWrap(True)
|
||||
layout.addWidget(guide)
|
||||
|
||||
@ -232,6 +260,13 @@ class DiscordPage(QWizardPage):
|
||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||
return
|
||||
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)
|
||||
if ok:
|
||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||
@ -242,14 +277,14 @@ class DiscordPage(QWizardPage):
|
||||
class FinishPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🎉 준비 완료!")
|
||||
self.setTitle("준비 완료!")
|
||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
msg = QLabel(
|
||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||
"🕐 단축키:\n"
|
||||
"단축키:\n"
|
||||
" • Ctrl+O — 출퇴근 토글\n"
|
||||
" • F1 — 도움말\n"
|
||||
" • F5 — 업데이트 확인\n"
|
||||
@ -286,7 +321,7 @@ class OnboardingWizard(QWizard):
|
||||
|
||||
def accept(self):
|
||||
# 1. 근무 패턴
|
||||
wm, lm = self.work_page.selected_minutes()
|
||||
wm, lm, dm = self.work_page.selected_minutes()
|
||||
if wm < 30:
|
||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||
return
|
||||
@ -294,6 +329,7 @@ class OnboardingWizard(QWizard):
|
||||
settings = {
|
||||
'work_minutes': wm,
|
||||
'lunch_duration_minutes': lm,
|
||||
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
|
||||
'annual_leave_days': self.leave_page.leave_spin.value(),
|
||||
'annual_leave_total': self.leave_page.leave_spin.value(),
|
||||
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
|
||||
@ -301,6 +337,8 @@ class OnboardingWizard(QWizard):
|
||||
'overtime_rate': self.leave_page.rate_combo.currentData(),
|
||||
'onboarding_completed': True,
|
||||
}
|
||||
if dm > 0:
|
||||
settings['dinner_duration_minutes'] = dm
|
||||
|
||||
# 2. 출근 감지 방식
|
||||
mode = self.detect_page.detection_mode()
|
||||
|
||||
@ -38,33 +38,39 @@ class OvertimeView(QDialog):
|
||||
|
||||
# 제목 + 잔액 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("연장근무 내역")
|
||||
title = QLabel(tr('view.overtime.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
self.balance_label = QLabel("잔액: 0분")
|
||||
self.balance_label = QLabel(tr('view.overtime.balance_zero'))
|
||||
self.balance_label.setObjectName("badge_balance")
|
||||
header_layout.addWidget(self.balance_label)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 적립 내역
|
||||
earned_group = QGroupBox("💰 적립 내역")
|
||||
earned_group = QGroupBox(tr('view.overtime.earned_group'))
|
||||
earned_layout = QVBoxLayout()
|
||||
earned_layout.setSpacing(4)
|
||||
earned_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.earned_table = QTableWidget()
|
||||
self.earned_table.setColumnCount(3)
|
||||
self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"])
|
||||
self.earned_table.setHorizontalHeaderLabels([
|
||||
tr('view.overtime.col_date'),
|
||||
tr('view.overtime.col_earned'),
|
||||
tr('view.overtime.col_memo'),
|
||||
])
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
self.earned_table.setAlternatingRowColors(True)
|
||||
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
|
||||
earned_layout.addWidget(self.earned_table)
|
||||
|
||||
add_earned_button = QPushButton("➕ 수동 적립")
|
||||
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
||||
add_earned_button.clicked.connect(self.add_earned_record)
|
||||
earned_layout.addWidget(add_earned_button)
|
||||
|
||||
@ -72,14 +78,18 @@ class OvertimeView(QDialog):
|
||||
layout.addWidget(earned_group)
|
||||
|
||||
# 사용 내역
|
||||
used_group = QGroupBox("📤 사용 내역")
|
||||
used_group = QGroupBox(tr('view.overtime.used_group'))
|
||||
used_layout = QVBoxLayout()
|
||||
used_layout.setSpacing(4)
|
||||
used_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.used_table = QTableWidget()
|
||||
self.used_table.setColumnCount(3)
|
||||
self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"])
|
||||
self.used_table.setHorizontalHeaderLabels([
|
||||
tr('view.overtime.col_date'),
|
||||
tr('view.overtime.col_used'),
|
||||
tr('view.overtime.col_reason'),
|
||||
])
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
@ -90,7 +100,7 @@ class OvertimeView(QDialog):
|
||||
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
|
||||
used_layout.addWidget(self.used_table)
|
||||
|
||||
add_used_button = QPushButton("➕ 수동 사용")
|
||||
add_used_button = QPushButton(tr('view.overtime.btn_add_used'))
|
||||
add_used_button.clicked.connect(self.add_used_record)
|
||||
used_layout.addWidget(add_used_button)
|
||||
|
||||
@ -98,7 +108,7 @@ class OvertimeView(QDialog):
|
||||
layout.addWidget(used_group)
|
||||
|
||||
# 닫기 버튼
|
||||
close_button = QPushButton("닫기")
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.clicked.connect(self.close)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
@ -110,38 +120,42 @@ class OvertimeView(QDialog):
|
||||
balance = self.db.get_total_overtime_balance()
|
||||
hours = balance // 60
|
||||
minutes = balance % 60
|
||||
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
|
||||
self.balance_label.setText(tr('view.overtime.balance_fmt',
|
||||
h=hours, m=minutes, total=balance))
|
||||
|
||||
# 적립 내역 로드
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT ob.date, ob.earned_minutes,
|
||||
CASE
|
||||
WHEN ob.work_record_id IS NULL THEN '수동 추가'
|
||||
ELSE COALESCE(wr.memo, '')
|
||||
END as memo
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
|
||||
FROM overtime_bank ob
|
||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||
ORDER BY ob.date DESC
|
||||
''')
|
||||
earned_records = cursor.fetchall()
|
||||
manual_label = tr('msg.manual_added')
|
||||
|
||||
self.earned_table.setRowCount(len(earned_records))
|
||||
for i, record in enumerate(earned_records):
|
||||
date_item = QTableWidgetItem(record[0])
|
||||
date_item.setTextAlignment(Qt.AlignCenter)
|
||||
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
|
||||
|
||||
minutes = record[1]
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
|
||||
if hours > 0:
|
||||
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
|
||||
else:
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(39, 174, 96)) # 초록색
|
||||
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
|
||||
|
||||
memo_item = QTableWidgetItem(record[2] or "")
|
||||
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
||||
memo_item = QTableWidgetItem(memo_text)
|
||||
|
||||
self.earned_table.setItem(i, 0, date_item)
|
||||
self.earned_table.setItem(i, 1, time_item)
|
||||
@ -166,10 +180,13 @@ class OvertimeView(QDialog):
|
||||
minutes = record[2]
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
|
||||
if hours > 0:
|
||||
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
|
||||
else:
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
|
||||
reason_item = QTableWidgetItem(record[3] or "")
|
||||
|
||||
@ -188,7 +205,7 @@ class OvertimeView(QDialog):
|
||||
|
||||
# 컨텍스트 메뉴 생성
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction("❌ 삭제", self)
|
||||
delete_action = QAction(tr('view.overtime.menu_delete'), self)
|
||||
delete_action.triggered.connect(self.delete_used_record)
|
||||
menu.addAction(delete_action)
|
||||
|
||||
@ -213,11 +230,10 @@ class OvertimeView(QDialog):
|
||||
# 확인 메시지
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"삭제 확인",
|
||||
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
|
||||
f"날짜: {date_item.text()}\n"
|
||||
f"시간: {time_item.text()}\n"
|
||||
f"사유: {reason_item.text()}",
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.overtime.delete_confirm_body',
|
||||
date=date_item.text(), time=time_item.text(),
|
||||
reason=reason_item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -236,6 +252,46 @@ class OvertimeView(QDialog):
|
||||
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||
self.parent().update_overtime_balance()
|
||||
|
||||
def show_earned_context_menu(self, position):
|
||||
"""적립 내역 우클릭 메뉴 (삭제)."""
|
||||
selected_rows = self.earned_table.selectionModel().selectedRows()
|
||||
if not selected_rows:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction(tr('view.overtime.menu_delete'), self)
|
||||
delete_action.triggered.connect(self.delete_earned_record)
|
||||
menu.addAction(delete_action)
|
||||
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
|
||||
|
||||
def delete_earned_record(self):
|
||||
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
|
||||
selected_rows = self.earned_table.selectionModel().selectedRows()
|
||||
if not selected_rows:
|
||||
return
|
||||
row = selected_rows[0].row()
|
||||
date_item = self.earned_table.item(row, 0)
|
||||
time_item = self.earned_table.item(row, 1)
|
||||
|
||||
# 행에 저장된 overtime_bank.id
|
||||
bank_id = date_item.data(Qt.UserRole)
|
||||
if bank_id is None:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.overtime.delete_earned_confirm_body',
|
||||
date=date_item.text(), time=time_item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_overtime_earned(bank_id)
|
||||
self.load_data()
|
||||
# 부모 윈도우 잔액 업데이트
|
||||
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||
self.parent().update_overtime_balance()
|
||||
|
||||
def add_earned_record(self):
|
||||
"""수동 적립 추가"""
|
||||
dialog = AddOvertimeEarnedDialog(self, self.db)
|
||||
@ -266,7 +322,7 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("추가근무 수동 적립")
|
||||
self.setWindowTitle(tr('view.overtime.manual_earned_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -275,14 +331,14 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("추가근무 수동 적립")
|
||||
title = QLabel(tr('view.overtime.manual_earned_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 날짜
|
||||
date_layout = QHBoxLayout()
|
||||
date_label = QLabel("날짜:")
|
||||
date_label = QLabel(tr('view.overtime.field_date'))
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(60)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -294,14 +350,15 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
# 시간 (30분 단위)
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel("시간:")
|
||||
time_label = QLabel(tr('view.overtime.field_time'))
|
||||
time_label.setObjectName("field_label")
|
||||
time_label.setFixedWidth(60)
|
||||
self.hour_spin = QSpinBox()
|
||||
self.hour_spin.setRange(0, 23)
|
||||
self.hour_spin.setSuffix("시간")
|
||||
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
|
||||
self.minute_combo = QComboBox()
|
||||
self.minute_combo.addItems(["0분", "30분"])
|
||||
self.minute_combo.addItems([tr('view.overtime.minute_0'),
|
||||
tr('view.overtime.minute_30')])
|
||||
time_layout.addWidget(time_label)
|
||||
time_layout.addWidget(self.hour_spin)
|
||||
time_layout.addWidget(self.minute_combo)
|
||||
@ -309,21 +366,21 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
# 메모
|
||||
memo_layout = QHBoxLayout()
|
||||
memo_label = QLabel("메모:")
|
||||
memo_label = QLabel(tr('view.overtime.field_memo'))
|
||||
memo_label.setObjectName("field_label")
|
||||
memo_label.setFixedWidth(60)
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("선택사항")
|
||||
self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo'))
|
||||
memo_layout.addWidget(memo_label)
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
layout.addLayout(memo_layout)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save)
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(save_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
@ -335,11 +392,12 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
"""저장"""
|
||||
# 시간 계산 (30분 단위)
|
||||
hours = self.hour_spin.value()
|
||||
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
|
||||
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
|
||||
total_minutes = hours * 60 + minutes
|
||||
|
||||
if total_minutes == 0:
|
||||
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
|
||||
QMessageBox.warning(self, tr('msg.input_error.title'),
|
||||
tr('view.overtime.zero_add_error'))
|
||||
return
|
||||
|
||||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||||
@ -371,8 +429,8 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"저장 완료",
|
||||
f"{hours}시간 {minutes}분이 적립되었습니다."
|
||||
tr('msg.save_success.title'),
|
||||
tr('view.overtime.saved_earned', h=hours, m=minutes)
|
||||
)
|
||||
self.accept()
|
||||
|
||||
@ -388,7 +446,7 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("추가근무 수동 사용")
|
||||
self.setWindowTitle(tr('view.overtime.manual_used_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -398,21 +456,25 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 제목 + 잔액 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("추가근무 수동 사용")
|
||||
title = QLabel(tr('view.overtime.manual_used_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
balance = self.db.get_total_overtime_balance()
|
||||
hours = balance // 60
|
||||
minutes = balance % 60
|
||||
balance_label = QLabel(f"잔액: {hours}시간 {minutes}분")
|
||||
if hours > 0:
|
||||
balance_text = tr('view.break.duration_fmt', h=hours, m=minutes)
|
||||
else:
|
||||
balance_text = tr('view.break.duration_min_only', m=minutes)
|
||||
balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}")
|
||||
balance_label.setObjectName("badge_balance")
|
||||
header_layout.addWidget(balance_label)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 날짜
|
||||
date_layout = QHBoxLayout()
|
||||
date_label = QLabel("날짜:")
|
||||
date_label = QLabel(tr('view.overtime.field_date'))
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(60)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -424,14 +486,15 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 시간 (30분 단위)
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel("시간:")
|
||||
time_label = QLabel(tr('view.overtime.field_time'))
|
||||
time_label.setObjectName("field_label")
|
||||
time_label.setFixedWidth(60)
|
||||
self.hour_spin = QSpinBox()
|
||||
self.hour_spin.setRange(0, 23)
|
||||
self.hour_spin.setSuffix("시간")
|
||||
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
|
||||
self.minute_combo = QComboBox()
|
||||
self.minute_combo.addItems(["0분", "30분"])
|
||||
self.minute_combo.addItems([tr('view.overtime.minute_0'),
|
||||
tr('view.overtime.minute_30')])
|
||||
time_layout.addWidget(time_label)
|
||||
time_layout.addWidget(self.hour_spin)
|
||||
time_layout.addWidget(self.minute_combo)
|
||||
@ -439,21 +502,21 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
reason_layout = QHBoxLayout()
|
||||
reason_label = QLabel("사유:")
|
||||
reason_label = QLabel(tr('view.overtime.field_reason'))
|
||||
reason_label.setObjectName("field_label")
|
||||
reason_label.setFixedWidth(60)
|
||||
self.reason_edit = QLineEdit()
|
||||
self.reason_edit.setPlaceholderText("예: 개인 사유")
|
||||
self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason'))
|
||||
reason_layout.addWidget(reason_label)
|
||||
reason_layout.addWidget(self.reason_edit)
|
||||
layout.addLayout(reason_layout)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save)
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(save_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
@ -465,11 +528,12 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
"""저장"""
|
||||
# 시간 계산 (30분 단위)
|
||||
hours = self.hour_spin.value()
|
||||
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
|
||||
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
|
||||
total_minutes = hours * 60 + minutes
|
||||
|
||||
if total_minutes == 0:
|
||||
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
|
||||
QMessageBox.warning(self, tr('msg.input_error.title'),
|
||||
tr('view.overtime.zero_use_error'))
|
||||
return
|
||||
|
||||
# 잔액 확인
|
||||
@ -477,23 +541,23 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
if total_minutes > balance:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"잔액 부족",
|
||||
f"사용 가능한 시간이 부족합니다.\n\n"
|
||||
f"요청: {hours}시간 {minutes}분\n"
|
||||
f"잔액: {balance // 60}시간 {balance % 60}분"
|
||||
tr('view.overtime.balance_short_title'),
|
||||
tr('view.overtime.balance_short_body',
|
||||
req_h=hours, req_m=minutes,
|
||||
bal_h=balance // 60, bal_m=balance % 60)
|
||||
)
|
||||
return
|
||||
|
||||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||||
reason = self.reason_edit.text().strip() or "수동 사용"
|
||||
reason = self.reason_edit.text().strip() or tr('msg.manual_added')
|
||||
|
||||
# DB에 저장
|
||||
self.db.add_overtime_usage(None, total_minutes, date, reason)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"저장 완료",
|
||||
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
|
||||
tr('msg.save_success.title'),
|
||||
tr('view.overtime.saved_used', h=hours, m=minutes)
|
||||
)
|
||||
self.accept()
|
||||
|
||||
|
||||
111
ui/past_record_dialog.py
Normal file
111
ui/past_record_dialog.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""
|
||||
과거 일자 수동 추가 다이얼로그.
|
||||
|
||||
캘린더 우클릭 → "기록 추가"에서 호출. 출/퇴근 시각 + 점심/저녁 + 메모 입력.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QCheckBox, QLineEdit,
|
||||
QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class PastRecordDialog(QDialog):
|
||||
"""과거 일자 근무 기록 입력."""
|
||||
|
||||
def __init__(self, parent=None, date_str: str = ''):
|
||||
super().__init__(parent)
|
||||
self.date_str = date_str
|
||||
self.setWindowTitle(f"기록 추가 — {date_str}")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 320)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(f"{date_str} 근무 기록을 입력하세요.")
|
||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 출근
|
||||
ci_row = QHBoxLayout()
|
||||
ci_row.addWidget(QLabel("출근:"))
|
||||
self.clock_in_edit = QTimeEdit()
|
||||
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||
self.clock_in_edit.setTime(QTime(9, 0))
|
||||
ci_row.addWidget(self.clock_in_edit)
|
||||
ci_row.addStretch()
|
||||
layout.addLayout(ci_row)
|
||||
|
||||
# 퇴근
|
||||
co_row = QHBoxLayout()
|
||||
co_row.addWidget(QLabel("퇴근:"))
|
||||
self.clock_out_check = QCheckBox("입력")
|
||||
self.clock_out_check.setChecked(True)
|
||||
self.clock_out_edit = QTimeEdit()
|
||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||
self.clock_out_edit.setTime(QTime(18, 0))
|
||||
self.clock_out_check.toggled.connect(self.clock_out_edit.setEnabled)
|
||||
co_row.addWidget(self.clock_out_check)
|
||||
co_row.addWidget(self.clock_out_edit)
|
||||
co_row.addStretch()
|
||||
layout.addLayout(co_row)
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox("점심시간 포함")
|
||||
self.lunch_check.setChecked(True)
|
||||
self.dinner_check = QCheckBox("저녁시간 포함")
|
||||
meal_row.addWidget(self.lunch_check)
|
||||
meal_row.addWidget(self.dinner_check)
|
||||
meal_row.addStretch()
|
||||
layout.addLayout(meal_row)
|
||||
|
||||
# 메모
|
||||
layout.addWidget(QLabel("메모 (선택):"))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
||||
layout.addWidget(self.memo_edit)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self._validate_and_accept)
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
if self.clock_out_check.isChecked():
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co <= ci:
|
||||
QMessageBox.warning(self, "입력 오류",
|
||||
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
ci = self.clock_in_edit.time().toPyTime()
|
||||
data = {
|
||||
'clock_in': f"{ci.hour:02d}:{ci.minute:02d}:00",
|
||||
'lunch': self.lunch_check.isChecked(),
|
||||
'dinner': self.dinner_check.isChecked(),
|
||||
'memo': self.memo_edit.text().strip(),
|
||||
}
|
||||
if self.clock_out_check.isChecked():
|
||||
co = self.clock_out_edit.time().toPyTime()
|
||||
data['clock_out'] = f"{co.hour:02d}:{co.minute:02d}:00"
|
||||
return data
|
||||
199
ui/recurring_leave_dialog.py
Normal file
199
ui/recurring_leave_dialog.py
Normal file
@ -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()
|
||||
315
ui/schedule_view.py
Normal file
315
ui/schedule_view.py
Normal file
@ -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
|
||||
@ -17,11 +17,17 @@ from core.i18n import tr
|
||||
from core.settings_keys import (
|
||||
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
||||
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
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,
|
||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
||||
GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY,
|
||||
GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED,
|
||||
FONT_SCALE, HIGH_CONTRAST,
|
||||
)
|
||||
from utils.csv_exporter import CSVExporter
|
||||
from ui.leave_view import AddLeaveDialog
|
||||
@ -86,6 +92,10 @@ class SettingsView(QDialog):
|
||||
leave_group = self.create_leave_group()
|
||||
layout.addWidget(leave_group)
|
||||
|
||||
# 목표 설정 그룹
|
||||
goal_group = self.create_goal_group()
|
||||
layout.addWidget(goal_group)
|
||||
|
||||
# 공휴일 설정
|
||||
holiday_group = self.create_holiday_group()
|
||||
layout.addWidget(holiday_group)
|
||||
@ -251,13 +261,50 @@ class SettingsView(QDialog):
|
||||
self.work_preset_combo.setCurrentIndex(custom_index)
|
||||
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:
|
||||
"""알림 설정 그룹"""
|
||||
group = QGroupBox(tr('group.notification'))
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
|
||||
# 알림 체크박스들을 2열로 배치
|
||||
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||
check_row1 = QHBoxLayout()
|
||||
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
||||
self.clock_out_notification_check.setChecked(True)
|
||||
@ -268,13 +315,25 @@ class SettingsView(QDialog):
|
||||
layout.addLayout(check_row1)
|
||||
|
||||
check_row2 = QHBoxLayout()
|
||||
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
||||
self.dinner_notification_check.setChecked(True)
|
||||
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
||||
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.setChecked(True)
|
||||
check_row2.addWidget(self.overtime_notification_check)
|
||||
check_row2.addWidget(self.health_notification_check)
|
||||
layout.addLayout(check_row2)
|
||||
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
|
||||
self.health_break_notification_check.setChecked(True)
|
||||
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분 전 알림 시점 설정
|
||||
before_row = QHBoxLayout()
|
||||
@ -292,6 +351,47 @@ class SettingsView(QDialog):
|
||||
before_row.addStretch()
|
||||
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()
|
||||
time_format_label = QLabel("시간 형식:")
|
||||
@ -317,6 +417,22 @@ class SettingsView(QDialog):
|
||||
format_row.addStretch()
|
||||
layout.addLayout(format_row)
|
||||
|
||||
# 접근성: 글꼴 크기 + 고대비
|
||||
a11y_row = QHBoxLayout()
|
||||
a11y_row.addWidget(QLabel("글꼴 크기:"))
|
||||
self.font_scale_combo = QComboBox()
|
||||
self.font_scale_combo.addItem("100%", "1.0")
|
||||
self.font_scale_combo.addItem("125%", "1.25")
|
||||
self.font_scale_combo.addItem("150%", "1.5")
|
||||
self.font_scale_combo.setFixedWidth(90)
|
||||
a11y_row.addWidget(self.font_scale_combo)
|
||||
a11y_row.addSpacing(16)
|
||||
self.high_contrast_check = QCheckBox("고대비 모드")
|
||||
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
|
||||
a11y_row.addWidget(self.high_contrast_check)
|
||||
a11y_row.addStretch()
|
||||
layout.addLayout(a11y_row)
|
||||
|
||||
# 언어 선택
|
||||
from core.i18n import available_languages, language_label
|
||||
lang_row = QHBoxLayout()
|
||||
@ -398,6 +514,51 @@ class SettingsView(QDialog):
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def create_goal_group(self) -> QGroupBox:
|
||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||
group = QGroupBox("월간 목표 (0=비활성)")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
ot_label = QLabel("월 연장근무 상한:")
|
||||
ot_label.setFixedWidth(150)
|
||||
self.goal_ot_h = QSpinBox()
|
||||
self.goal_ot_h.setRange(0, 100)
|
||||
self.goal_ot_h.setSuffix(" 시간")
|
||||
self.goal_ot_h.setFixedWidth(100)
|
||||
self.goal_ot_m = QSpinBox()
|
||||
self.goal_ot_m.setRange(0, 59)
|
||||
self.goal_ot_m.setSingleStep(30)
|
||||
self.goal_ot_m.setSuffix(" 분")
|
||||
self.goal_ot_m.setFixedWidth(90)
|
||||
ot_row.addWidget(ot_label)
|
||||
ot_row.addWidget(self.goal_ot_h)
|
||||
ot_row.addWidget(self.goal_ot_m)
|
||||
ot_row.addStretch()
|
||||
layout.addLayout(ot_row)
|
||||
|
||||
# 일평균 목표
|
||||
avg_row = QHBoxLayout()
|
||||
avg_label = QLabel("일 평균 근무 목표:")
|
||||
avg_label.setFixedWidth(150)
|
||||
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
||||
self.goal_avg.setRange(0, 24)
|
||||
self.goal_avg.setSuffix(" 시간")
|
||||
self.goal_avg.setFixedWidth(100)
|
||||
avg_row.addWidget(avg_label)
|
||||
avg_row.addWidget(self.goal_avg)
|
||||
avg_row.addStretch()
|
||||
layout.addLayout(avg_row)
|
||||
|
||||
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
|
||||
note.setObjectName("note_text")
|
||||
layout.addWidget(note)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def create_leave_group(self) -> QGroupBox:
|
||||
"""휴가 설정 그룹"""
|
||||
group = QGroupBox(tr('group.leave'))
|
||||
@ -510,27 +671,39 @@ class SettingsView(QDialog):
|
||||
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
||||
|
||||
def add_korean_holidays_auto(self):
|
||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가"""
|
||||
current_year = datetime.now().year
|
||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||
|
||||
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(
|
||||
self,
|
||||
"한국 공휴일 자동 추가",
|
||||
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
"포함:\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||
"• 정부 지정 대체·임시공휴일\n\n"
|
||||
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
|
||||
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
||||
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
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:
|
||||
# 패키지 미설치 시 고정 공휴일로 폴백
|
||||
self.db.add_korean_holidays(current_year)
|
||||
if include_next:
|
||||
self.db.add_korean_holidays(current_year + 1)
|
||||
self.update_holiday_count()
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
@ -690,6 +863,19 @@ class SettingsView(QDialog):
|
||||
|
||||
layout.addLayout(export_layout)
|
||||
|
||||
# CSV 가져오기
|
||||
import_layout = QHBoxLayout()
|
||||
import_btn = QPushButton("CSV 가져오기")
|
||||
import_btn.setObjectName("btn_small")
|
||||
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
||||
import_btn.clicked.connect(self._import_csv)
|
||||
import_layout.addWidget(import_btn)
|
||||
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
|
||||
import_label.setObjectName("note_text")
|
||||
import_layout.addWidget(import_label)
|
||||
import_layout.addStretch()
|
||||
layout.addLayout(import_layout)
|
||||
|
||||
# DB 경로 설정 (클라우드 동기화 가능)
|
||||
db_path_layout = QHBoxLayout()
|
||||
db_path_label = QLabel("DB 경로:")
|
||||
@ -711,6 +897,22 @@ class SettingsView(QDialog):
|
||||
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
||||
layout.addWidget(self.auto_break_check)
|
||||
|
||||
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
||||
feedback_layout = QHBoxLayout()
|
||||
feedback_label = QLabel("Gitea 피드백:")
|
||||
feedback_label.setFixedWidth(80)
|
||||
self.gitea_token_edit = QLineEdit()
|
||||
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
||||
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
|
||||
feedback_layout.addWidget(feedback_label)
|
||||
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
||||
layout.addLayout(feedback_layout)
|
||||
|
||||
self.gitea_feedback_enabled_check = QCheckBox(
|
||||
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
||||
)
|
||||
layout.addWidget(self.gitea_feedback_enabled_check)
|
||||
|
||||
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
||||
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
||||
self.clock_in_unlock_check.setToolTip(
|
||||
@ -808,17 +1010,55 @@ class SettingsView(QDialog):
|
||||
if hasattr(self, 'clock_in_unlock_check'):
|
||||
self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False))
|
||||
|
||||
# 목표
|
||||
if hasattr(self, 'goal_ot_h'):
|
||||
ot_min = int(settings.get(GOAL_OVERTIME_MAX_MONTHLY, 0) or 0)
|
||||
self.goal_ot_h.setValue(ot_min // 60)
|
||||
self.goal_ot_m.setValue(ot_min % 60)
|
||||
if hasattr(self, 'goal_avg'):
|
||||
self.goal_avg.setValue(int(float(settings.get(GOAL_AVG_HOURS_DAILY, 0) or 0)))
|
||||
|
||||
# Gitea 피드백
|
||||
if hasattr(self, 'gitea_token_edit'):
|
||||
self.gitea_token_edit.setText(self.db.get_setting(GITEA_FEEDBACK_TOKEN, '') or '')
|
||||
if hasattr(self, 'gitea_feedback_enabled_check'):
|
||||
self.gitea_feedback_enabled_check.setChecked(
|
||||
settings.get(GITEA_FEEDBACK_ENABLED, False)
|
||||
)
|
||||
|
||||
# 접근성
|
||||
if hasattr(self, 'font_scale_combo'):
|
||||
scale = str(settings.get(FONT_SCALE, '1.0'))
|
||||
idx = self.font_scale_combo.findData(scale)
|
||||
if idx >= 0:
|
||||
self.font_scale_combo.setCurrentIndex(idx)
|
||||
if hasattr(self, 'high_contrast_check'):
|
||||
self.high_contrast_check.setChecked(settings.get(HIGH_CONTRAST, False))
|
||||
|
||||
# 알림
|
||||
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
|
||||
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
||||
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.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'):
|
||||
try:
|
||||
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
||||
except (ValueError, TypeError):
|
||||
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')
|
||||
if isinstance(time_format, int):
|
||||
@ -828,7 +1068,7 @@ class SettingsView(QDialog):
|
||||
self.time_format_combo.setCurrentIndex(index)
|
||||
|
||||
# 테마
|
||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
|
||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
|
||||
|
||||
# 언어 선택 적용
|
||||
if hasattr(self, 'language_combo'):
|
||||
@ -862,6 +1102,49 @@ class SettingsView(QDialog):
|
||||
# 남은 연차 계산
|
||||
self.update_remaining_leave()
|
||||
|
||||
def _import_csv(self):
|
||||
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "CSV 가져오기",
|
||||
os.path.expanduser("~"),
|
||||
"CSV files (*.csv);;All files (*.*)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
rows = parse_csv(path)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
QMessageBox.critical(self, "파싱 실패", str(e))
|
||||
return
|
||||
|
||||
if not rows:
|
||||
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"충돌 처리",
|
||||
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
||||
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
||||
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||
)
|
||||
if reply == QMessageBox.Cancel:
|
||||
return
|
||||
policy = 'overwrite' if reply == QMessageBox.Yes else 'skip'
|
||||
|
||||
try:
|
||||
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "가져오기 실패", str(e))
|
||||
return
|
||||
|
||||
QMessageBox.information(
|
||||
self, "완료",
|
||||
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
||||
)
|
||||
|
||||
def _check_updates(self):
|
||||
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
|
||||
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
|
||||
@ -902,15 +1185,38 @@ class SettingsView(QDialog):
|
||||
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
||||
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
||||
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(),
|
||||
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
||||
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
||||
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'):
|
||||
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
||||
if hasattr(self, 'clock_in_unlock_check'):
|
||||
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
|
||||
if hasattr(self, 'goal_ot_h'):
|
||||
settings[GOAL_OVERTIME_MAX_MONTHLY] = self.goal_ot_h.value() * 60 + self.goal_ot_m.value()
|
||||
if hasattr(self, 'goal_avg'):
|
||||
settings[GOAL_AVG_HOURS_DAILY] = self.goal_avg.value()
|
||||
if hasattr(self, 'gitea_token_edit'):
|
||||
self.db.set_setting(GITEA_FEEDBACK_TOKEN, self.gitea_token_edit.text().strip())
|
||||
if hasattr(self, 'gitea_feedback_enabled_check'):
|
||||
settings[GITEA_FEEDBACK_ENABLED] = self.gitea_feedback_enabled_check.isChecked()
|
||||
if hasattr(self, 'font_scale_combo'):
|
||||
settings[FONT_SCALE] = self.font_scale_combo.currentData()
|
||||
if hasattr(self, 'high_contrast_check'):
|
||||
settings[HIGH_CONTRAST] = self.high_contrast_check.isChecked()
|
||||
if hasattr(self, 'language_combo'):
|
||||
settings[LANGUAGE] = self.language_combo.currentData()
|
||||
|
||||
@ -937,16 +1243,18 @@ class SettingsView(QDialog):
|
||||
if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
|
||||
self.parent_window.reload_settings()
|
||||
|
||||
# 언어 변경 감지 → 재시작 제안
|
||||
# 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장
|
||||
if hasattr(self, 'language_combo'):
|
||||
from core.i18n import get_language
|
||||
from ui.i18n_runtime import set_language_and_retranslate
|
||||
new_lang = self.language_combo.currentData()
|
||||
if new_lang and new_lang != get_language():
|
||||
set_language_and_retranslate(new_lang)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"재시작 필요 / Restart required",
|
||||
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n"
|
||||
"Restart now to fully apply the language change?",
|
||||
"재시작 / Restart",
|
||||
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
|
||||
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -1064,7 +1372,7 @@ class SettingsView(QDialog):
|
||||
|
||||
if filename:
|
||||
try:
|
||||
saved_path = CSVExporter.export_work_records(records, filename)
|
||||
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"내보내기 완료",
|
||||
|
||||
250
ui/stats_view.py
250
ui/stats_view.py
@ -2,7 +2,8 @@
|
||||
통계 대시보드 - 주간/월간 통계
|
||||
"""
|
||||
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 datetime import datetime, timedelta
|
||||
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.i18n import tr
|
||||
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, tc,
|
||||
)
|
||||
|
||||
|
||||
class StatsView(QDialog):
|
||||
@ -22,72 +27,91 @@ class StatsView(QDialog):
|
||||
self.db = db if db else Database()
|
||||
self.init_ui()
|
||||
self.load_stats()
|
||||
apply_dark_titlebar(self)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('window.stats'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(420, 350)
|
||||
self.setMinimumSize(720, 600)
|
||||
self.resize(900, 720)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 14)
|
||||
|
||||
title = QLabel(tr('stats.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
# 다크 톤 타이틀
|
||||
title = QLabel(f"{tr('stats.title')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
|
||||
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
|
||||
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.setMinimumWidth(100)
|
||||
close_button.setStyleSheet(button_qss('ghost'))
|
||||
close_button.clicked.connect(self.close)
|
||||
layout.addWidget(close_button)
|
||||
btn_row.addWidget(close_button)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
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:
|
||||
"""주간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
summary_group = QGroupBox(tr('stats.weekly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||
theme='blue', icon='clock')
|
||||
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||
theme='cyan', icon='calendar')
|
||||
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||
theme='green', icon='chart')
|
||||
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
||||
theme='gold', icon='flame')
|
||||
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
|
||||
self.weekly_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.weekly_chart, 1)
|
||||
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||
theme='gray', icon='trending-up')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@ -95,46 +119,49 @@ class StatsView(QDialog):
|
||||
def create_monthly_tab(self) -> QWidget:
|
||||
"""월간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(10)
|
||||
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='clock')
|
||||
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||
theme='cyan', icon='calendar')
|
||||
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||
theme='green', icon='chart')
|
||||
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
||||
theme='gold', icon='flame')
|
||||
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.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
|
||||
self.salary_label.setStyleSheet(
|
||||
f"background: rgba(81, 207, 102, 0.12); "
|
||||
f"border: 1px solid {tc('green')}; border-radius: 8px; "
|
||||
f"color: {tc('green')}; font-weight: bold; "
|
||||
f"padding: 10px 14px; font-size: 11pt;"
|
||||
)
|
||||
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)
|
||||
|
||||
# 목표 진행률
|
||||
from ui.goal_widget import GoalWidget
|
||||
self.goal_widget = GoalWidget(self.db)
|
||||
layout.addWidget(self.goal_widget)
|
||||
|
||||
# 월간 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.monthly_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.monthly_chart, 1)
|
||||
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||
theme='gray', icon='chart')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@ -142,42 +169,71 @@ class StatsView(QDialog):
|
||||
def create_pattern_tab(self) -> QWidget:
|
||||
"""패턴 분석 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
pattern_group = QGroupBox(tr('stats.pattern_insights'))
|
||||
pattern_layout = QVBoxLayout()
|
||||
pattern_layout.setSpacing(4)
|
||||
pattern_layout.setContentsMargins(8, 20, 8, 6)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
# 패턴 텍스트 카드
|
||||
self.pattern_text = QLabel(tr('stats.analyzing'))
|
||||
self.pattern_text.setWordWrap(True)
|
||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
self.pattern_text.setStyleSheet(
|
||||
f"font-size: 11pt; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||
theme='cyan', icon='search'))
|
||||
|
||||
pattern_layout.addWidget(self.pattern_text)
|
||||
# 출근 시각 분포 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.clock_in_chart = make_chart_widget(widget)
|
||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||
theme='gray', icon='clock'), 1)
|
||||
|
||||
pattern_group.setLayout(pattern_layout)
|
||||
layout.addWidget(pattern_group)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
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):
|
||||
"""통계 로드"""
|
||||
# 주간 통계
|
||||
weekly_stats = self.db.get_weekly_stats()
|
||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 0)}일")
|
||||
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
|
||||
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
|
||||
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_hours = 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
|
||||
@ -187,26 +243,29 @@ class StatsView(QDialog):
|
||||
(today - _td(days=6)).isoformat(), today.isoformat()
|
||||
)
|
||||
if hasattr(self, 'weekly_chart'):
|
||||
# 도전과제 chart_hover 감지를 위해 db 참조 attach
|
||||
self.weekly_chart._achievement_db = self.db
|
||||
draw_daily_hours(self.weekly_chart, week_records)
|
||||
|
||||
# 월간 통계
|
||||
now = datetime.now()
|
||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
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
|
||||
self.monthly_work_days.setText(f"{work_days}일")
|
||||
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
||||
|
||||
if work_days > 0:
|
||||
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:
|
||||
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_hours = 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'):
|
||||
@ -215,6 +274,10 @@ class StatsView(QDialog):
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
self._update_salary_estimate(monthly_stats.get('records', []))
|
||||
|
||||
# 목표 진행률
|
||||
if hasattr(self, 'goal_widget'):
|
||||
self.goal_widget.refresh()
|
||||
|
||||
# 패턴 분석
|
||||
self.analyze_patterns(monthly_stats.get('records', []))
|
||||
|
||||
@ -244,6 +307,11 @@ class StatsView(QDialog):
|
||||
|
||||
def analyze_patterns(self, records):
|
||||
"""패턴 분석"""
|
||||
# 출근 분포 차트는 데이터 유무와 무관하게 갱신 (빈 차트 표시)
|
||||
if hasattr(self, 'clock_in_chart'):
|
||||
from ui.chart_widget import draw_clock_in_distribution
|
||||
draw_clock_in_distribution(self.clock_in_chart, records or [])
|
||||
|
||||
if not records:
|
||||
self.pattern_text.setText(tr('stats.no_data'))
|
||||
return
|
||||
|
||||
282
ui/styles.py
282
ui/styles.py
@ -33,8 +33,8 @@ def _ensure_icons():
|
||||
for name, color_hex, points in [
|
||||
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||
]:
|
||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||
if not os.path.exists(path):
|
||||
@ -78,6 +78,9 @@ LIGHT_COLORS = {
|
||||
'bg_primary': '#F5F5F7',
|
||||
'bg_secondary': '#FFFFFF',
|
||||
'bg_tertiary': '#EDEDF0',
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#E2E3E7',
|
||||
'surface_pressed': '#D5D6DB',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#1A1A2E',
|
||||
'text_secondary': '#4A4A68',
|
||||
@ -85,9 +88,15 @@ LIGHT_COLORS = {
|
||||
'text_inverse': '#FFFFFF',
|
||||
# 액센트
|
||||
'accent_primary': '#3B82F6',
|
||||
'accent_primary_hover': '#2F74EE',
|
||||
'accent_primary_pressed': '#2563EB',
|
||||
'accent_success': '#10B981',
|
||||
'accent_success_hover': '#0EA372',
|
||||
'accent_success_pressed': '#0C8F63',
|
||||
'accent_warning': '#F59E0B',
|
||||
'accent_danger': '#EF4444',
|
||||
'accent_danger_hover': '#DC2626',
|
||||
'accent_danger_pressed': '#B91C1C',
|
||||
# 테두리
|
||||
'border_subtle': '#E5E7EB',
|
||||
'border_default': '#D1D5DB',
|
||||
@ -120,40 +129,58 @@ LIGHT_COLORS = {
|
||||
}
|
||||
|
||||
DARK_COLORS = {
|
||||
'bg_primary': '#111118',
|
||||
'bg_secondary': '#1C1C2E',
|
||||
'bg_tertiary': '#282842',
|
||||
'text_primary': '#ECECF4',
|
||||
'text_secondary': '#B0B0C8',
|
||||
'text_tertiary': '#808098',
|
||||
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
|
||||
'bg_primary': '#1A1B1E', # 앱 배경
|
||||
'bg_secondary': '#25262B', # 카드 / 패널
|
||||
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#34363D',
|
||||
'surface_pressed': '#3A3D44',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#E9ECEF',
|
||||
'text_secondary': '#909296',
|
||||
'text_tertiary': '#6C6E73',
|
||||
'text_inverse': '#FFFFFF',
|
||||
'accent_primary': '#6B9EFF',
|
||||
'accent_success': '#4ADE80',
|
||||
'accent_warning': '#FCD34D',
|
||||
'accent_danger': '#FB7185',
|
||||
'border_subtle': '#32324E',
|
||||
'border_default': '#44446A',
|
||||
'border_focus': '#6B9EFF',
|
||||
'badge_overtime_bg': '#3D2008',
|
||||
'badge_overtime_text': '#FDE68A',
|
||||
'badge_leave_bg': '#1E2D5F',
|
||||
'badge_leave_text': '#A5D0FE',
|
||||
'badge_total_bg': '#0A3324',
|
||||
'badge_total_text': '#86EFAC',
|
||||
'progress_bg': '#282842',
|
||||
'progress_start': '#6B9EFF',
|
||||
'progress_end': '#4ADE80',
|
||||
'status_overtime': '#FB7185',
|
||||
'status_warning': '#FCD34D',
|
||||
'status_normal': '#4ADE80',
|
||||
'status_break_active': '#FB7185',
|
||||
'status_break_idle': '#808098',
|
||||
'cal_normal': '#1A4D3A',
|
||||
'cal_overtime': '#5C1A1A',
|
||||
'cal_incomplete': '#5C3A10',
|
||||
'scrollbar_bg': '#111118',
|
||||
'scrollbar_handle': '#44446A',
|
||||
'scrollbar_hover': '#5A5A88',
|
||||
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||
'accent_primary': '#4DABF7',
|
||||
'accent_primary_hover': '#69B6F8',
|
||||
'accent_primary_pressed': '#3D97E0',
|
||||
'accent_success': '#51CF66',
|
||||
'accent_success_hover': '#69DB7C',
|
||||
'accent_success_pressed': '#43B85A',
|
||||
'accent_warning': '#FAB005',
|
||||
'accent_danger': '#FA5252',
|
||||
'accent_danger_hover': '#FF6B6B',
|
||||
'accent_danger_pressed': '#E64545',
|
||||
# 테두리
|
||||
'border_subtle': '#2C2E33',
|
||||
'border_default': '#373A40',
|
||||
'border_focus': '#4DABF7',
|
||||
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
|
||||
'badge_overtime_bg': '#2C2E33',
|
||||
'badge_overtime_text': '#FAB005',
|
||||
'badge_leave_bg': '#2C2E33',
|
||||
'badge_leave_text': '#4DABF7',
|
||||
'badge_total_bg': '#2C2E33',
|
||||
'badge_total_text': '#51CF66',
|
||||
# 프로그레스 — 단일 accent 솔리드
|
||||
'progress_bg': '#2C2E33',
|
||||
'progress_start': '#4DABF7',
|
||||
'progress_end': '#4DABF7',
|
||||
# 상태 색상 (동적 텍스트 피드백)
|
||||
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
|
||||
'status_warning': '#FAB005',
|
||||
'status_normal': '#51CF66',
|
||||
'status_break_active': '#FA5252',
|
||||
'status_break_idle': '#6C6E73',
|
||||
# 캘린더 날짜 배경 — 미묘한 다크 틴트
|
||||
'cal_normal': '#1E3A2A',
|
||||
'cal_overtime': '#3A2122',
|
||||
'cal_incomplete': '#3A331E',
|
||||
# 스크롤바
|
||||
'scrollbar_bg': '#1A1B1E',
|
||||
'scrollbar_handle': '#373A40',
|
||||
'scrollbar_hover': '#4DABF7',
|
||||
}
|
||||
|
||||
|
||||
@ -192,7 +219,7 @@ QMainWindow, QDialog {{
|
||||
}}
|
||||
|
||||
QWidget {{
|
||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
||||
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@ -206,14 +233,14 @@ QWidget#central_widget {{
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QLabel#app_title {{
|
||||
font-size: 12pt;
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#date_label {{
|
||||
font-size: 9pt;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_secondary']};
|
||||
padding-bottom: 4px;
|
||||
}}
|
||||
@ -221,7 +248,7 @@ QLabel#date_label {{
|
||||
QLabel#section_title {{
|
||||
font-size: 9.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QLabel#field_label {{
|
||||
@ -229,29 +256,30 @@ QLabel#field_label {{
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||
QLabel#time_value {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 11pt;
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||
QLabel#time_display {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 22pt;
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
}}
|
||||
|
||||
QLabel#expected_time {{
|
||||
font-size: 10pt;
|
||||
font-size: 11.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 4px;
|
||||
color: {c['text_secondary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#dialog_title {{
|
||||
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_overtime_bg']};
|
||||
color: {c['badge_overtime_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_leave {{
|
||||
@ -306,7 +334,7 @@ QLabel#badge_leave {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_leave_bg']};
|
||||
color: {c['badge_leave_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_total {{
|
||||
@ -317,7 +345,7 @@ QLabel#badge_total {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_balance {{
|
||||
@ -326,7 +354,7 @@ QLabel#badge_balance {{
|
||||
padding: 10px;
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_success {{
|
||||
@ -335,7 +363,7 @@ QLabel#badge_success {{
|
||||
padding: 8px;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -355,9 +383,9 @@ QLabel#separator {{
|
||||
QGroupBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
padding-top: 28px;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
@ -378,52 +406,55 @@ QGroupBox::title {{
|
||||
버튼
|
||||
════════════════════════════════════════ */
|
||||
|
||||
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||
QPushButton {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
background: {c['border_default']};
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background: {c['border_subtle']};
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background: {c['bg_tertiary']};
|
||||
background: {c['bg_secondary']};
|
||||
color: {c['text_tertiary']};
|
||||
border-color: {c['border_subtle']};
|
||||
}}
|
||||
|
||||
QPushButton:checked {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
border-color: {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 (primary action) */
|
||||
QPushButton:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||
QPushButton#clock_out_button {{
|
||||
background: {c['accent_success']};
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
padding: 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:hover {{
|
||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:pressed {{
|
||||
background: {'#0C8F63' if not is_dark else '#28A87A'};
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 주요 액션 버튼 */
|
||||
@ -435,11 +466,11 @@ QPushButton#btn_primary {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:hover {{
|
||||
background: {c['accent_primary']}DD;
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:pressed {{
|
||||
background: {c['accent_primary']}BB;
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 위험 버튼 */
|
||||
@ -450,11 +481,11 @@ QPushButton#btn_danger {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:hover {{
|
||||
background: {c['accent_danger']}DD;
|
||||
background: {c['accent_danger_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:pressed {{
|
||||
background: {c['accent_danger']}BB;
|
||||
background: {c['accent_danger_pressed']};
|
||||
}}
|
||||
|
||||
/* 성공 버튼 */
|
||||
@ -465,25 +496,44 @@ QPushButton#btn_success {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:hover {{
|
||||
background: {c['accent_success']}DD;
|
||||
background: {c['accent_success_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:pressed {{
|
||||
background: {c['accent_success']}BB;
|
||||
background: {c['accent_success_pressed']};
|
||||
}}
|
||||
|
||||
/* 작은 버튼 */
|
||||
/* 작은 버튼 — 미묘한 표면 */
|
||||
QPushButton#btn_small {{
|
||||
font-size: 8.5pt;
|
||||
padding: 5px 10px;
|
||||
padding: 6px 10px;
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:hover {{
|
||||
background: {c['accent_primary']}20;
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:pressed {{
|
||||
background: {c['accent_primary']}35;
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */
|
||||
QPushButton#nav_btn {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 4px;
|
||||
font-size: 8.5pt;
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:hover {{
|
||||
background: {c['surface_hover']};
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:pressed {{
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
|
||||
QLineEdit, QTextEdit, QComboBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
|
||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
min-height: 20px;
|
||||
}}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 7px;
|
||||
}}
|
||||
|
||||
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 27px 5px 7px;
|
||||
border: 1px solid {c['border_focus']};
|
||||
}}
|
||||
|
||||
/* 비활성 입력 필드 */
|
||||
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||
QDateEdit::up-button, QTimeEdit::up-button {{
|
||||
subcontrol-position: top right;
|
||||
border-top-right-radius: 4px;
|
||||
border-top-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||
subcontrol-position: bottom right;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
|
||||
QProgressBar {{
|
||||
border: none;
|
||||
background: {c['progress_bg']};
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
max-height: 6px;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
font-size: 0px;
|
||||
}}
|
||||
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
||||
border-radius: 4px;
|
||||
background: {c['progress_start']};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -648,7 +694,7 @@ QProgressBar::chunk {{
|
||||
QTableWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
gridline-color: {c['border_subtle']};
|
||||
color: {c['text_primary']};
|
||||
font-size: 9pt;
|
||||
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
|
||||
background: {c['bg_tertiary']};
|
||||
}}
|
||||
|
||||
/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
|
||||
QHeaderView {{
|
||||
background: {c['bg_secondary']};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QHeaderView::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_secondary']};
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
font-weight: bold;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QHeaderView::section:horizontal {{
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 세로헤더(행번호) — accent 밑줄 없이 미묘하게 */
|
||||
QHeaderView::section:vertical {{
|
||||
border-right: 1px solid {c['border_subtle']};
|
||||
color: {c['text_tertiary']};
|
||||
font-weight: normal;
|
||||
padding: 4px 8px;
|
||||
}}
|
||||
|
||||
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
|
||||
QTableView QTableCornerButton::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
탭 위젯
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QTabWidget::pane {{
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: {c['bg_secondary']};
|
||||
top: -1px;
|
||||
}}
|
||||
@ -694,8 +764,8 @@ QTabBar::tab {{
|
||||
padding: 8px 20px;
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 2px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
||||
QCalendarWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
@ -902,7 +972,7 @@ QToolTip {{
|
||||
QMenu {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@ -916,6 +986,16 @@ QMenu::item:selected {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
}}
|
||||
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background: {c['border_subtle']};
|
||||
margin: 4px 8px;
|
||||
}}
|
||||
|
||||
QMenu::icon {{
|
||||
padding-left: 8px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -16,12 +16,12 @@ class TodaySummaryCard(QFrame):
|
||||
self.setObjectName("today_summary_card")
|
||||
self.setStyleSheet("""
|
||||
QFrame#today_summary_card {
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||
background-color: rgba(81, 207, 102, 0.08);
|
||||
border: 1px solid rgba(81, 207, 102, 0.40);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
QLabel { padding: 1px; }
|
||||
QLabel { padding: 1px; background: transparent; border: none; }
|
||||
""")
|
||||
self.setVisible(False)
|
||||
|
||||
@ -30,7 +30,7 @@ class TodaySummaryCard(QFrame):
|
||||
layout.setSpacing(2)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("📋 오늘의 요약")
|
||||
title = QLabel("오늘의 요약")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@ -43,9 +43,9 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
self.total_label = QLabel("")
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
layout.addWidget(self.total_label)
|
||||
layout.addWidget(self.detail_label)
|
||||
@ -55,24 +55,28 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
def show_summary(self, total_hours: float, lunch_minutes: 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:
|
||||
total_hours: 총 근무시간(시간)
|
||||
lunch_minutes: 점심 시간(분)
|
||||
break_minutes: 외출 시간(분)
|
||||
break_minutes: 외출 시간(분, 식사 제외)
|
||||
overtime_actual: 실제 연장근무(분)
|
||||
overtime_earned: 적립 연장근무(분)
|
||||
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
||||
dinner_minutes: 저녁 시간(분), 0이면 표시 안 함
|
||||
"""
|
||||
h = int(total_hours)
|
||||
m = int((total_hours - h) * 60)
|
||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
||||
self.total_label.setText(f"총 근무: {h}시간 {m}분")
|
||||
|
||||
details = []
|
||||
if lunch_minutes > 0:
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
if dinner_minutes > 0:
|
||||
details.append(f"저녁 {dinner_minutes}분")
|
||||
if break_minutes > 0:
|
||||
details.append(f"외출 {break_minutes}분")
|
||||
if overtime_actual > 0:
|
||||
@ -81,7 +85,7 @@ class TodaySummaryCard(QFrame):
|
||||
self.detail_label.setVisible(bool(details))
|
||||
|
||||
if salary_text:
|
||||
self.salary_label.setText(f"💰 {salary_text}")
|
||||
self.salary_label.setText(f"{salary_text}")
|
||||
self.salary_label.setVisible(True)
|
||||
else:
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
103
updater.py
103
updater.py
@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
|
||||
3. new_exe → target_exe 이동
|
||||
4. target_exe 재실행 + 업데이터 자가 종료
|
||||
실패 시 .bak 복원
|
||||
|
||||
빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임.
|
||||
모든 진단 출력은 ~/.clockout_logs/updater.log 로 append.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
@ -21,9 +24,30 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
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:
|
||||
"""Windows에서 PID 실행 중인지 확인."""
|
||||
if sys.platform != 'win32':
|
||||
@ -62,28 +86,65 @@ def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
|
||||
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 위치로 이동.
|
||||
|
||||
Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
|
||||
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
|
||||
있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).
|
||||
|
||||
Returns:
|
||||
백업 파일 경로 (롤백용). 실패 시 None.
|
||||
백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
|
||||
"""
|
||||
backup = target_path.with_suffix(target_path.suffix + '.bak')
|
||||
try:
|
||||
last_err: Exception | None = None
|
||||
|
||||
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
|
||||
if backup.exists():
|
||||
try:
|
||||
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))
|
||||
break
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
# 모든 재시도 실패
|
||||
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||
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:
|
||||
print(f"[updater] replace failed: {e}", file=sys.stderr)
|
||||
# 롤백 시도
|
||||
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))
|
||||
except OSError:
|
||||
pass
|
||||
_log("[updater] rolled back from backup")
|
||||
except OSError as e:
|
||||
_log(f"[updater] rollback also failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@ -91,13 +152,20 @@ def launch(exe_path: Path) -> bool:
|
||||
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
||||
try:
|
||||
if sys.platform == 'win32':
|
||||
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
|
||||
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
|
||||
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:
|
||||
subprocess.Popen([str(exe_path)], close_fds=True)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"[updater] launch failed: {e}", file=sys.stderr)
|
||||
_log(f"[updater] launch failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@ -109,29 +177,34 @@ def main() -> int:
|
||||
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
||||
args = parser.parse_args()
|
||||
|
||||
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
|
||||
|
||||
new_exe = Path(args.new).resolve()
|
||||
target_exe = Path(args.target).resolve()
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# Windows 파일 핸들 해제 시간 여유
|
||||
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
||||
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
|
||||
time.sleep(0.5)
|
||||
|
||||
backup = replace_file(new_exe, target_exe)
|
||||
if backup is None:
|
||||
_log("[updater] replace_file failed — aborting")
|
||||
return 4
|
||||
|
||||
if args.no_launch:
|
||||
_log("[updater] --no-launch set, exiting after replace")
|
||||
return 0
|
||||
|
||||
if not launch(target_exe):
|
||||
# 시작 실패 시 롤백
|
||||
_log("[updater] launch failed — rolling back")
|
||||
try:
|
||||
target_exe.unlink()
|
||||
shutil.move(str(backup), str(target_exe))
|
||||
@ -140,7 +213,7 @@ def main() -> int:
|
||||
pass
|
||||
return 5
|
||||
|
||||
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
|
||||
_log("[updater] update complete, new app launched")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ exe = EXE(
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
|
||||
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
||||
@ -71,5 +71,7 @@ def _rotate(directory: Path, keep: int) -> None:
|
||||
for old in files[keep:]:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
# 회전 실패 시 로그만 — 다음 실행에 재시도. 누적 시 디스크 압박 가능.
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"backup rotation failed for {old}: {e}")
|
||||
|
||||
228
utils/crash_handler.py
Normal file
228
utils/crash_handler.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""
|
||||
전역 예외 후킹 + Gitea Issues 자동 보고.
|
||||
|
||||
sys.excepthook을 등록해 처리되지 않은 예외를 가로채:
|
||||
1. crash_log 테이블에 저장
|
||||
2. 사용자에게 다이얼로그로 알림 + "Gitea에 보고" / "복사" 옵션
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# 자체 호스팅 Gitea (updater_client와 동일)
|
||||
GITEA_API = 'https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator'
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
|
||||
|
||||
|
||||
def install_global_handler(db, app_version: str = 'unknown') -> None:
|
||||
"""sys.excepthook 등록. db는 crash_log 저장용.
|
||||
|
||||
각 단계(log → dialog → 파일 fallback → original hook)는 모두 독립 try로 감싸
|
||||
하나가 실패해도 다음 단계가 동작. 가장 최후 fallback은 stderr + 로컬 파일.
|
||||
"""
|
||||
original = sys.excepthook
|
||||
|
||||
def hook(exc_type, exc_value, exc_tb):
|
||||
if exc_type is KeyboardInterrupt:
|
||||
original(exc_type, exc_value, exc_tb)
|
||||
return
|
||||
|
||||
# 1단계: traceback 직렬화 (실패하면 minimal fallback)
|
||||
try:
|
||||
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
except Exception:
|
||||
tb_str = f"{exc_type}: {exc_value} (traceback formatting failed)"
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""crash_log 테이블에 기록."""
|
||||
try:
|
||||
conn = db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
# 테이블 자동 생성 (마이그레이션 누락 대비)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS crash_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
exception_type TEXT,
|
||||
message TEXT,
|
||||
traceback TEXT,
|
||||
app_version TEXT,
|
||||
reported BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO crash_log (exception_type, message, traceback, app_version)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (exc_type, message, tb, version))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _show_dialog(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||
"""크래시 알림 + Gitea 보고/복사 버튼."""
|
||||
try:
|
||||
from PyQt5.QtWidgets import (QApplication, QMessageBox, QDialog,
|
||||
QVBoxLayout, QLabel, QTextEdit,
|
||||
QHBoxLayout, QPushButton, QLineEdit)
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
|
||||
dlg = QDialog()
|
||||
dlg.setWindowTitle("⚠️ 오류 발생")
|
||||
dlg.setMinimumSize(560, 420)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
title = QLabel(f"<b>{exc_type}</b>: {message[:200]}")
|
||||
title.setWordWrap(True)
|
||||
layout.addWidget(title)
|
||||
|
||||
layout.addWidget(QLabel("무엇을 하다가 발생했나요? (선택)"))
|
||||
user_note = QLineEdit()
|
||||
user_note.setPlaceholderText("예: 출근 버튼 누른 직후")
|
||||
layout.addWidget(user_note)
|
||||
|
||||
layout.addWidget(QLabel("기술 정보 (자동 보고에 포함):"))
|
||||
tb_view = QTextEdit()
|
||||
tb_view.setReadOnly(True)
|
||||
tb_view.setFont(__import__('PyQt5.QtGui', fromlist=['QFont']).QFont('Consolas', 9))
|
||||
tb_view.setText(tb[-3000:]) # 너무 긴 traceback 제한
|
||||
layout.addWidget(tb_view, 1)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
cancel_btn = QPushButton("닫기")
|
||||
copy_btn = QPushButton("📋 복사")
|
||||
report_btn = QPushButton("📤 Gitea에 보고")
|
||||
|
||||
has_token = bool(db.get_setting('gitea_feedback_token', '') or '')
|
||||
enabled = db.get_setting('gitea_feedback_enabled', 'false').lower() == 'true'
|
||||
if not (has_token and enabled):
|
||||
report_btn.setEnabled(False)
|
||||
report_btn.setToolTip("설정 → 데이터 관리 → Gitea 피드백 토큰 입력 후 활성화 필요")
|
||||
|
||||
def do_copy():
|
||||
clipboard = QApplication.clipboard()
|
||||
text = (
|
||||
f"## {exc_type}\n\n{message}\n\n"
|
||||
f"**Version**: {version}\n**Note**: {user_note.text()}\n\n"
|
||||
f"```\n{tb}\n```"
|
||||
)
|
||||
clipboard.setText(text)
|
||||
copy_btn.setText("✓ 복사됨")
|
||||
|
||||
def do_report():
|
||||
token = db.get_setting('gitea_feedback_token', '') or ''
|
||||
if not token:
|
||||
QMessageBox.warning(dlg, "토큰 없음", "Gitea PAT를 설정에서 먼저 입력하세요.")
|
||||
return
|
||||
title_str = f"[Auto] {exc_type}: {message[:80]}"
|
||||
body = (
|
||||
f"**Version**: `{version}`\n"
|
||||
f"**Time**: {datetime.now().isoformat(timespec='seconds')}\n"
|
||||
f"**User note**: {user_note.text() or '(none)'}\n\n"
|
||||
f"### Traceback\n```\n{tb[-3000:]}\n```"
|
||||
)
|
||||
ok = _send_to_gitea(token, title_str, body)
|
||||
if ok:
|
||||
QMessageBox.information(dlg, "보고 완료", "Gitea Issues에 등록되었습니다.")
|
||||
report_btn.setText("✓ 보고됨")
|
||||
report_btn.setEnabled(False)
|
||||
else:
|
||||
QMessageBox.warning(dlg, "보고 실패", "네트워크 또는 토큰 권한 문제일 수 있습니다.")
|
||||
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
copy_btn.clicked.connect(do_copy)
|
||||
report_btn.clicked.connect(do_report)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(copy_btn)
|
||||
btn_row.addWidget(report_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
dlg.setLayout(layout)
|
||||
dlg.exec_()
|
||||
|
||||
|
||||
def _send_to_gitea(token: str, title: str, body: str) -> bool:
|
||||
"""Gitea Issues API로 issue 생성."""
|
||||
payload = json.dumps({'title': title, 'body': body}).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
f"{GITEA_API}/issues",
|
||||
data=payload,
|
||||
headers={
|
||||
'Authorization': f'token {token}',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
|
||||
return False
|
||||
@ -11,12 +11,15 @@ class CSVExporter:
|
||||
"""CSV 내보내기 클래스"""
|
||||
|
||||
@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:
|
||||
records: 근무 기록 리스트
|
||||
filename: 저장할 파일명 (None이면 자동 생성)
|
||||
db: 점심/저녁 기본 분(minutes)을 표시용으로 읽을 Database 인스턴스 (옵션)
|
||||
Returns:
|
||||
str: 저장된 파일 경로
|
||||
"""
|
||||
@ -24,31 +27,70 @@ class CSVExporter:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
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:
|
||||
fieldnames = [
|
||||
'날짜', '출근시간', '퇴근시간', '점심시간',
|
||||
'총근무시간', '연장근무(분)', '적립(분)', '메모'
|
||||
'날짜', '출근시간', '퇴근시간',
|
||||
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
|
||||
'총근무시간', '연장근무(분)', '적립(분)', '메모',
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for record in records:
|
||||
lunch_on = bool(record.get('lunch_break'))
|
||||
dinner_on = bool(record.get('dinner_break'))
|
||||
row = {
|
||||
'날짜': record.get('date', ''),
|
||||
'출근시간': record.get('clock_in', ''),
|
||||
'퇴근시간': 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}시간",
|
||||
'연장근무(분)': record.get('overtime_minutes', 0),
|
||||
'적립(분)': record.get('overtime_earned', 0),
|
||||
'메모': record.get('memo', '')
|
||||
'메모': record.get('memo', ''),
|
||||
}
|
||||
writer.writerow(row)
|
||||
|
||||
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
|
||||
def export_overtime_summary(db, filename: str = None) -> str:
|
||||
"""
|
||||
|
||||
182
utils/csv_importer.py
Normal file
182
utils/csv_importer.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""
|
||||
CSV 가져오기 — 우리 표준 포맷.
|
||||
|
||||
표준 포맷 (v2.7.1+):
|
||||
date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||
2026-04-01,09:00:00,18:30:00,60,0,"메모"
|
||||
|
||||
- 헤더 첫 줄 필수
|
||||
- date: YYYY-MM-DD
|
||||
- clock_in/out: HH:MM:SS 또는 HH:MM (clock_out이 clock_in보다 빠르면 익일로 간주)
|
||||
- lunch_minutes: 정수 (0이면 점심 미포함)
|
||||
- dinner_minutes: 정수 (옵션, 0/누락이면 저녁 미포함)
|
||||
- memo: 선택 (따옴표 가능)
|
||||
|
||||
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Iterator, Tuple
|
||||
|
||||
|
||||
def parse_csv(path: str) -> List[Dict]:
|
||||
"""CSV 파일을 dict 리스트로 파싱. 검증 실패 시 ValueError."""
|
||||
rows = []
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {path}")
|
||||
|
||||
with open(p, 'r', encoding='utf-8-sig', newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
required = {'date', 'clock_in'}
|
||||
if not required.issubset(reader.fieldnames or []):
|
||||
raise ValueError(
|
||||
f"헤더에 필수 필드 누락: {required - set(reader.fieldnames or [])}\n"
|
||||
f"필수 헤더: date,clock_in,clock_out,lunch_minutes,memo"
|
||||
)
|
||||
|
||||
for i, row in enumerate(reader, start=2): # 데이터 시작 줄 번호 (1=헤더)
|
||||
try:
|
||||
clean = _normalize_row(row)
|
||||
rows.append(clean)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"줄 {i}: {e}")
|
||||
return rows
|
||||
|
||||
|
||||
def _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:
|
||||
"""단일 행 검증 + 정규화."""
|
||||
date_str = (row.get('date') or '').strip()
|
||||
if not date_str:
|
||||
raise ValueError("date 비어있음")
|
||||
try:
|
||||
datetime.strptime(date_str, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise ValueError(f"date 형식 오류: '{date_str}' (YYYY-MM-DD 필요)")
|
||||
|
||||
ci = _normalize_time(row.get('clock_in', '').strip(), 'clock_in')
|
||||
co_raw = (row.get('clock_out') or '').strip()
|
||||
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
|
||||
|
||||
lunch = _parse_minutes(row.get('lunch_minutes', ''), 'lunch_minutes')
|
||||
dinner = _parse_minutes(row.get('dinner_minutes', ''), 'dinner_minutes')
|
||||
|
||||
memo = (row.get('memo') or '').strip()
|
||||
return {
|
||||
'date': date_str,
|
||||
'clock_in': ci,
|
||||
'clock_out': co,
|
||||
'lunch_minutes': lunch,
|
||||
'dinner_minutes': dinner,
|
||||
'memo': memo,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_time(s: str, field_name: str) -> str:
|
||||
"""'HH:MM' 또는 'HH:MM:SS' → 'HH:MM:SS'."""
|
||||
if not s:
|
||||
raise ValueError(f"{field_name} 비어있음")
|
||||
parts = s.split(':')
|
||||
if len(parts) == 2:
|
||||
s = f"{s}:00"
|
||||
elif len(parts) != 3:
|
||||
raise ValueError(f"{field_name} 형식 오류: '{s}' (HH:MM[:SS] 필요)")
|
||||
try:
|
||||
datetime.strptime(s, '%H:%M:%S')
|
||||
except ValueError:
|
||||
raise ValueError(f"{field_name} 시간 파싱 실패: '{s}'")
|
||||
return s
|
||||
|
||||
|
||||
def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int, int, int]:
|
||||
"""파싱된 rows를 DB에 일괄 입력.
|
||||
|
||||
Args:
|
||||
db: Database 인스턴스
|
||||
rows: parse_csv 결과
|
||||
on_conflict: 'skip' | 'overwrite'
|
||||
|
||||
Returns:
|
||||
(added, updated, skipped) 수
|
||||
"""
|
||||
if on_conflict not in ('skip', 'overwrite'):
|
||||
raise ValueError("on_conflict는 'skip' 또는 'overwrite'")
|
||||
|
||||
added = updated = skipped = 0
|
||||
|
||||
from datetime import timedelta
|
||||
from core.time_calculator import TimeCalculator
|
||||
work_minutes = db.get_work_minutes()
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60)
|
||||
|
||||
for row in rows:
|
||||
existing = db.get_work_record(row['date'])
|
||||
|
||||
if existing and on_conflict == 'skip':
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if existing and on_conflict == 'overwrite':
|
||||
# 기존 레코드 삭제 후 재추가 (단순화)
|
||||
conn = db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
updated += 1
|
||||
else:
|
||||
added += 1
|
||||
|
||||
wid = db.add_work_record(row['date'], row['clock_in'], is_manual=True)
|
||||
if row.get('clock_out'):
|
||||
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||
# 자정 경계: 퇴근이 출근보다 빠르면 익일로 간주
|
||||
if co_dt <= ci_dt:
|
||||
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
|
||||
ot_actual, ot_earned = calc.calculate_overtime(
|
||||
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)
|
||||
if include_lunch:
|
||||
db.update_lunch_break(row['date'], True)
|
||||
if include_dinner:
|
||||
db.update_dinner_break(row['date'], True)
|
||||
if ot_earned > 0:
|
||||
db.add_overtime_earned(wid, ot_earned, row['date'])
|
||||
|
||||
return added, updated, skipped
|
||||
@ -5,6 +5,7 @@ URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
@ -21,6 +22,23 @@ COLOR_YELLOW = 0xFEE75C
|
||||
COLOR_PINK = 0xEB459E
|
||||
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,
|
||||
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
|
||||
@ -37,7 +55,7 @@ def send(webhook_url: str, title: str, description: str,
|
||||
Returns:
|
||||
성공 시 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
|
||||
|
||||
payload = {
|
||||
|
||||
84
utils/font_loader.py
Normal file
84
utils/font_loader.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""번들 폰트(NanumSquare) 로딩.
|
||||
|
||||
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
|
||||
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
|
||||
등록 실패 시 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5.QtGui import QFontDatabase, QFont
|
||||
|
||||
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
|
||||
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
|
||||
_FONT_FILES = [
|
||||
'NanumSquareL.ttf',
|
||||
'NanumSquareR.ttf',
|
||||
'NanumSquareB.ttf',
|
||||
'NanumSquareEB.ttf',
|
||||
'NanumSquare_acR.ttf',
|
||||
'NanumSquare_acB.ttf',
|
||||
]
|
||||
|
||||
|
||||
def _font_dir() -> str:
|
||||
"""번들 font/ 디렉토리 절대 경로."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
|
||||
else:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(base, 'font')
|
||||
|
||||
|
||||
def load_bundled_fonts() -> list:
|
||||
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
|
||||
families: list = []
|
||||
fdir = _font_dir()
|
||||
if not os.path.isdir(fdir):
|
||||
return families
|
||||
for name in _FONT_FILES:
|
||||
path = os.path.join(fdir, name)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
fid = QFontDatabase.addApplicationFont(path)
|
||||
if fid == -1:
|
||||
continue
|
||||
for fam in QFontDatabase.applicationFontFamilies(fid):
|
||||
if fam not in families:
|
||||
families.append(fam)
|
||||
return families
|
||||
|
||||
|
||||
def _pick_primary(families: list) -> str:
|
||||
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
|
||||
if 'NanumSquare' in families:
|
||||
return 'NanumSquare'
|
||||
for fam in families:
|
||||
low = fam.lower()
|
||||
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
|
||||
return fam
|
||||
return 'Malgun Gothic'
|
||||
|
||||
|
||||
def apply_app_font(app, point_size: int = 9) -> str:
|
||||
"""앱 전역 기본 폰트를 NanumSquare로 설정.
|
||||
|
||||
Returns:
|
||||
실제 적용된 primary family 이름 (폴백 시 'Malgun Gothic').
|
||||
"""
|
||||
families = load_bundled_fonts()
|
||||
primary = _pick_primary(families)
|
||||
font = QFont(primary, point_size)
|
||||
font.setStyleStrategy(QFont.PreferAntialias)
|
||||
app.setFont(font)
|
||||
return primary
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
_app = QApplication(sys.argv)
|
||||
fams = load_bundled_fonts()
|
||||
print('font dir:', _font_dir())
|
||||
print('registered families:', fams)
|
||||
print('picked primary:', _pick_primary(fams))
|
||||
98
utils/holiday_api.py
Normal file
98
utils/holiday_api.py
Normal file
@ -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
|
||||
@ -57,58 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
|
||||
return QIcon(pixmap)
|
||||
|
||||
def setup_menu(self):
|
||||
"""트레이 메뉴 설정"""
|
||||
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
|
||||
menu = QMenu()
|
||||
|
||||
show_action = QAction(tr('tray.open'), self)
|
||||
show_action.triggered.connect(self.show_window)
|
||||
menu.addAction(show_action)
|
||||
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
|
||||
self._icon_actions = []
|
||||
|
||||
mini_action = QAction(tr('tray.mini_widget'), self)
|
||||
mini_action.triggered.connect(self._open_mini_widget)
|
||||
menu.addAction(mini_action)
|
||||
def add(text, slot, icon_name=None):
|
||||
action = QAction(text, self)
|
||||
action.triggered.connect(slot)
|
||||
menu.addAction(action)
|
||||
if icon_name:
|
||||
self._icon_actions.append((action, icon_name))
|
||||
return action
|
||||
|
||||
add(tr('tray.open'), self.show_window, 'home')
|
||||
add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
lunch_action = QAction(tr('tray.toggle_lunch'), self)
|
||||
lunch_action.triggered.connect(self._toggle_lunch)
|
||||
menu.addAction(lunch_action)
|
||||
|
||||
break_out_action = QAction(tr('btn.break_out'), self)
|
||||
break_out_action.triggered.connect(self._break_out)
|
||||
menu.addAction(break_out_action)
|
||||
|
||||
break_in_action = QAction(tr('btn.break_in'), self)
|
||||
break_in_action.triggered.connect(self._break_in)
|
||||
menu.addAction(break_in_action)
|
||||
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
|
||||
add(tr('btn.break_out'), self._break_out)
|
||||
add(tr('btn.break_in'), self._break_in)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
|
||||
clock_out_action.triggered.connect(self.quick_clock_out)
|
||||
menu.addAction(clock_out_action)
|
||||
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
stats_action = QAction("📊 " + tr('menu.stats'), self)
|
||||
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
|
||||
menu.addAction(stats_action)
|
||||
|
||||
cal_action = QAction("📅 " + tr('menu.calendar'), self)
|
||||
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
||||
menu.addAction(cal_action)
|
||||
|
||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
||||
menu.addAction(help_action)
|
||||
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
|
||||
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
|
||||
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
|
||||
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
quit_action = QAction(tr('tray.quit'), self)
|
||||
quit_action.triggered.connect(self.quit_app)
|
||||
menu.addAction(quit_action)
|
||||
add(tr('tray.quit'), self.quit_app)
|
||||
|
||||
self.setContextMenu(menu)
|
||||
self.refresh_theme()
|
||||
|
||||
def refresh_theme(self):
|
||||
"""트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
|
||||
|
||||
QMenu()는 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
|
||||
명시적으로 적용한다. 테마 변경 시 main_window.apply_theme에서 호출.
|
||||
"""
|
||||
menu = self.contextMenu()
|
||||
if menu is None:
|
||||
return
|
||||
# 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
|
||||
qss = self.parent_window.styleSheet() if self.parent_window else ''
|
||||
if not qss:
|
||||
try:
|
||||
from ui.styles import get_theme
|
||||
qss = get_theme('dark')
|
||||
except Exception:
|
||||
qss = ''
|
||||
if qss:
|
||||
menu.setStyleSheet(qss)
|
||||
# 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
|
||||
try:
|
||||
from ui.icons import get_icon
|
||||
from ui.styles import ThemeColors
|
||||
color = ThemeColors.get('text_primary')
|
||||
for action, name in getattr(self, '_icon_actions', []):
|
||||
action.setIcon(get_icon(name, color, 16))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _call_parent(self, method_name: str):
|
||||
if self.parent_window and hasattr(self.parent_window, method_name):
|
||||
|
||||
@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
|
||||
|
||||
pid = os.getpid()
|
||||
try:
|
||||
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
|
||||
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
|
||||
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
|
||||
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
|
||||
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
|
||||
if sys.platform == 'win32':
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
|
||||
else:
|
||||
creationflags = 0
|
||||
subprocess.Popen(
|
||||
[str(updater_exe),
|
||||
'--pid', str(pid),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user