Compare commits

..
8 Commits
Author SHA1 Message Date
KINDNICK ff71886fd7 v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키
- 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용
- MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출
- 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등)
- pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122)
- README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
2026-04-30 19:30:47 +09:00
KINDNICKandClaude Opus 4.7 6a17876af1 v2.6.0: Phase 3 + Phase 4 — accessibility + code sign infra
Phase 3 (also v2.5.0 in CHANGELOG):
- Weekly auto report on Monday
- Matplotlib chart hover annotation
- Clock-in time distribution histogram
- Leave usage calendar with color coding

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

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

Fixed: leave_view setLayout indentation regression

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

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

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

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

Move setLayout back to the end of init_ui.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:01:48 +09:00
42 changed files with 4230 additions and 353 deletions
+136 -27
View File
@@ -1,20 +1,40 @@
# Project Conventions and Operational Gotchas # Project Conventions and Operational Gotchas
## 🛠️ Setup & Execution ## 🛠️ 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` - **Run:** `python main.py`
- **Module-level tests:** - **Module-level smoke:**
- Event monitoring: `python core/event_monitor.py` - Event monitoring: `python core/event_monitor.py`
- Time calculation: `python core/time_calculator.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) ## 🗄️ 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. - **`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. - **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 the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours). - **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`).
- **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). - **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect. - **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) ## ⚠️ 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. 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 ### 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 ### 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). 24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
### 4. Workday boundary ### 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 ### 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 ## ⚙️ Build Process
```bash ```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) - `dist/main.exe` running → `PermissionError`. Kill it first.
- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`) - `holidays` package only baked in if installed in build env.
- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first. - Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env.
## 🚦 External Integrations ## 🚦 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. - **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.
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design. - **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. - **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). - **`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. - **+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**: `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". - **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. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling. - **`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) for hours derivation when consistency matters. - **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 ## 🌐 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. - **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 (입력란 placeholder 등), calendar_view detail labels. - **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated. - **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.
+140
View File
@@ -4,6 +4,146 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.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
- **Discord 웹훅 항상 실패하던 문제** (Cloudflare error 1010 / 403 Forbidden)
- 원인: Python 기본 User-Agent(`Python-urllib/3.x`)를 Discord/Cloudflare가
봇으로 인식해 차단
- 수정: `utils/discord_webhook.py`에 브라우저 UA 헤더 추가
(`Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3`)
- 이제 온보딩 위저드의 "테스트 메시지 보내기" + 출퇴근/휴식 push 모두 정상 동작
## [2.3.2] — 2026-04-30
### Fixed
- **도움말 다이얼로그가 빈 창으로 표시되던 치명적 버그**
- v2.3.1에서 "온보딩 다시 보기" 버튼 추가 시 `self.setLayout(main_layout)` 라인이
실수로 `_reopen_onboarding` 메서드 안으로 들여쓰기되어 init_ui가 setLayout 없이 종료
- F1 누르면 빈 화면만 나옴 (탭 6개 모두 미렌더링)
- setLayout 호출 위치 복원 → 정상 표시
## [2.3.1] — 2026-04-30
### Fixed
- **도움말 다이얼로그에 "온보딩 다시 보기" 버튼 추가**
- v2.3.0에서 `show_onboarding()` 메서드는 만들었지만 UI 진입점 누락
- 이제 도움말 (F1) → 좌측 하단 "🚀 온보딩 다시 보기" 버튼으로 위저드 재실행 가능
- 본인 검증/설정 변경 시 유용
## [2.3.0] — 2026-04-30
### Added — Phase 1 + E1 (소비자 친화 6종)
- **첫 실행 온보딩 위저드** (강제) — 5단계: 환영 → 근무패턴 → 출근 감지 방식 → 연차/시급(옵션) → Discord(옵션) → 완료
- 신규 사용자: 자동 표시 / 기존 사용자(work_records 있음): 자동 완료 처리
- "도움말 → 온보딩 다시 보기" 메뉴로 언제든 재실행 가능
- **시급 → 추정 급여** (옵션) — 포괄임금이 아닐 때만 활성화
- 통계 화면 월간 탭에 "이번 달 추정 급여" 카드
- 퇴근 후 "오늘 요약" 카드에도 추정 급여 표시
- 연장수당 가산률 1.0 / 1.5 / 2.0 선택
- **출퇴근 시각 인라인 편집** — 메인 화면 출근/퇴근 라벨 클릭 → 즉시 수정 다이얼로그
- **퇴근 후 "오늘 요약" 카드** — 메인 화면 상단에 총 근무/점심/외출/연장/추정급여 표시
- 다음 출근 시 자동 숨김 / X 버튼으로 수동 닫기
- **장시간 근무 휴식 권고 알림** — 연속 N시간(기본 4시간) 자리 비움 없으면 "🌿 잠시 일어나세요" 토스트
- 5분 throttle + 일 1회 가드 (notification_log 테이블)
- **Discord 웹훅 알림** (옵션) — 출퇴근/휴식권고 모바일 push
- 봇 등록·서버 운영 0. 채널 웹훅 URL만 입력
- 출근(녹색) / 퇴근 정시(파랑) / 퇴근 연장(주황) / 건강경고(분홍) embed
- 온보딩에서 즉시 활성화 + "테스트 메시지" 버튼
### Database
- `break_records.break_type` 컬럼 추가 ('break' / 'lunch' / 'dinner' 구분)
- `notification_log` 테이블 신규 (channel, event_type, sent_at, success — 중복 발송 가드 + 통계용)
- 기존 사용자 `onboarding_completed` 자동 true 처리 마이그레이션
### Settings (신규 11개)
- `onboarding_completed`, `salary_enabled`, `hourly_wage`, `overtime_rate`
- `health_break_enabled`, `health_break_hours`
- `discord_webhook_url`, `discord_notif_clock_in`, `discord_notif_clock_out`, `discord_notif_health`
## [2.2.4] — 2026-04-30 ## [2.2.4] — 2026-04-30
### Added ### Added
+93 -61
View File
@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 ## Build and Run
@@ -20,46 +20,73 @@ python main.py
python core/event_monitor.py python core/event_monitor.py
python core/time_calculator.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 python -m PyInstaller --clean main.spec
# Integration tests (35 + 5 + view scenarios) # Tests
python _integration_test.py python _integration_test.py # business-logic scenarios
python _i18n_gui_test.py python _i18n_gui_test.py # ko/en GUI verification
python _gui_smoke_test.py 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 ## Architecture
### Core (`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()`. - **[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 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()`. - **[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)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required. - **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
- **[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. - **[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.
- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback. - **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
- **[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')`. - **[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. - **[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/`) ### 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). - **[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.
- **[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`. - **[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.
- **[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. - **[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.
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible. - **[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`.
- **[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. - **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
- 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). - **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing. - **[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/`) ### ui/controllers/
- **[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_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked). - **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
- **[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`. - **[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.
- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production.
### 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`.
- **[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. - **[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: Critical invariant — preserve in any change:
```python ```python
@@ -72,49 +99,54 @@ remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_min
remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation 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 ## Database invariants
- `work_records.date` UNIQUE (one row/day). - `work_records.date` UNIQUE.
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings. - `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 (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label. - `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). - `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()`. - Balance: `SUM(bank.earned) - SUM(usage.used)`.
- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code. - `notification_log` for daily dedupe (channel+event_type+date).
- `crash_log` for unhandled exceptions.
## Settings system ## 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()`: 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: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only) - `WORK_MINUTES ↔ WORK_HOURS` (floor)
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL` - `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 ## 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. - **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict.
- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`. - 24h `datetime` internal. 12h conversion only in `format_time()`.
- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover. - 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min.
- **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. - Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture. - PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
- **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. - main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon).
## Tests ## Tests
- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt). - [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`. - [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets. - [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+).
All three should be green before any release. 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).
+100 -76
View File
@@ -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 이상이 필요합니다. Python 3.9 이상이 필요합니다.
### Windows #### Windows
1. https://www.python.org/downloads/ 방문 1. https://www.python.org/downloads/ 방문
2. "Download Python 3.x.x" 클릭 2. "Download Python 3.x.x" 클릭
3. 설치 시 **"Add Python to PATH"** 체크 필수! 3. 설치 시 **"Add Python to PATH"** 체크 필수
확인: 확인:
```bash ```bash
python --version 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을 엽니다. 프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다.
@@ -26,66 +47,26 @@ python --version
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 설치되는 패키지: #### 설치되는 패키지 (requirements.txt)
1. **PyQt5** - GUI 프레임워크 1. **PyQt5** — GUI 프레임워크
2. **pywin32** - Windows API 접근 2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지)
3. **python-dateutil** - 날짜 계산 3. **python-dateutil** — 날짜 계산
4. **matplotlib** - 그래프 4. **matplotlib** — 통계 차트
5. **plyer** - 알림 시스템 5. **plyer** — 시스템 알림
6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함) 6. **holidays** — 한국 공휴일 자동 등록 (음력 명절 포함)
## 4. 리소스 다운로드 (선택) ### 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. 실행
```bash ```bash
python main.py python main.py
``` ```
### 관리자 권한으로 실행 (권장) #### 관리자 권한으로 실행 (권장)
Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다. Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다.
1. **방법 1**: cmd를 관리자 권한으로 실행 1. **방법 1**: cmd를 관리자 권한으로 실행
- Windows 키 + X - Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)"
- "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택
- 프로젝트 폴더로 이동: `cd "경로"` - 프로젝트 폴더로 이동: `cd "경로"`
- `python main.py` 실행 - `python main.py` 실행
@@ -93,11 +74,39 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있
- `python main.py`를 실행하는 배치 파일(.bat) 생성 - `python main.py`를 실행하는 배치 파일(.bat) 생성
- 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크 - 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크
## 6. 첫 실행 ### 5. 첫 실행
1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성 1. 실행 시 자동으로 데이터베이스(`database.db`) 생성
2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지 2. 5단계 온보딩 위저드 표시
3. 감지된 시간이 출근시간으로 설정됨 - 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → 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 ```bash
git pull
pip install --upgrade -r requirements.txt pip install --upgrade -r requirements.txt
``` ```
## 제거 ## 제거
1. 프로젝트 폴더 삭제 1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스)
2. 패키지 제거 (선택): 2. 데이터 보존하려면 `database.db`만 별도 백업
```bash 3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제)
pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer
```
## 프로덕션 빌드 (PyInstaller) ## 프로덕션 빌드 (PyInstaller)
소스 없이 실행 파일만 배포하려면: 소스 없이 실행 파일만 배포하려면:
```bash ```bash
python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB) # 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드)
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터) 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를 함께 포함시켜 같은 폴더에 두세요. 또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화.
자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다.
빌드 시 주의: 빌드 시 주의:
- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행 - `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행
- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨 - `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨
- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출)
## 환경 변수
- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력
- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경
## 다음 단계 ## 다음 단계
설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요! 설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요.
개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요.
+30 -6
View File
@@ -45,9 +45,11 @@
### 7. 통계·분석 ### 7. 통계·분석
- 주간/월간 요약 + matplotlib 차트 - 주간/월간 요약 + matplotlib 차트
- 일별 근무시간 + 연장 누적 막대 그래프 - 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁)
- 요일별 평균 근무시간 - 요일별 평균 근무시간
- 근무 패턴 인사이트 (정적 통계 요약) - 출근 시각 분포 히스토그램 (30분 단위 + 평균선)
- 근무 패턴 인사이트
- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약)
### 8. 공휴일 관리 ### 8. 공휴일 관리
- 한국 공휴일 자동 등록 (`holidays` 패키지) - 한국 공휴일 자동 등록 (`holidays` 패키지)
@@ -69,13 +71,35 @@
- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작 - 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작
- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거 - F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거
- 실패 시 자동 롤백 - 실패 시 자동 롤백
- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출)
### 12. 다국어 지원 (i18n) ### 12. 첫 실행 온보딩 위저드
- 한국어 / English 전환 - 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시
- 알림 메시지·UI 라벨 28개 카테고리 - 기존 사용자: 자동 완료 처리 + 도움말(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 콘텐츠 - HelpView 6개 탭 ko/en HTML 콘텐츠
### 13. 단축키 ### 17. 단축키
- `Ctrl+O` 출/퇴근 토글 - `Ctrl+O` 출/퇴근 토글
- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글 - `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글
- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말 - `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말
+245
View File
@@ -485,6 +485,234 @@ def s35_lock():
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트) # 일반적으로 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("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 # Run all
# ============================================================ # ============================================================
@@ -520,6 +748,23 @@ def main():
s33_short_overtime() s33_short_overtime()
s34_format() s34_format()
s35_lock() 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()
s52_csv_overtime()
print() print()
print("=" * 60) print("=" * 60)
+180 -5
View File
@@ -161,6 +161,9 @@ class Database:
self.migrate_cleanup_balance_adjustments() self.migrate_cleanup_balance_adjustments()
self.migrate_work_hours_to_minutes() self.migrate_work_hours_to_minutes()
self.migrate_annual_leave_keys() self.migrate_annual_leave_keys()
self.migrate_v23_break_type()
self.migrate_v23_notification_log()
self.migrate_v23_onboarding_for_existing()
# 기본 설정 초기화 # 기본 설정 초기화
self.init_default_settings() self.init_default_settings()
@@ -467,6 +470,111 @@ class Database:
finally: finally:
conn.close() conn.close()
def migrate_v23_break_type(self):
"""break_records에 break_type 컬럼 추가 (v2.3.0).
값: 'break'(기본 외출) / 'lunch' / 'dinner'.
기존 점심 1시간 자동 적용 모드와 무관 — 실제 시간 입력용.
"""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(break_records)")
cols = [row[1] for row in cursor.fetchall()]
if 'break_type' not in cols:
cursor.execute("ALTER TABLE break_records ADD COLUMN break_type TEXT DEFAULT 'break'")
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"break_type 컬럼 추가 경고: {e}", file=sys.stderr)
finally:
conn.close()
def migrate_v23_notification_log(self):
"""알림 발송 이력 테이블 (v2.3.0). 중복 발송 방지 + 통계."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute('''
CREATE TABLE IF NOT EXISTS notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel TEXT NOT NULL,
event_type TEXT NOT NULL,
payload TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
success BOOLEAN DEFAULT 1
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_notif_log_event
ON notification_log(event_type, sent_at)
''')
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"notification_log 생성 경고: {e}", file=sys.stderr)
finally:
conn.close()
def log_notification(self, channel: str, event_type: str,
payload: str = None, success: bool = True) -> None:
"""알림 발송 이력 기록 (중복 방지 가드용)."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"INSERT INTO notification_log (channel, event_type, payload, success) VALUES (?, ?, ?, ?)",
(channel, event_type, payload, success)
)
conn.commit()
finally:
conn.close()
def has_notification_today(self, channel: str, event_type: str) -> bool:
"""오늘 같은 (channel, event_type) 발송 이력 존재 여부."""
conn = self.get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT COUNT(*) FROM notification_log "
"WHERE channel = ? AND event_type = ? AND DATE(sent_at) = DATE('now', 'localtime')",
(channel, event_type)
)
return cursor.fetchone()[0] > 0
finally:
conn.close()
def migrate_v23_onboarding_for_existing(self):
"""기존 사용자(이미 work_records 데이터 있음)는 온보딩 자동 완료 처리.
v2.3.0 도입 시 한 번만 실행. 신규 DB(데이터 0)는 영향 없음 → 첫 실행 시 위저드.
"""
conn = self.get_connection()
cursor = conn.cursor()
try:
# 이미 완료/스킵 마크 있으면 패스
cursor.execute("SELECT value FROM settings WHERE key = 'onboarding_completed'")
row = cursor.fetchone()
if row and row[0] == 'true':
return
# 기존 work_records 데이터가 1건 이상 있으면 자동 완료
cursor.execute("SELECT COUNT(*) FROM work_records")
count = cursor.fetchone()[0]
if count > 0:
cursor.execute('''
INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES ('onboarding_completed', 'true', CURRENT_TIMESTAMP)
''')
conn.commit()
except Exception as e:
conn.rollback()
import sys
print(f"onboarding 마이그레이션 경고: {e}", file=sys.stderr)
finally:
conn.close()
def get_setting_int(self, key: str, default: int = 0) -> int: def get_setting_int(self, key: str, default: int = 0) -> int:
"""설정을 int로 안전하게 조회 (변환 실패 시 default).""" """설정을 int로 안전하게 조회 (변환 실패 시 default)."""
raw = self.get_setting(key, None) raw = self.get_setting(key, None)
@@ -533,6 +641,23 @@ class Database:
'workday_boundary_hour': '6', 'workday_boundary_hour': '6',
'overtime_unit': '30', 'overtime_unit': '30',
'time_format': '24', 'time_format': '24',
# v2.3.0
'onboarding_completed': 'false',
'salary_enabled': 'false',
'hourly_wage': '0',
'overtime_rate': '1.5',
'health_break_enabled': 'true',
'health_break_hours': '4',
'discord_webhook_url': '',
'discord_notif_clock_in': 'true',
'discord_notif_clock_out': 'true',
'discord_notif_health': 'true',
# v2.4.0
'goal_overtime_max_monthly': '0',
'goal_avg_hours_daily': '0',
# v2.6.0
'font_scale': '1.0',
'high_contrast': 'false',
} }
conn = self.get_connection() conn = self.get_connection()
@@ -1146,21 +1271,71 @@ class Database:
# ===== 외출 관련 메서드 ===== # ===== 외출 관련 메서드 =====
def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None) -> int: def add_break_record(self, work_record_id: int, date: str, break_out: str,
"""외출 기록 추가""" reason: str = None, break_type: str = 'break') -> int:
"""외출 기록 추가. break_type: 'break'(외출) / 'lunch' / 'dinner'."""
conn = self.get_connection() conn = self.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
INSERT INTO break_records (work_record_id, date, break_out, reason) INSERT INTO break_records (work_record_id, date, break_out, reason, break_type)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
''', (work_record_id, date, break_out, reason)) ''', (work_record_id, date, break_out, reason, break_type))
record_id = cursor.lastrowid record_id = cursor.lastrowid
conn.commit() conn.commit()
conn.close() conn.close()
return record_id return record_id
def add_meal_record(self, date: str, start_time: str, end_time: str,
meal_type: str = 'lunch') -> int:
"""식사 시간 기록 (시작·종료 둘 다 알 때).
Args:
date: 'YYYY-MM-DD'
start_time, end_time: 'HH:MM:SS'
meal_type: 'lunch' or 'dinner'
Returns:
새 break_record id
"""
from datetime import datetime as _dt
# 분 계산
start_dt = _dt.strptime(start_time, '%H:%M:%S')
end_dt = _dt.strptime(end_time, '%H:%M:%S')
if end_dt < start_dt:
from datetime import timedelta as _td
end_dt += _td(days=1)
total_min = int((end_dt - start_dt).total_seconds() / 60)
rec = self.get_today_record() if date == _dt.now().date().isoformat() else None
wid = rec['id'] if rec else None
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO break_records (work_record_id, date, break_out, break_in,
total_minutes, break_type)
VALUES (?, ?, ?, ?, ?, ?)
''', (wid, date, start_time, end_time, total_min, meal_type))
rid = cursor.lastrowid
conn.commit()
conn.close()
return rid
def get_meal_minutes_today(self, meal_type: str = 'lunch') -> int:
"""오늘의 식사 시간 합계 (분). 수동 입력된 경우만."""
from datetime import date as _date
today = _date.today().isoformat()
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT COALESCE(SUM(total_minutes), 0) FROM break_records
WHERE date = ? AND break_type = ? AND total_minutes IS NOT NULL
''', (today, meal_type))
result = cursor.fetchone()[0] or 0
conn.close()
return int(result)
def update_break_return(self, break_id: int, break_in: str): def update_break_return(self, break_id: int, break_in: str):
"""외출 복귀 시간 업데이트""" """외출 복귀 시간 업데이트"""
conn = self.get_connection() conn = self.get_connection()
+196
View File
@@ -103,6 +103,7 @@ _DICT = {
'msg.no_record.body': '오늘 출근 기록이 없습니다.', 'msg.no_record.body': '오늘 출근 기록이 없습니다.',
'msg.confirm_delete.title': '삭제 확인', 'msg.confirm_delete.title': '삭제 확인',
'msg.no_data.title': '데이터 없음', 'msg.no_data.title': '데이터 없음',
'msg.manual_added': '수동 추가',
# === 트레이 === # === 트레이 ===
'tray.open': '프로그램 열기', 'tray.open': '프로그램 열기',
@@ -153,6 +154,103 @@ _DICT = {
'help.tab_leave': '🌴 연차/휴가', 'help.tab_leave': '🌴 연차/휴가',
'help.tab_break': '🚪 외출/저녁', 'help.tab_break': '🚪 외출/저녁',
'help.tab_faq': '❓ 자주 묻는 질문', '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.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': { 'en': {
# === Menu/Buttons === # === Menu/Buttons ===
@@ -244,6 +342,7 @@ _DICT = {
'msg.no_record.body': 'No clock-in record for today.', 'msg.no_record.body': 'No clock-in record for today.',
'msg.confirm_delete.title': 'Confirm Delete', 'msg.confirm_delete.title': 'Confirm Delete',
'msg.no_data.title': 'No Data', 'msg.no_data.title': 'No Data',
'msg.manual_added': 'Manual',
# === Tray === # === Tray ===
'tray.open': 'Open Program', 'tray.open': 'Open Program',
@@ -294,6 +393,103 @@ _DICT = {
'help.tab_leave': '🌴 Leave', 'help.tab_leave': '🌴 Leave',
'help.tab_break': '🚪 Break/Dinner', 'help.tab_break': '🚪 Break/Dinner',
'help.tab_faq': '❓ FAQ', '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.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}',
}, },
} }
+27
View File
@@ -164,6 +164,33 @@ class Notifier(QObject):
) )
self.notified_weekly = True self.notified_weekly = True
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
"""장시간 연속 근무 휴식 알림.
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
"""
if current_time is None:
current_time = datetime.now()
if self.db is None:
return
if not self._enabled('health_break_enabled'):
return
if self.db.has_notification_today('system', 'health_break'):
return
try:
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
except (ValueError, TypeError):
threshold_hours = 4
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
if elapsed_sec >= threshold_hours * 3600:
from core.i18n import tr
self.notification_signal.emit(
tr('notif.health_break.title') if False else "🌿 휴식 권고",
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
)
self.db.log_notification('system', 'health_break')
def reset_notifications(self): def reset_notifications(self):
"""알림 상태 리셋 (날짜 변경 시)""" """알림 상태 리셋 (날짜 변경 시)"""
self.notified_30min = False self.notified_30min = False
+50
View File
@@ -0,0 +1,50 @@
"""
급여 추정 (옵션).
포괄임금제 회사면 사용 안 함. 시급 + 연장수당 가산률만 받아서 단순 계산.
"""
from __future__ import annotations
from typing import List, Dict
def estimate_pay(records: List[Dict], hourly_wage: float,
overtime_rate: float = 1.5) -> Dict[str, float]:
"""근무 기록 리스트로부터 추정 급여 계산.
Args:
records: [{'total_hours': float, 'overtime_minutes': int}, ...]
hourly_wage: 시급(원)
overtime_rate: 연장수당 가산률 (기본 1.5배 - 한국 노동법)
Returns:
{'base': 기본급, 'overtime': 연장수당, 'total': 합계}
"""
if hourly_wage <= 0:
return {'base': 0.0, 'overtime': 0.0, 'total': 0.0}
base_hours = 0.0
overtime_hours = 0.0
for r in records:
total = float(r.get('total_hours') or 0)
ot_min = int(r.get('overtime_minutes') or 0)
ot_hours = ot_min / 60.0
# 정규 근무 = 총 - 연장
regular = max(0.0, total - ot_hours)
base_hours += regular
overtime_hours += ot_hours
base = base_hours * hourly_wage
overtime = overtime_hours * hourly_wage * overtime_rate
return {
'base': base,
'overtime': overtime,
'total': base + overtime,
'base_hours': base_hours,
'overtime_hours': overtime_hours,
}
def format_won(amount: float) -> str:
"""원화 포맷팅. 1234567 → '1,234,567원'."""
return f"{int(round(amount)):,}원"
+29
View File
@@ -37,6 +37,8 @@ THEME = 'theme'
TIME_FORMAT = 'time_format' TIME_FORMAT = 'time_format'
LANGUAGE = 'language' LANGUAGE = 'language'
OVERTIME_UNIT = 'overtime_unit' OVERTIME_UNIT = 'overtime_unit'
FONT_SCALE = 'font_scale' # '1.0' / '1.25' / '1.5'
HIGH_CONTRAST = 'high_contrast'
# 통합/외부 # 통합/외부
DB_PATH_OVERRIDE = 'db_path_override' DB_PATH_OVERRIDE = 'db_path_override'
@@ -44,6 +46,33 @@ DB_PATH_OVERRIDE = 'db_path_override'
# 백업 # 백업
LAST_BACKUP_DATE = 'last_backup_date' LAST_BACKUP_DATE = 'last_backup_date'
# Crash Report (Gitea Issues 통합 — 옵션)
GITEA_FEEDBACK_TOKEN = 'gitea_feedback_token' # PAT (저장소 issue 쓰기 권한)
GITEA_FEEDBACK_ENABLED = 'gitea_feedback_enabled'
# === v2.3.0 신규 ===
# 온보딩
ONBOARDING_COMPLETED = 'onboarding_completed'
# 급여 (옵션, 포괄임금이면 미설정)
SALARY_ENABLED = 'salary_enabled'
HOURLY_WAGE = 'hourly_wage'
OVERTIME_RATE = 'overtime_rate' # 1.5
# 휴식 알림
HEALTH_BREAK_ENABLED = 'health_break_enabled'
HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4
# 목표
GOAL_OVERTIME_MAX_MONTHLY = 'goal_overtime_max_monthly' # 월 연장근무 상한 (분)
GOAL_AVG_HOURS_DAILY = 'goal_avg_hours_daily' # 일평균 목표 (시간, float)
# Discord 웹훅
DISCORD_WEBHOOK_URL = 'discord_webhook_url'
DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in'
DISCORD_NOTIF_CLOCK_OUT = 'discord_notif_clock_out'
DISCORD_NOTIF_HEALTH = 'discord_notif_health'
# 마이그레이션 sentinel # 마이그레이션 sentinel
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated' ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2' BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
+1 -1
View File
@@ -4,4 +4,4 @@
릴리스 시 이 값을 올린 후 git tag → push. 릴리스 시 이 값을 올린 후 git tag → push.
CHANGELOG.md의 최상단 항목과 일치시킬 것. CHANGELOG.md의 최상단 항목과 일치시킬 것.
""" """
__version__ = '2.2.4' __version__ = '2.7.0'
+17
View File
@@ -128,6 +128,23 @@ def main():
from utils.debug_log import dlog from utils.debug_log import dlog
dlog(f"backup failed: {e}") dlog(f"backup failed: {e}")
# 전역 예외 후킹 (crash report)
try:
from utils.crash_handler import install_global_handler
from core.version import __version__
install_global_handler(db, app_version=__version__)
except Exception as e:
from utils.debug_log import dlog
dlog(f"crash handler install failed: {e}")
# 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시
try:
from ui.onboarding_view import maybe_show_onboarding
maybe_show_onboarding(db)
except Exception as e:
from utils.debug_log import dlog
dlog(f"onboarding skipped: {e}")
# 메인 윈도우 생성 및 표시 # 메인 윈도우 생성 및 표시
try: try:
window = MainWindow() window = MainWindow()
+21
View File
@@ -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) $updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)" 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 ====== # ====== 4. ZIP ======
Step "4/7 ZIP packaging" Step "4/7 ZIP packaging"
$zipPath = "dist/ClockOutCalculator-$Version.zip" $zipPath = "dist/ClockOutCalculator-$Version.zip"
+110
View 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
View 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)
+129
View 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/123/abc', '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/x', '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/x', '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/x', '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/x', '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', '')
+106
View 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()
+98
View 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
+82
View File
@@ -0,0 +1,82 @@
"""
접근성 — 글꼴 크기 / 고대비 모드 적용.
QApplication 글로벌 폰트 + 추가 QSS 오버레이.
설정 변경 즉시 반영 (재시작 불필요).
"""
from __future__ import annotations
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QFont
# 고대비 QSS — 검정 배경 + 노란 텍스트 + 굵은 테두리
HIGH_CONTRAST_QSS = """
* {
background-color: #000000;
color: #FFEB3B;
border-color: #FFEB3B;
}
QPushButton, QLineEdit, QSpinBox, QComboBox, QTextEdit, QTableWidget, QGroupBox {
border: 2px solid #FFEB3B;
background-color: #000000;
color: #FFEB3B;
}
QPushButton:hover { background-color: #333333; }
QPushButton:pressed { background-color: #FFEB3B; color: #000000; }
QPushButton:disabled { color: #888; border-color: #888; }
QGroupBox::title { color: #FFEB3B; padding: 0 4px; }
QProgressBar { border: 2px solid #FFEB3B; }
QProgressBar::chunk { background-color: #FFEB3B; }
QToolTip { background-color: #000; color: #FFEB3B; border: 2px solid #FFEB3B; }
"""
def apply_font_scale(scale: float) -> None:
"""전역 글꼴 크기 배율 적용 (1.0 = 기본, 1.25 = 125%, 1.5 = 150%)."""
app = QApplication.instance()
if app is None:
return
base = app.font()
if base.pointSize() > 0:
# 기존 배율 무시하고 새 배율로 (기본 9pt 가정)
base_pt = 9
base.setPointSize(int(round(base_pt * scale)))
else:
base_px = 12
base.setPixelSize(int(round(base_px * scale)))
app.setFont(base)
def apply_high_contrast(enabled: bool, base_qss: str = "") -> None:
"""고대비 모드 ON/OFF. base_qss는 평소 테마 QSS (OFF 시 복원용)."""
app = QApplication.instance()
if app is None:
return
if enabled:
app.setStyleSheet(base_qss + "\n" + HIGH_CONTRAST_QSS)
else:
app.setStyleSheet(base_qss)
def apply_from_settings(db) -> None:
"""db에서 font_scale + high_contrast 읽어 적용."""
try:
scale = float(db.get_setting('font_scale', '1.0') or 1.0)
except (ValueError, TypeError):
scale = 1.0
scale = max(0.8, min(2.0, scale))
apply_font_scale(scale)
enabled = db.get_setting('high_contrast', 'false').lower() == 'true'
# base_qss는 main_window에서 apply_theme() 호출 직후 적용되므로,
# 여기서는 현재 styleSheet 그대로 두고 high_contrast만 추가.
app = QApplication.instance()
if app is None:
return
current = app.styleSheet() or ""
# 기존에 추가된 HIGH_CONTRAST_QSS 제거
base = current.replace(HIGH_CONTRAST_QSS, "").rstrip() + "\n"
if enabled:
app.setStyleSheet(base + HIGH_CONTRAST_QSS)
else:
app.setStyleSheet(base.rstrip())
+28 -19
View File
@@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("외출 기록 수정") self.setWindowTitle(tr('dlg.break.edit_title'))
self.setFixedSize(380, 180) self.setFixedSize(380, 180)
layout = QVBoxLayout() layout = QVBoxLayout()
@@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
# 외출 시간 # 외출 시간
out_layout = QHBoxLayout() out_layout = QHBoxLayout()
out_label = QLabel("외출 시간:") out_label = QLabel(tr('dlg.break.out_label'))
out_label.setFixedWidth(80) out_label.setFixedWidth(80)
self.out_time_edit = QTimeEdit() self.out_time_edit = QTimeEdit()
self.out_time_edit.setDisplayFormat("HH:mm:ss") self.out_time_edit.setDisplayFormat("HH:mm:ss")
@@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
# 복귀 시간 # 복귀 시간
in_layout = QHBoxLayout() in_layout = QHBoxLayout()
in_label = QLabel("복귀 시간:") in_label = QLabel(tr('dlg.break.in_label'))
in_label.setFixedWidth(80) in_label.setFixedWidth(80)
self.in_time_edit = QTimeEdit() self.in_time_edit = QTimeEdit()
self.in_time_edit.setDisplayFormat("HH:mm:ss") self.in_time_edit.setDisplayFormat("HH:mm:ss")
@@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
# 사유 # 사유
reason_layout = QHBoxLayout() reason_layout = QHBoxLayout()
reason_label = QLabel("사유:") reason_label = QLabel(tr('dlg.break.reason_label'))
reason_label.setFixedWidth(80) reason_label.setFixedWidth(80)
self.reason_edit = QLineEdit() self.reason_edit = QLineEdit()
reason_layout.addWidget(reason_label) reason_layout.addWidget(reason_label)
@@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
save_button.clicked.connect(self.accept) save_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
@@ -128,7 +128,7 @@ class BreakView(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("오늘의 외출 기록") title = QLabel(tr('view.break.today_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
@@ -136,7 +136,13 @@ class BreakView(QDialog):
# 외출 리스트 테이블 # 외출 리스트 테이블
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(5) 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() header = self.table.horizontalHeader()
@@ -152,7 +158,7 @@ class BreakView(QDialog):
layout.addWidget(self.table) 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.setObjectName("section_title")
self.total_label.setAlignment(Qt.AlignRight) self.total_label.setAlignment(Qt.AlignRight)
layout.addWidget(self.total_label) layout.addWidget(self.total_label)
@@ -160,8 +166,8 @@ class BreakView(QDialog):
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.refresh_button = QPushButton("새로고침") self.refresh_button = QPushButton(tr('btn.refresh'))
close_button = QPushButton("닫기") close_button = QPushButton(tr('btn.close'))
self.refresh_button.clicked.connect(self.load_break_records) self.refresh_button.clicked.connect(self.load_break_records)
close_button.clicked.connect(self.accept) close_button.clicked.connect(self.accept)
@@ -190,7 +196,7 @@ class BreakView(QDialog):
if break_in: if break_in:
self.table.setItem(i, 1, QTableWidgetItem(break_in)) self.table.setItem(i, 1, QTableWidgetItem(break_in))
else: else:
item = QTableWidgetItem("진행중") item = QTableWidgetItem(tr('view.break.in_progress'))
item.setForeground(Qt.red) item.setForeground(Qt.red)
self.table.setItem(i, 1, item) self.table.setItem(i, 1, item)
@@ -199,7 +205,10 @@ class BreakView(QDialog):
if total_minutes: if total_minutes:
hours = total_minutes // 60 hours = total_minutes // 60
minutes = 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)) self.table.setItem(i, 2, QTableWidgetItem(duration_str))
else: else:
self.table.setItem(i, 2, QTableWidgetItem("-")) self.table.setItem(i, 2, QTableWidgetItem("-"))
@@ -214,8 +223,8 @@ class BreakView(QDialog):
action_layout.setContentsMargins(0, 0, 0, 0) action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(5) action_layout.setSpacing(5)
edit_button = QPushButton("수정") edit_button = QPushButton(tr('btn.edit_short'))
delete_button = QPushButton("삭제") delete_button = QPushButton(tr('btn.delete_short'))
edit_button.setFixedSize(50, 25) edit_button.setFixedSize(50, 25)
delete_button.setFixedSize(50, 25) delete_button.setFixedSize(50, 25)
@@ -237,9 +246,9 @@ class BreakView(QDialog):
minutes = total_minutes % 60 minutes = total_minutes % 60
if hours > 0: if hours > 0:
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분") self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
else: 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): def edit_record(self, record_id):
"""외출 기록 수정""" """외출 기록 수정"""
@@ -277,8 +286,8 @@ class BreakView(QDialog):
"""외출 기록 삭제""" """외출 기록 삭제"""
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"삭제 확인", tr('msg.confirm_delete.title'),
"이 외출 기록을 삭제하시겠습니까?", tr('view.break.delete_confirm'),
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
+104
View File
@@ -47,6 +47,9 @@ class CalendarView(QDialog):
self.calendar.setMinimumHeight(280) self.calendar.setMinimumHeight(280)
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader) self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self.date_selected) self.calendar.clicked.connect(self.date_selected)
# 우클릭 컨텍스트 메뉴 (과거 일자 수동 추가)
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
self.calendar.customContextMenuRequested.connect(self._show_date_context)
layout.addWidget(self.calendar, 1) layout.addWidget(self.calendar, 1)
# 범례 # 범례
@@ -152,6 +155,107 @@ class CalendarView(QDialog):
self.calendar.setDateTextFormat(qdate, fmt) self.calendar.setDateTextFormat(qdate, fmt)
def _show_date_context(self, pos):
"""캘린더 우클릭 메뉴 — 과거 일자 추가/편집/삭제."""
from PyQt5.QtWidgets import QMenu
qdate = self.calendar.selectedDate()
date_str = qdate.toString('yyyy-MM-dd')
existing = self.db.get_work_record(date_str)
menu = QMenu(self)
if existing:
edit_action = menu.addAction(f"✏️ {date_str} 편집")
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
else:
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
action = menu.exec_(self.calendar.mapToGlobal(pos))
if action is None:
return
if existing and action.text().startswith("✏️"):
self._open_edit_dialog(date_str)
elif existing and action.text().startswith("🗑️"):
self._delete_record(date_str)
elif not existing and action.text().startswith("➕"):
self._add_past_record(date_str)
def _add_past_record(self, date_str: str):
"""과거 일자 수동 추가."""
from ui.past_record_dialog import PastRecordDialog
dialog = PastRecordDialog(self, date_str)
if dialog.exec_() != QDialog.Accepted:
return
data = dialog.get_data()
if not data:
return
try:
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
if data.get('clock_out'):
# 총 시간/연장근무 계산
from datetime import datetime as _dt
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
from core.time_calculator import TimeCalculator
wm = self.db.get_work_minutes()
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
calc = TimeCalculator(work_minutes=wm, lunch_duration_minutes=lunch)
total = (co - ci).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci, co,
include_lunch=data.get('lunch', False),
include_dinner=data.get('dinner', False),
)
self.db.update_clock_out(date_str, data['clock_out'], total, ot_actual, ot_earned)
if data.get('lunch'):
self.db.update_lunch_break(date_str, True)
if data.get('dinner'):
self.db.update_dinner_break(date_str, True)
if ot_earned > 0:
self.db.add_overtime_earned(wid, ot_earned, date_str)
self._refresh_calendar()
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
except Exception as e:
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
def _open_edit_dialog(self, date_str: str):
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
from PyQt5.QtCore import QDate
y, m, d = date_str.split('-')
self.calendar.setSelectedDate(QDate(int(y), int(m), int(d)))
self.date_selected(self.calendar.selectedDate())
# 사용자가 화면 하단에 표시된 "✏️ 시간 수정" 버튼 클릭하면 편집
def _delete_record(self, date_str: str):
reply = QMessageBox.question(
self, "삭제 확인",
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
conn = self.db.get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (date_str,))
cursor.execute("DELETE FROM break_records WHERE date = ?", (date_str,))
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
conn.commit()
self._refresh_calendar()
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
except Exception as e:
conn.rollback()
QMessageBox.critical(self, "오류", str(e))
finally:
conn.close()
def _refresh_calendar(self):
"""캘린더 마킹 갱신."""
if hasattr(self, 'load_calendar_data'):
self.load_calendar_data()
elif hasattr(self, 'load_records'):
self.load_records()
def date_selected(self, qdate): def date_selected(self, qdate):
"""날짜 선택 시""" """날짜 선택 시"""
selected_date = qdate.toPyDate() selected_date = qdate.toPyDate()
+87 -8
View File
@@ -50,12 +50,7 @@ def make_chart_widget(parent=None) -> QWidget:
def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: 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): if not getattr(widget, '_figure', None):
return return
fig = widget._figure fig = widget._figure
@@ -67,17 +62,101 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
return return
dates = [r['date'][5:] for r in records] # MM-DD만 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] 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] 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)] base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
ax = fig.add_subplot(111) ax = fig.add_subplot(111)
ax.bar(dates, base, label='정상', color='#4a90e2') bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b') bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
ax.set_ylabel('시간') ax.set_ylabel('시간')
ax.legend(loc='upper left', fontsize=8) ax.legend(loc='upper left', fontsize=8)
ax.tick_params(axis='x', labelrotation=45, labelsize=8) ax.tick_params(axis='x', labelrotation=45, labelsize=8)
ax.grid(axis='y', alpha=0.3) ax.grid(axis='y', alpha=0.3)
# 호버 annotation 설정
annot = ax.annotate(
"", xy=(0, 0), xytext=(15, 15),
textcoords="offset points",
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
color="white", fontsize=9,
arrowprops=dict(arrowstyle="->", color="#888"),
)
annot.set_visible(False)
def on_hover(event):
if event.inaxes != ax:
if annot.get_visible():
annot.set_visible(False)
widget._canvas.draw_idle()
return
for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')):
for i, bar in enumerate(bars):
if bar.contains(event)[0]:
h = hours[i]; ot = overtimes[i]
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
if ot > 0:
text += f"\n연장 +{ot:.1f}h"
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
annot.set_text(text)
annot.set_visible(True)
widget._canvas.draw_idle()
return
if annot.get_visible():
annot.set_visible(False)
widget._canvas.draw_idle()
widget._canvas.mpl_connect("motion_notify_event", on_hover)
widget._canvas.draw()
def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
"""출근 시각 분포 히스토그램 (30분 빈)."""
if not getattr(widget, '_figure', None):
return
fig = widget._figure
fig.clear()
if not records:
ax = fig.add_subplot(111)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
widget._canvas.draw()
return
# 출근 시각을 분 단위로 (00:00=0)
minutes_list = []
for r in records:
ci = r.get('clock_in')
if not ci:
continue
parts = ci.split(':')
if len(parts) >= 2:
try:
minutes_list.append(int(parts[0]) * 60 + int(parts[1]))
except ValueError:
pass
if not minutes_list:
return
# 30분 빈
bin_size = 30
min_m = (min(minutes_list) // bin_size) * bin_size
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
bins = list(range(min_m, max_m + bin_size, bin_size))
ax = fig.add_subplot(111)
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
avg = sum(minutes_list) / len(minutes_list)
ax.axvline(avg, color='#ff6b6b', linestyle='--',
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
ax.set_xticks([m for m in bins if m % 60 == 0])
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
rotation=45, fontsize=8)
ax.set_ylabel('일수')
ax.set_title('출근 시각 분포')
ax.legend(loc='upper right', fontsize=8)
ax.grid(axis='y', alpha=0.3)
widget._canvas.draw() widget._canvas.draw()
+6 -6
View File
@@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 안내 문구 # 안내 문구
info_label = QLabel("오늘의 출근시간을 입력해주세요") info_label = QLabel(tr('dlg.clock_in.prompt'))
info_label.setObjectName("field_label") info_label.setObjectName("field_label")
info_label.setAlignment(Qt.AlignCenter) info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label) layout.addWidget(info_label)
# 시간 입력 # 시간 입력
time_layout = QHBoxLayout() time_layout = QHBoxLayout()
time_label = QLabel("출근시간:") time_label = QLabel(tr('dlg.clock_in.label'))
time_label.setObjectName("field_label") time_label.setObjectName("field_label")
self.time_edit = QTimeEdit() self.time_edit = QTimeEdit()
@@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
# 빠른 선택 버튼 # 빠른 선택 버튼
quick_layout = QHBoxLayout() quick_layout = QHBoxLayout()
quick_label = QLabel("빠른 선택:") quick_label = QLabel(tr('dlg.clock_in.quick'))
quick_label.setObjectName("field_label") quick_label.setObjectName("field_label")
btn_8am = QPushButton("08:00") btn_8am = QPushButton("08:00")
btn_9am = QPushButton("09:00") btn_9am = QPushButton("09:00")
btn_10am = QPushButton("10: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]: for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
btn.setMinimumHeight(30) btn.setMinimumHeight(30)
@@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
ok_button = QPushButton("확인") ok_button = QPushButton(tr('btn.confirm'))
ok_button.setObjectName("btn_primary") ok_button.setObjectName("btn_primary")
ok_button.setMinimumHeight(40) ok_button.setMinimumHeight(40)
ok_button.clicked.connect(self.accept) ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.setMinimumHeight(40) cancel_button.setMinimumHeight(40)
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
+56
View 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')
)
+80 -1
View File
@@ -17,6 +17,58 @@ class NotificationOrchestrator:
self.notifier = window.notifier self.notifier = window.notifier
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만) self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
def maybe_send_weekly_report(self, now: datetime) -> None:
"""월요일 첫 update_display 호출 시 지난주 요약 발송 (시스템 + Discord).
notification_log로 중복 가드. 월요일이 아니거나 이미 보냈으면 no-op.
"""
if now.weekday() != 0: # 0=월요일
return
if self.db.has_notification_today('system', 'weekly_report'):
return
# 지난주 데이터 (월~일)
from datetime import timedelta as _td
last_mon = now.date() - _td(days=7)
last_sun = now.date() - _td(days=1)
records = self.db.get_work_records_by_range(last_mon.isoformat(), last_sun.isoformat())
closed = [r for r in records if r.get('clock_out')]
if not closed:
return # 지난주 기록 없음
total_h = sum((r.get('total_hours') or 0) for r in closed)
ot_total = sum((r.get('overtime_minutes') or 0) for r in closed)
ot_h, ot_m = ot_total // 60, ot_total % 60
avg_h = total_h / len(closed) if closed else 0
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
title = "📊 지난주 요약"
body = (f"기간: {last_mon} ~ {last_sun}\n"
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
f"일 평균: {avg_h:.1f}시간\n"
f"연장근무: {ot_h}시간 {ot_m}분\n"
f"가장 긴 날: {longest_str}")
self.notifier.notification_signal.emit(title, body)
self.db.log_notification('system', 'weekly_report')
# Discord 도 옵션 활성 시 push
if self.db.get_setting('discord_notif_clock_out', 'true').lower() == 'true':
url = self.db.get_setting('discord_webhook_url', '') or ''
if url:
try:
from utils.discord_webhook import send, COLOR_BLUE
fields = [
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
{"name": "가장 긴 날", "value": longest_str, "inline": False},
]
ok = send(url, "📊 지난주 요약",
f"기간: {last_mon} ~ {last_sun}",
color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception:
pass
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None: def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립 # 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
@@ -26,15 +78,42 @@ class NotificationOrchestrator:
if remaining_seconds < 0: if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60))) n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적 # 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute: if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute self._last_5min_bucket = now.minute
# 월요일 첫 출근 시 지난주 리포트
self.maybe_send_weekly_report(now)
# 휴식 권고 (장시간 연속 근무)
break_minutes = self.db.get_total_break_minutes_today()
n.check_health_break(self.window.clock_in_time, break_minutes, now)
consecutive = self.db.get_consecutive_overtime_days() consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3: if consecutive >= 3:
n.notify_health_warning(consecutive) n.notify_health_warning(consecutive)
self._discord_health(consecutive, break_minutes, now)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0) weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52: if weekly_hours > 52:
n.notify_weekly_hours(weekly_hours) n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance() balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200: if balance_minutes >= 1200:
n.notify_overtime_threshold(balance_minutes / 60.0) n.notify_overtime_threshold(balance_minutes / 60.0)
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
"""건강 경고 Discord push (옵션)."""
if self.db.has_notification_today('discord', 'health'):
return
if self.db.get_setting('discord_notif_health', 'true').lower() != 'true':
return
url = self.db.get_setting('discord_webhook_url', '') or ''
if not url:
return
try:
from utils import discord_webhook
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
ok = discord_webhook.send_health_warning(url, elapsed)
self.db.log_notification('discord', 'health', success=ok)
except Exception:
pass
+100
View File
@@ -0,0 +1,100 @@
"""
목표 진행률 위젯.
월 연장근무 상한 + 일평균 목표를 stats_view 또는 메인에 표시.
설정값이 0이면 비활성 (위젯 자체 hide).
"""
from __future__ import annotations
from datetime import datetime, date
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt5.QtCore import Qt
class GoalWidget(QWidget):
"""월간 목표 진행률 표시."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
layout = QVBoxLayout()
layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(4)
title = QLabel("🎯 이번 달 목표")
title.setStyleSheet("font-weight: bold;")
layout.addWidget(title)
# 연장근무 상한
ot_row = QHBoxLayout()
self.ot_label = QLabel("연장근무:")
self.ot_label.setFixedWidth(100)
self.ot_bar = QProgressBar()
self.ot_bar.setTextVisible(True)
self.ot_bar.setFixedHeight(18)
ot_row.addWidget(self.ot_label)
ot_row.addWidget(self.ot_bar, 1)
layout.addLayout(ot_row)
# 일평균
avg_row = QHBoxLayout()
self.avg_label = QLabel("일평균:")
self.avg_label.setFixedWidth(100)
self.avg_bar = QProgressBar()
self.avg_bar.setTextVisible(True)
self.avg_bar.setFixedHeight(18)
avg_row.addWidget(self.avg_label)
avg_row.addWidget(self.avg_bar, 1)
layout.addLayout(avg_row)
self.setLayout(layout)
def refresh(self):
"""현재 설정값과 이번 달 통계로 진행률 갱신. 0=비활성 시 row 숨김."""
try:
ot_target = int(self.db.get_setting('goal_overtime_max_monthly', '0') or 0)
avg_target = float(self.db.get_setting('goal_avg_hours_daily', '0') or 0)
except (ValueError, TypeError):
ot_target, avg_target = 0, 0.0
# 둘 다 비활성이면 위젯 자체 숨김
if ot_target <= 0 and avg_target <= 0:
self.setVisible(False)
return
self.setVisible(True)
now = datetime.now()
stats = self.db.get_monthly_stats(now.year, now.month)
ot_total = (stats.get('total_overtime_minutes') or 0)
total_h = stats.get('total_hours') or 0
work_days = stats.get('work_days') or 1
# 연장근무 상한 (낮을수록 좋음)
if ot_target > 0:
self.ot_label.setVisible(True)
self.ot_bar.setVisible(True)
self.ot_bar.setMaximum(ot_target)
self.ot_bar.setValue(min(ot_total, ot_target))
ratio = ot_total / ot_target if ot_target else 0
ot_h, ot_m = ot_total // 60, ot_total % 60
tg_h, tg_m = ot_target // 60, ot_target % 60
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.ot_label.setVisible(False)
self.ot_bar.setVisible(False)
# 일평균 (목표 시간보다 적으면 좋음)
if avg_target > 0:
self.avg_label.setVisible(True)
self.avg_bar.setVisible(True)
avg = total_h / work_days if work_days else 0
self.avg_bar.setMaximum(int(avg_target * 100))
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
ratio = avg / avg_target if avg_target else 0
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else:
self.avg_label.setVisible(False)
self.avg_bar.setVisible(False)
+14 -1
View File
@@ -51,18 +51,31 @@ class HelpView(QDialog):
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 10, 20, 20) button_layout.setContentsMargins(20, 10, 20, 20)
# 온보딩 다시 보기 (왼쪽)
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
onboarding_button.setMinimumHeight(40)
onboarding_button.clicked.connect(self._reopen_onboarding)
button_layout.addWidget(onboarding_button)
button_layout.addStretch() button_layout.addStretch()
close_button = QPushButton(tr('btn.close')) close_button = QPushButton(tr('btn.close'))
close_button.setObjectName("btn_primary") close_button.setObjectName("btn_primary")
close_button.setMinimumHeight(40) close_button.setMinimumHeight(40)
close_button.setMinimumWidth(120) close_button.setMinimumWidth(120)
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
button_layout.addWidget(close_button) button_layout.addWidget(close_button)
button_layout.addStretch()
main_layout.addLayout(button_layout) main_layout.addLayout(button_layout)
self.setLayout(main_layout) self.setLayout(main_layout)
def _reopen_onboarding(self):
"""부모 윈도우의 show_onboarding 호출 후 도움말 닫음."""
self.close()
if self.parent() and hasattr(self.parent(), 'show_onboarding'):
self.parent().show_onboarding()
def _make_tab(self, html: str) -> QWidget: def _make_tab(self, html: str) -> QWidget:
container = QWidget() container = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
+94
View 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)
+112
View File
@@ -0,0 +1,112 @@
"""
연차 사용 캘린더 시각화.
QCalendarWidget에 사용 연차일을 색칠로 표시.
- 1.0일: 진한 색
- 0.5일(반차): 중간 색
- 0.25일(반반차): 옅은 색
"""
from __future__ import annotations
from datetime import datetime
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from ui.styles import apply_dark_titlebar
class LeaveCalendarView(QDialog):
"""연차 캘린더 시각화."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("📅 연차 캘린더")
self.setModal(True)
self.setMinimumSize(540, 480)
self._build_ui()
self._mark_dates()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 헤더: 잔여 + 범례
header = QHBoxLayout()
balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
layout.addLayout(header)
# 범례
legend = QHBoxLayout()
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
legend.addStretch()
layout.addLayout(legend)
# 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
layout.addWidget(self.calendar, 1)
# 선택 일자 정보
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
layout.addWidget(self.detail_label)
# 닫기 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
def _mark_dates(self):
"""연차 사용 일자에 색상 표시."""
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
d = datetime.strptime(r['date'], '%Y-%m-%d').date()
except (ValueError, TypeError):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))
self.calendar.setDateTextFormat(qd, fmt)
def _on_date_click(self, qdate):
date_str = qdate.toString('yyyy-MM-dd')
records = self.db.get_all_leave_records(limit=365)
match = [r for r in records if r['date'] == date_str]
if not match:
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
return
parts = []
for r in match:
t = r.get('leave_type', 'annual')
d = float(r.get('days') or 0)
memo = r.get('memo') or ''
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
+59 -47
View File
@@ -40,27 +40,32 @@ class LeaveView(QDialog):
# 제목 + 잔액 + 설정 한 줄 # 제목 + 잔액 + 설정 한 줄
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
title = QLabel("연차 관리") title = QLabel(tr('view.leave.title'))
title.setObjectName("dialog_title") title.setObjectName("dialog_title")
header_layout.addWidget(title) header_layout.addWidget(title)
header_layout.addStretch() header_layout.addStretch()
self.balance_label = QLabel("잔여: 0일") self.balance_label = QLabel(tr('view.leave.balance_zero'))
self.balance_label.setObjectName("badge_leave") self.balance_label.setObjectName("badge_leave")
header_layout.addWidget(self.balance_label) 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) set_balance_button.clicked.connect(self.set_balance)
header_layout.addWidget(set_balance_button) header_layout.addWidget(set_balance_button)
layout.addLayout(header_layout) layout.addLayout(header_layout)
# 사용 내역 # 사용 내역
used_group = QGroupBox("📤 사용 내역") used_group = QGroupBox(tr('view.leave.used_group'))
used_layout = QVBoxLayout() used_layout = QVBoxLayout()
used_layout.setSpacing(4) used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6) used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget() self.used_table = QTableWidget()
self.used_table.setColumnCount(4) 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(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
@@ -77,22 +82,33 @@ class LeaveView(QDialog):
# 버튼들 # 버튼들
button_layout = QHBoxLayout() 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) add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button) 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)
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
button_layout.addWidget(close_button) button_layout.addWidget(close_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
self.setLayout(layout) self.setLayout(layout)
def _show_calendar(self):
from ui.leave_calendar_view import LeaveCalendarView
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def load_data(self): def load_data(self):
"""데이터 로드""" """데이터 로드"""
# 잔액 업데이트 # 잔액 업데이트
balance = self.db.get_leave_balance() balance = self.db.get_leave_balance()
hours = balance * 8 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) records = self.db.get_leave_records(exclude_bulk=True)
@@ -134,7 +150,7 @@ class LeaveView(QDialog):
return return
menu = QMenu(self) menu = QMenu(self)
delete_action = QAction("삭제", self) delete_action = QAction(tr('btn.delete_short'), self)
delete_action.triggered.connect(self.delete_leave_record) delete_action.triggered.connect(self.delete_leave_record)
menu.addAction(delete_action) menu.addAction(delete_action)
menu.exec_(self.used_table.viewport().mapToGlobal(position)) menu.exec_(self.used_table.viewport().mapToGlobal(position))
@@ -154,11 +170,10 @@ class LeaveView(QDialog):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"삭제 확인", tr('msg.confirm_delete.title'),
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n" tr('view.leave.delete_confirm_body',
f"날짜: {date_item.text()}\n" date=date_item.text(), type=type_item.text(),
f"구분: {type_item.text()}\n" days=days_item.text()),
f"사용: {days_item.text()}",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@@ -173,13 +188,12 @@ class LeaveView(QDialog):
hours, ok = QInputDialog.getDouble( hours, ok = QInputDialog.getDouble(
self, self,
"연차 시간 설정", tr('view.leave.set_title'),
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n" tr('view.leave.set_prompt'),
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
current_hours, current_hours,
0.0, 0.0,
999.0, 999.0,
1 # 소수점 첫째자리까지 (0.5 단위) 1
) )
if ok: if ok:
@@ -190,8 +204,8 @@ class LeaveView(QDialog):
self.db.set_leave_balance(days) self.db.set_leave_balance(days)
QMessageBox.information( QMessageBox.information(
self, self,
"설정 완료", tr('view.leave.set_done_title'),
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다." tr('view.leave.set_done_body', days=days, hours=hours)
) )
self.load_data() self.load_data()
@@ -213,7 +227,7 @@ class AddLeaveDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("연차 사용 기록 추가") self.setWindowTitle(tr('view.leave.add_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(360) self.setMinimumWidth(360)
@@ -222,14 +236,14 @@ class AddLeaveDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("연차 사용 기록 추가") title = QLabel(tr('view.leave.add_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
# 날짜 + 구분 한 줄 # 날짜 + 구분 한 줄
row1 = QHBoxLayout() row1 = QHBoxLayout()
date_label = QLabel("날짜:") date_label = QLabel(tr('view.leave.field_date'))
date_label.setObjectName("field_label") date_label.setObjectName("field_label")
date_label.setFixedWidth(40) date_label.setFixedWidth(40)
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
@@ -238,14 +252,14 @@ class AddLeaveDialog(QDialog):
row1.addWidget(date_label) row1.addWidget(date_label)
row1.addWidget(self.date_edit) row1.addWidget(self.date_edit)
row1.addSpacing(8) row1.addSpacing(8)
type_label = QLabel("구분:") type_label = QLabel(tr('view.leave.field_type'))
type_label.setObjectName("field_label") type_label.setObjectName("field_label")
type_label.setFixedWidth(40) type_label.setFixedWidth(40)
self.type_combo = QComboBox() self.type_combo = QComboBox()
self.type_combo.addItem("연차", "annual") self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
self.type_combo.addItem("반차", "half") self.type_combo.addItem(tr('view.leave.type_half'), "half")
self.type_combo.addItem("반반차", "quarter") self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
self.type_combo.addItem("시간", "hourly") self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
self.type_combo.currentIndexChanged.connect(self.on_type_changed) self.type_combo.currentIndexChanged.connect(self.on_type_changed)
row1.addWidget(type_label) row1.addWidget(type_label)
row1.addWidget(self.type_combo) row1.addWidget(self.type_combo)
@@ -253,14 +267,14 @@ class AddLeaveDialog(QDialog):
# 사용 시간 (시간 연차용) # 사용 시간 (시간 연차용)
hours_layout = QHBoxLayout() hours_layout = QHBoxLayout()
hours_label = QLabel("시간:") hours_label = QLabel(tr('view.leave.field_hours'))
hours_label.setObjectName("field_label") hours_label.setObjectName("field_label")
hours_label.setFixedWidth(40) hours_label.setFixedWidth(40)
self.hours_spin = QDoubleSpinBox() self.hours_spin = QDoubleSpinBox()
self.hours_spin.setRange(0.5, 8.0) self.hours_spin.setRange(0.5, 8.0)
self.hours_spin.setSingleStep(0.5) self.hours_spin.setSingleStep(0.5)
self.hours_spin.setValue(1.0) self.hours_spin.setValue(1.0)
self.hours_spin.setSuffix(" 시간") self.hours_spin.setSuffix(' ' + tr('label.unit_hour'))
self.hours_spin.setEnabled(False) self.hours_spin.setEnabled(False)
hours_layout.addWidget(hours_label) hours_layout.addWidget(hours_label)
hours_layout.addWidget(self.hours_spin) hours_layout.addWidget(self.hours_spin)
@@ -268,27 +282,27 @@ class AddLeaveDialog(QDialog):
# 사유 # 사유
memo_layout = QHBoxLayout() memo_layout = QHBoxLayout()
memo_label = QLabel("사유:") memo_label = QLabel(tr('view.leave.field_reason'))
memo_label.setObjectName("field_label") memo_label.setObjectName("field_label")
memo_label.setFixedWidth(40) memo_label.setFixedWidth(40)
self.memo_input = QLineEdit() 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(memo_label)
memo_layout.addWidget(self.memo_input) memo_layout.addWidget(self.memo_input)
layout.addLayout(memo_layout) layout.addLayout(memo_layout)
# 안내 # 안내
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.") info_label = QLabel(tr('view.leave.note_auto_deduct'))
info_label.setObjectName("note_text") info_label.setObjectName("note_text")
layout.addWidget(info_label) layout.addWidget(info_label)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary") save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save_record) save_button.clicked.connect(self.save_record)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
layout.addLayout(button_layout) layout.addLayout(button_layout)
@@ -326,8 +340,8 @@ class AddLeaveDialog(QDialog):
if current_balance < days: if current_balance < days:
QMessageBox.warning( QMessageBox.warning(
self, self,
"잔여 연차 부족", tr('view.leave.short_title'),
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일" tr('view.leave.short_body', balance=current_balance, req=days)
) )
return return
@@ -335,12 +349,10 @@ class AddLeaveDialog(QDialog):
hours = days * 8 hours = days * 8
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"연차 사용 기록 추가", tr('view.leave.confirm_title'),
f"날짜: {date}\n" tr('view.leave.confirm_body',
f"구분: {leave_type_name}\n" date=date, type=leave_type_name, days=days, hours=hours,
f"사용: {days}일 ({hours}시간)\n" reason=(memo if memo else '-')),
f"사유: {memo if memo else '(없음)'}\n\n"
f"이 기록을 추가하시겠습니까?",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@@ -350,15 +362,15 @@ class AddLeaveDialog(QDialog):
self.db.use_leave(days, date, leave_type_name, memo) self.db.use_leave(days, date, leave_type_name, memo)
QMessageBox.information( QMessageBox.information(
self, self,
"추가 완료", tr('view.leave.added_title'),
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다." tr('view.leave.added_body', days=days, hours=hours)
) )
self.accept() self.accept()
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, self,
"오류", tr('view.leave.error_title'),
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}" tr('view.leave.error_body', err=str(e))
) )
+183 -31
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QLabel, QPushButton, QProgressBar, QHBoxLayout, QLabel, QPushButton, QProgressBar,
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon, QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
QShortcut) QShortcut, QDialog)
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
@@ -59,6 +59,13 @@ class MainWindow(QMainWindow):
from core.i18n import set_language from core.i18n import set_language
set_language(self.db.get_setting(LANGUAGE, 'ko') or 'ko') set_language(self.db.get_setting(LANGUAGE, 'ko') or 'ko')
# 접근성 — 글꼴 크기 + 고대비
try:
from ui.accessibility import apply_from_settings as _apply_a11y
_apply_a11y(self.db)
except Exception:
pass
# TimeCalculator 초기화 (설정값 반영) # TimeCalculator 초기화 (설정값 반영)
settings = self.db.get_settings() settings = self.db.get_settings()
@@ -74,13 +81,15 @@ class MainWindow(QMainWindow):
self.notifier = Notifier(self, db=self.db) self.notifier = Notifier(self, db=self.db)
self.notifier.notification_signal.connect(self.show_notification) self.notifier.notification_signal.connect(self.show_notification)
# 책임 분리된 컨트롤러들 (1Hz hot path) # 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
from ui.controllers.lock_monitor import LockMonitor from ui.controllers.lock_monitor import LockMonitor
from ui.controllers.auto_lunch import AutoLunchManager from ui.controllers.auto_lunch import AutoLunchManager
from ui.controllers.notification_orchestrator import NotificationOrchestrator from ui.controllers.notification_orchestrator import NotificationOrchestrator
from ui.controllers.meal_controller import MealController
self._lock_monitor = LockMonitor(self) self._lock_monitor = LockMonitor(self)
self._auto_lunch = AutoLunchManager(self) self._auto_lunch = AutoLunchManager(self)
self._notif_orch = NotificationOrchestrator(self) self._notif_orch = NotificationOrchestrator(self)
self._meal = MealController(self)
# 시스템 트레이 # 시스템 트레이
self.tray_icon = SystemTrayIcon(self) self.tray_icon = SystemTrayIcon(self)
@@ -177,7 +186,11 @@ class MainWindow(QMainWindow):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
from core.version import __version__ from core.version import __version__
from ui.i18n_runtime import register
self._app_version = __version__
self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}") self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
register(self, 'window.main_title', setter='setWindowTitle',
post=lambda t: f"⏰ {t} v{__version__}")
self.setGeometry(100, 100, 500, 620) self.setGeometry(100, 100, 500, 620)
self.setMinimumSize(480, 520) self.setMinimumSize(480, 520)
@@ -219,6 +232,11 @@ class MainWindow(QMainWindow):
self.date_label.setAlignment(Qt.AlignCenter) self.date_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.date_label) main_layout.addWidget(self.date_label)
# 1.5 오늘 요약 카드 (퇴근 후 표시, 평소엔 숨김)
from ui.today_summary import TodaySummaryCard
self.today_summary_card = TodaySummaryCard()
main_layout.addWidget(self.today_summary_card)
# 2. 출근 정보 그룹 # 2. 출근 정보 그룹
clock_in_group = self.create_clock_in_group() clock_in_group = self.create_clock_in_group()
main_layout.addWidget(clock_in_group) main_layout.addWidget(clock_in_group)
@@ -240,10 +258,20 @@ class MainWindow(QMainWindow):
self.lunch_button = QPushButton(tr('btn.lunch_add')) self.lunch_button = QPushButton(tr('btn.lunch_add'))
self.lunch_button.setCheckable(True) self.lunch_button.setCheckable(True)
self.lunch_button.clicked.connect(self.toggle_lunch_break) self.lunch_button.clicked.connect(self.toggle_lunch_break)
self.lunch_button.setContextMenuPolicy(Qt.CustomContextMenu)
self.lunch_button.customContextMenuRequested.connect(
lambda pos: self._show_meal_context('lunch', self.lunch_button, pos)
)
self.lunch_button.setToolTip("좌클릭: 토글 / 우클릭: 실제 시간 입력")
self.dinner_button = QPushButton(tr('btn.dinner_add')) self.dinner_button = QPushButton(tr('btn.dinner_add'))
self.dinner_button.setCheckable(True) self.dinner_button.setCheckable(True)
self.dinner_button.clicked.connect(self.toggle_dinner_break) self.dinner_button.clicked.connect(self.toggle_dinner_break)
self.dinner_button.setContextMenuPolicy(Qt.CustomContextMenu)
self.dinner_button.customContextMenuRequested.connect(
lambda pos: self._show_meal_context('dinner', self.dinner_button, pos)
)
self.dinner_button.setToolTip("좌클릭: 토글 / 우클릭: 실제 시간 입력")
meal_button_layout.addWidget(self.lunch_button) meal_button_layout.addWidget(self.lunch_button)
meal_button_layout.addWidget(self.dinner_button) meal_button_layout.addWidget(self.dinner_button)
@@ -304,6 +332,14 @@ class MainWindow(QMainWindow):
help_button = QPushButton(tr('menu.help')) help_button = QPushButton(tr('menu.help'))
settings_button = QPushButton(tr('menu.settings')) settings_button = QPushButton(tr('menu.settings'))
# 런타임 i18n 등록
for btn, key in [(stats_button, 'menu.stats'),
(calendar_button, 'menu.calendar'),
(report_button, 'menu.daily_report'),
(help_button, 'menu.help'),
(settings_button, 'menu.settings')]:
register(btn, key)
for btn in [stats_button, calendar_button, report_button, help_button, settings_button]: for btn in [stats_button, calendar_button, report_button, help_button, settings_button]:
bottom_layout.addWidget(btn) bottom_layout.addWidget(btn)
@@ -356,7 +392,11 @@ class MainWindow(QMainWindow):
self.clock_in_value = QLabel("--:--:--") self.clock_in_value = QLabel("--:--:--")
self.clock_in_value.setObjectName("time_value") self.clock_in_value.setObjectName("time_value")
self.clock_in_value.setMinimumWidth(90) self.clock_in_value.setMinimumWidth(90)
self.edit_clock_in_button = QPushButton("수정") # 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
self.clock_in_value.setCursor(Qt.PointingHandCursor)
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
self.edit_clock_in_button = QPushButton("✏️ 수정")
self.edit_clock_in_button.setObjectName("btn_small") self.edit_clock_in_button.setObjectName("btn_small")
self.edit_clock_in_button.setFixedWidth(70) self.edit_clock_in_button.setFixedWidth(70)
self.edit_clock_in_button.clicked.connect(self.manual_clock_in) self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
@@ -724,40 +764,20 @@ class MainWindow(QMainWindow):
self.date_label.setText(date_str) self.date_label.setText(date_str)
def toggle_lunch_break(self): def toggle_lunch_break(self):
"""점심시간 토글""" """점심시간 토글 — MealController 위임."""
self.lunch_break_enabled = self.lunch_button.isChecked() self._meal.toggle_lunch()
self.update_lunch_status()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if self.lunch_break_enabled:
self.auto_lunch_applied_today = True
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, self.lunch_break_enabled)
def toggle_dinner_break(self): def toggle_dinner_break(self):
"""저녁시간 토글""" """저녁시간 토글 — MealController 위임."""
self.dinner_break_enabled = self.dinner_button.isChecked() self._meal.toggle_dinner()
self.update_dinner_status()
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, self.dinner_break_enabled)
def update_lunch_status(self): def update_lunch_status(self):
"""점심시간 상태 업데이트""" """점심시간 상태 업데이트 — MealController 위임."""
self.lunch_button.setText( self._meal.refresh_lunch_label()
tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add')
)
def update_dinner_status(self): def update_dinner_status(self):
"""저녁시간 상태 업데이트""" """저녁시간 상태 업데이트 — MealController 위임."""
self.dinner_button.setText( self._meal.refresh_dinner_label()
tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add')
)
def update_overtime_balance(self): def update_overtime_balance(self):
"""연장근무 잔액 업데이트""" """연장근무 잔액 업데이트"""
@@ -1169,6 +1189,12 @@ class MainWindow(QMainWindow):
# 잔액 업데이트 # 잔액 업데이트
self.update_overtime_balance() self.update_overtime_balance()
# Discord 웹훅 push (옵션)
self._discord_push_clock_out(now, total_hours, overtime_actual, overtime_earned)
# 오늘 요약 카드 표시
self._show_today_summary(total_hours, overtime_actual, overtime_earned, break_minutes)
def cancel_clock_out(self): def cancel_clock_out(self):
"""퇴근 취소""" """퇴근 취소"""
# 확인 대화상자 # 확인 대화상자
@@ -1579,6 +1605,13 @@ class MainWindow(QMainWindow):
f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}" f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}"
) )
# Discord 웹훅 (옵션)
self._discord_push_clock_in(selected_time)
# 오늘 요약 카드 숨김 (새 출근 시작)
if hasattr(self, 'today_summary_card'):
self.today_summary_card.hide()
def show_stats(self): def show_stats(self):
"""통계 창 표시""" """통계 창 표시"""
dialog = StatsView(self, self.db) dialog = StatsView(self, self.db)
@@ -1619,6 +1652,118 @@ class MainWindow(QMainWindow):
dialog = HelpView(self) dialog = HelpView(self)
dialog.exec_() dialog.exec_()
def _show_meal_context(self, meal_type: str, button, pos):
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
from PyQt5.QtWidgets import QMenu
from ui.meal_time_dialog import MealTimeDialog
menu = QMenu(self)
title = "점심" if meal_type == 'lunch' else "저녁"
edit_action = menu.addAction(f"⏱ {title} 실제 시간 입력...")
global_pos = button.mapToGlobal(pos)
action = menu.exec_(global_pos)
if action != edit_action:
return
if not self.is_clocked_in:
QMessageBox.warning(self, "출근 필요", "출근 후에만 식사 시간을 기록할 수 있습니다.")
return
default_min = (self.time_calc.lunch_duration_minutes
if meal_type == 'lunch'
else self.time_calc.dinner_duration_minutes)
dialog = MealTimeDialog(self, meal_type=meal_type, default_minutes=default_min)
if dialog.exec_() != QDialog.Accepted:
return
start, end, minutes = dialog.get_times()
if minutes <= 0:
QMessageBox.warning(self, "입력 오류", "종료 시간이 시작보다 빠릅니다.")
return
today = datetime.now().date().isoformat()
self.db.add_meal_record(today, start, end, meal_type=meal_type)
# 자동 토글 ON
if meal_type == 'lunch':
self.lunch_break_enabled = True
self.lunch_button.setChecked(True)
self.update_lunch_status()
self.db.update_lunch_break(today, True)
self.auto_lunch_applied_today = True
else:
self.dinner_break_enabled = True
self.dinner_button.setChecked(True)
self.update_dinner_status()
self.db.update_dinner_break(today, True)
QMessageBox.information(self, "기록 완료",
f"{title} {minutes}분 기록되었습니다.\n({start} ~ {end})")
def show_onboarding(self):
"""온보딩 위저드 다시 보기."""
from ui.onboarding_view import OnboardingWizard
wizard = OnboardingWizard(self.db, self)
if wizard.exec_():
self.reload_settings()
QMessageBox.information(self, "설정 업데이트", "변경된 설정이 즉시 반영되었습니다.")
# ===== Discord 웹훅 push (옵션, 실패 silent) =====
def _show_today_summary(self, total_hours, overtime_actual, overtime_earned, break_minutes):
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
if not hasattr(self, 'today_summary_card'):
return
# 점심 시간 계산 (lunch_break_enabled면 설정값, 아니면 0)
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
# 추정 급여 (옵션)
salary_text = ""
if self.db.get_setting('salary_enabled', 'false').lower() == 'true':
try:
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
if wage > 0:
from core.salary import estimate_pay, format_won
fake_record = {'total_hours': total_hours, 'overtime_minutes': overtime_actual}
result = estimate_pay([fake_record], wage, rate)
salary_text = f"오늘 추정: {format_won(result['total'])}"
except (ValueError, TypeError):
pass
self.today_summary_card.show_summary(
total_hours=total_hours,
lunch_minutes=lunch_min,
break_minutes=break_minutes,
overtime_actual=overtime_actual,
overtime_earned=overtime_earned,
salary_text=salary_text,
)
def _discord_url(self) -> str:
return self.db.get_setting('discord_webhook_url', '') or ''
def _discord_push_clock_in(self, when):
if self.db.get_setting('discord_notif_clock_in', 'true').lower() != 'true':
return
url = self._discord_url()
if not url:
return
try:
from utils import discord_webhook
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
self.db.log_notification('discord', 'clock_in', success=ok)
except Exception:
pass
def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned):
if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true':
return
url = self._discord_url()
if not url:
return
try:
from utils import discord_webhook
ok = discord_webhook.send_clock_out(
url, when.strftime('%H:%M:%S'),
total_hours, overtime_actual, overtime_earned,
)
self.db.log_notification('discord', 'clock_out', success=ok)
except Exception:
pass
def check_for_updates(self, silent: bool = False): def check_for_updates(self, silent: bool = False):
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용).""" """업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
from core.version import __version__ from core.version import __version__
@@ -1860,6 +2005,13 @@ class MainWindow(QMainWindow):
# auto_lunch 캐시 무효화 (설정에서 토글 가능하므로) # auto_lunch 캐시 무효화 (설정에서 토글 가능하므로)
self._auto_lunch.invalidate() self._auto_lunch.invalidate()
# 접근성 재적용 (글꼴 / 고대비)
try:
from ui.accessibility import apply_from_settings as _apply_a11y
_apply_a11y(self.db)
except Exception:
pass
# UI 업데이트 # UI 업데이트
self.update_overtime_balance() self.update_overtime_balance()
self.update_leave_balance() self.update_leave_balance()
+108
View File
@@ -0,0 +1,108 @@
"""
점심/저녁 실제 시간 입력 다이얼로그.
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QMessageBox)
from PyQt5.QtCore import QTime, Qt
from ui.styles import apply_dark_titlebar
class MealTimeDialog(QDialog):
"""점심/저녁 실제 시작·종료 시간 입력."""
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
super().__init__(parent)
self.meal_type = meal_type
title_kr = '점심' if meal_type == 'lunch' else '저녁'
self.setWindowTitle(f"{title_kr} 시간 입력")
self.setModal(True)
self.setFixedSize(360, 220)
layout = QVBoxLayout()
layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding-bottom: 6px;")
layout.addWidget(info)
# 시작
start_row = QHBoxLayout()
start_row.addWidget(QLabel("시작:"))
self.start_edit = QTimeEdit()
self.start_edit.setDisplayFormat("HH:mm")
# 기본값: 점심=12:00, 저녁=18:00
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
self.start_edit.setTime(default_start)
start_row.addWidget(self.start_edit)
start_row.addStretch()
layout.addLayout(start_row)
# 종료
end_row = QHBoxLayout()
end_row.addWidget(QLabel("종료:"))
self.end_edit = QTimeEdit()
self.end_edit.setDisplayFormat("HH:mm")
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
self.end_edit.setTime(default_end)
end_row.addWidget(self.end_edit)
end_row.addStretch()
layout.addLayout(end_row)
# 미리보기 라벨
self.preview = QLabel("")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
layout.addWidget(self.preview)
self._update_preview()
self.start_edit.timeChanged.connect(self._update_preview)
self.end_edit.timeChanged.connect(self._update_preview)
# 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
ok_btn = QPushButton("저장")
ok_btn.setObjectName("btn_primary")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("취소")
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
apply_dark_titlebar(self)
def _update_preview(self):
s = self.start_edit.time()
e = self.end_edit.time()
start_dt = datetime.combine(datetime.today(), s.toPyTime())
end_dt = datetime.combine(datetime.today(), e.toPyTime())
if end_dt < start_dt:
end_dt += timedelta(days=1)
minutes = int((end_dt - start_dt).total_seconds() / 60)
if minutes <= 0:
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
self.preview.setStyleSheet("color: #f44336;")
else:
self.preview.setText(f"총 {minutes}분")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
def get_times(self) -> tuple[str, str, int]:
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
s = self.start_edit.time().toPyTime()
e = self.end_edit.time().toPyTime()
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
s_dt = datetime.combine(datetime.today(), s)
e_dt = datetime.combine(datetime.today(), e)
if e_dt < s_dt:
e_dt += timedelta(days=1)
minutes = int((e_dt - s_dt).total_seconds() / 60)
return start_str, end_str, minutes
+322
View File
@@ -0,0 +1,322 @@
"""
첫 실행 온보딩 위저드.
5단계: 환영 → 근무패턴 → 출근감지 → 연차/시급 → Discord(옵션) → 완료
첫 실행 시 강제 표시. 완료 시 ONBOARDING_COMPLETED=true 저장.
재방문은 메인 메뉴 → "온보딩 다시 보기"로 가능.
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
QLabel, QRadioButton, QButtonGroup, QSpinBox,
QCheckBox, QLineEdit, QComboBox, QPushButton,
QMessageBox, QGroupBox)
from PyQt5.QtCore import Qt
from ui.styles import apply_dark_titlebar
# (label, work_minutes, lunch_minutes)
WORK_PRESETS = [
("표준 8시간 (점심 60분)", 480, 60),
("단축근무 7시간 30분 (점심 30분)", 450, 30),
("단축근무 7시간 (점심 60분)", 420, 60),
("단축근무 6시간 (점심 30분)", 360, 30),
("반일 4시간 (점심 0분)", 240, 0),
]
class WelcomePage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("👋 환영합니다!")
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
layout = QVBoxLayout()
intro = QLabel(
"이 앱은:\n"
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
"• 30분 단위 연장근무 적립\n"
"• 연차·반차·외출 시간 추적\n"
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
"[다음] 버튼을 눌러 시작하세요."
)
intro.setWordWrap(True)
layout.addWidget(intro)
self.setLayout(layout)
class WorkPatternPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🕘 근무 패턴")
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
layout = QVBoxLayout()
self.button_group = QButtonGroup(self)
for i, (label, _, _) in enumerate(WORK_PRESETS):
rb = QRadioButton(label)
self.button_group.addButton(rb, i)
layout.addWidget(rb)
if i == 0:
rb.setChecked(True)
# 사용자 정의
custom_box = QGroupBox("사용자 정의")
custom_layout = QHBoxLayout()
self.custom_radio = QRadioButton("직접 입력:")
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
self.hours_spin = QSpinBox()
self.hours_spin.setRange(0, 12)
self.hours_spin.setValue(8)
self.hours_spin.setSuffix(" 시간")
self.minutes_spin = QSpinBox()
self.minutes_spin.setRange(0, 59)
self.minutes_spin.setSingleStep(15)
self.minutes_spin.setSuffix(" 분")
self.lunch_spin = QSpinBox()
self.lunch_spin.setRange(0, 120)
self.lunch_spin.setSingleStep(5)
self.lunch_spin.setValue(60)
self.lunch_spin.setPrefix("점심 ")
self.lunch_spin.setSuffix(" 분")
custom_layout.addWidget(self.custom_radio)
custom_layout.addWidget(self.hours_spin)
custom_layout.addWidget(self.minutes_spin)
custom_layout.addWidget(self.lunch_spin)
custom_layout.addStretch()
custom_box.setLayout(custom_layout)
layout.addWidget(custom_box)
self.setLayout(layout)
def selected_minutes(self):
idx = self.button_group.checkedId()
if 0 <= idx < len(WORK_PRESETS):
_, wm, lm = WORK_PRESETS[idx]
return wm, lm
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
class ClockInDetectionPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("⏰ 출근 시간 감지 방식")
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
layout = QVBoxLayout()
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
self.option_boot.setChecked(True)
for opt in (self.option_boot, self.option_unlock, self.option_manual):
layout.addWidget(opt)
info = QLabel(
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;")
layout.addWidget(info)
layout.addStretch()
self.setLayout(layout)
def detection_mode(self) -> str:
if self.option_unlock.isChecked():
return 'unlock'
if self.option_manual.isChecked():
return 'manual'
return 'boot'
class LeaveSalaryPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
layout = QVBoxLayout()
# 연차
leave_box = QGroupBox("연간 연차")
leave_layout = QHBoxLayout()
self.leave_spin = QSpinBox()
self.leave_spin.setRange(0, 30)
self.leave_spin.setValue(15)
self.leave_spin.setSuffix(" 일")
leave_layout.addWidget(QLabel("내 연차:"))
leave_layout.addWidget(self.leave_spin)
leave_layout.addStretch()
leave_box.setLayout(leave_layout)
layout.addWidget(leave_box)
# 급여 (옵션)
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
salary_layout = QVBoxLayout()
self.salary_enabled = QCheckBox("급여 추정 활성화")
salary_layout.addWidget(self.salary_enabled)
wage_row = QHBoxLayout()
wage_row.addWidget(QLabel("시급:"))
self.wage_spin = QSpinBox()
self.wage_spin.setRange(0, 1000000)
self.wage_spin.setSingleStep(1000)
self.wage_spin.setSuffix(" 원/시간")
self.wage_spin.setValue(0)
self.wage_spin.setEnabled(False)
wage_row.addWidget(self.wage_spin)
wage_row.addStretch()
salary_layout.addLayout(wage_row)
rate_row = QHBoxLayout()
rate_row.addWidget(QLabel("연장수당 가산률:"))
self.rate_combo = QComboBox()
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
self.rate_combo.setCurrentIndex(1)
self.rate_combo.setEnabled(False)
rate_row.addWidget(self.rate_combo)
rate_row.addStretch()
salary_layout.addLayout(rate_row)
self.salary_enabled.toggled.connect(self.wage_spin.setEnabled)
self.salary_enabled.toggled.connect(self.rate_combo.setEnabled)
salary_box.setLayout(salary_layout)
layout.addWidget(salary_box)
layout.addStretch()
self.setLayout(layout)
class DiscordPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("💬 Discord 알림 (선택)")
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
layout = QVBoxLayout()
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
layout.addWidget(self.enable_check)
self.url_edit = QLineEdit()
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
self.url_edit.setEnabled(False)
layout.addWidget(self.url_edit)
guide = QLabel(
"셋업 방법:\n"
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
guide.setStyleSheet("color: #888; padding: 6px;")
guide.setWordWrap(True)
layout.addWidget(guide)
test_row = QHBoxLayout()
self.test_btn = QPushButton("테스트 메시지 보내기")
self.test_btn.setEnabled(False)
self.test_btn.clicked.connect(self._test_webhook)
test_row.addWidget(self.test_btn)
test_row.addStretch()
layout.addLayout(test_row)
self.enable_check.toggled.connect(self.url_edit.setEnabled)
self.enable_check.toggled.connect(self.test_btn.setEnabled)
layout.addStretch()
self.setLayout(layout)
def _test_webhook(self):
url = self.url_edit.text().strip()
if not url:
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
return
from utils import discord_webhook
ok = discord_webhook.send_test(url)
if ok:
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
else:
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
class FinishPage(QWizardPage):
def __init__(self):
super().__init__()
self.setTitle("🎉 준비 완료!")
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
layout = QVBoxLayout()
msg = QLabel(
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
"🕐 단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
" • Ctrl+, — 설정"
)
msg.setWordWrap(True)
layout.addWidget(msg)
self.setLayout(layout)
class OnboardingWizard(QWizard):
"""5단계 첫 실행 위저드. accept() 시 모든 설정 저장 + ONBOARDING_COMPLETED=true."""
def __init__(self, db, parent=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("Clock-out Calculator — 시작 설정")
self.setMinimumSize(600, 500)
self.setWizardStyle(QWizard.ModernStyle)
self.setOption(QWizard.NoBackButtonOnStartPage, True)
self.welcome_page = WelcomePage()
self.work_page = WorkPatternPage()
self.detect_page = ClockInDetectionPage()
self.leave_page = LeaveSalaryPage()
self.discord_page = DiscordPage()
self.finish_page = FinishPage()
for page in (self.welcome_page, self.work_page, self.detect_page,
self.leave_page, self.discord_page, self.finish_page):
self.addPage(page)
apply_dark_titlebar(self)
def accept(self):
# 1. 근무 패턴
wm, lm = self.work_page.selected_minutes()
if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
return
settings = {
'work_minutes': wm,
'lunch_duration_minutes': lm,
'annual_leave_days': self.leave_page.leave_spin.value(),
'annual_leave_total': self.leave_page.leave_spin.value(),
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
'hourly_wage': self.leave_page.wage_spin.value(),
'overtime_rate': self.leave_page.rate_combo.currentData(),
'onboarding_completed': True,
}
# 2. 출근 감지 방식
mode = self.detect_page.detection_mode()
settings['clock_in_on_unlock'] = (mode == 'unlock')
# 3. Discord 웹훅 (옵션)
if self.discord_page.enable_check.isChecked():
settings['discord_webhook_url'] = self.discord_page.url_edit.text().strip()
self.db.save_settings(settings)
super().accept()
def maybe_show_onboarding(db, parent=None) -> bool:
"""ONBOARDING_COMPLETED=false 일 때만 위저드 표시. 사용자가 끝까지 완료하면 True."""
if db.get_setting('onboarding_completed', 'false').lower() == 'true':
return False # 이미 완료
wizard = OnboardingWizard(db, parent)
return wizard.exec_() == QWizard.Accepted
+79 -58
View File
@@ -38,24 +38,28 @@ class OvertimeView(QDialog):
# 제목 + 잔액 한 줄 # 제목 + 잔액 한 줄
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
title = QLabel("연장근무 내역") title = QLabel(tr('view.overtime.title'))
title.setObjectName("dialog_title") title.setObjectName("dialog_title")
header_layout.addWidget(title) header_layout.addWidget(title)
header_layout.addStretch() header_layout.addStretch()
self.balance_label = QLabel("잔액: 0분") self.balance_label = QLabel(tr('view.overtime.balance_zero'))
self.balance_label.setObjectName("badge_balance") self.balance_label.setObjectName("badge_balance")
header_layout.addWidget(self.balance_label) header_layout.addWidget(self.balance_label)
layout.addLayout(header_layout) layout.addLayout(header_layout)
# 적립 내역 # 적립 내역
earned_group = QGroupBox("💰 적립 내역") earned_group = QGroupBox(tr('view.overtime.earned_group'))
earned_layout = QVBoxLayout() earned_layout = QVBoxLayout()
earned_layout.setSpacing(4) earned_layout.setSpacing(4)
earned_layout.setContentsMargins(8, 20, 8, 6) earned_layout.setContentsMargins(8, 20, 8, 6)
self.earned_table = QTableWidget() self.earned_table = QTableWidget()
self.earned_table.setColumnCount(3) 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(0, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -64,7 +68,7 @@ class OvertimeView(QDialog):
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
earned_layout.addWidget(self.earned_table) 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) add_earned_button.clicked.connect(self.add_earned_record)
earned_layout.addWidget(add_earned_button) earned_layout.addWidget(add_earned_button)
@@ -72,14 +76,18 @@ class OvertimeView(QDialog):
layout.addWidget(earned_group) layout.addWidget(earned_group)
# 사용 내역 # 사용 내역
used_group = QGroupBox("📤 사용 내역") used_group = QGroupBox(tr('view.overtime.used_group'))
used_layout = QVBoxLayout() used_layout = QVBoxLayout()
used_layout.setSpacing(4) used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6) used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget() self.used_table = QTableWidget()
self.used_table.setColumnCount(3) 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(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -90,7 +98,7 @@ class OvertimeView(QDialog):
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu) self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
used_layout.addWidget(self.used_table) 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) add_used_button.clicked.connect(self.add_used_record)
used_layout.addWidget(add_used_button) used_layout.addWidget(add_used_button)
@@ -98,7 +106,7 @@ class OvertimeView(QDialog):
layout.addWidget(used_group) layout.addWidget(used_group)
# 닫기 버튼 # 닫기 버튼
close_button = QPushButton("닫기") close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
layout.addWidget(close_button) layout.addWidget(close_button)
@@ -110,23 +118,21 @@ class OvertimeView(QDialog):
balance = self.db.get_total_overtime_balance() balance = self.db.get_total_overtime_balance()
hours = balance // 60 hours = balance // 60
minutes = 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() conn = self.db.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT ob.date, ob.earned_minutes, SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
CASE
WHEN ob.work_record_id IS NULL THEN '수동 추가'
ELSE COALESCE(wr.memo, '')
END as memo
FROM overtime_bank ob FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC ORDER BY ob.date DESC
''') ''')
earned_records = cursor.fetchall() earned_records = cursor.fetchall()
manual_label = tr('msg.manual_added')
self.earned_table.setRowCount(len(earned_records)) self.earned_table.setRowCount(len(earned_records))
for i, record in enumerate(earned_records): for i, record in enumerate(earned_records):
@@ -136,12 +142,17 @@ class OvertimeView(QDialog):
minutes = record[1] minutes = record[1]
hours = minutes // 60 hours = minutes // 60
mins = 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 = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter) time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색 time_item.setForeground(QColor(39, 174, 96)) # 초록색
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, 0, date_item)
self.earned_table.setItem(i, 1, time_item) self.earned_table.setItem(i, 1, time_item)
@@ -166,7 +177,10 @@ class OvertimeView(QDialog):
minutes = record[2] minutes = record[2]
hours = minutes // 60 hours = minutes // 60
mins = 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 = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter) time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색 time_item.setForeground(QColor(231, 76, 60)) # 빨간색
@@ -188,7 +202,7 @@ class OvertimeView(QDialog):
# 컨텍스트 메뉴 생성 # 컨텍스트 메뉴 생성
menu = QMenu(self) menu = QMenu(self)
delete_action = QAction("❌ 삭제", self) delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_used_record) delete_action.triggered.connect(self.delete_used_record)
menu.addAction(delete_action) menu.addAction(delete_action)
@@ -213,11 +227,10 @@ class OvertimeView(QDialog):
# 확인 메시지 # 확인 메시지
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"삭제 확인", tr('msg.confirm_delete.title'),
f"다음 사용 기록을 삭제하시겠습니까?\n\n" tr('view.overtime.delete_confirm_body',
f"날짜: {date_item.text()}\n" date=date_item.text(), time=time_item.text(),
f"시간: {time_item.text()}\n" reason=reason_item.text()),
f"사유: {reason_item.text()}",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("추가근무 수동 적립") self.setWindowTitle(tr('view.overtime.manual_earned_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(360) self.setMinimumWidth(360)
@@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("추가근무 수동 적립") title = QLabel(tr('view.overtime.manual_earned_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
# 날짜 # 날짜
date_layout = QHBoxLayout() date_layout = QHBoxLayout()
date_label = QLabel("날짜:") date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label") date_label.setObjectName("field_label")
date_label.setFixedWidth(60) date_label.setFixedWidth(60)
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
@@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog):
# 시간 (30분 단위) # 시간 (30분 단위)
time_layout = QHBoxLayout() time_layout = QHBoxLayout()
time_label = QLabel("시간:") time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label") time_label.setObjectName("field_label")
time_label.setFixedWidth(60) time_label.setFixedWidth(60)
self.hour_spin = QSpinBox() self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23) 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 = 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(time_label)
time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo) time_layout.addWidget(self.minute_combo)
@@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog):
# 메모 # 메모
memo_layout = QHBoxLayout() memo_layout = QHBoxLayout()
memo_label = QLabel("메모:") memo_label = QLabel(tr('view.overtime.field_memo'))
memo_label.setObjectName("field_label") memo_label.setObjectName("field_label")
memo_label.setFixedWidth(60) memo_label.setFixedWidth(60)
self.memo_edit = QLineEdit() 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(memo_label)
memo_layout.addWidget(self.memo_edit) memo_layout.addWidget(self.memo_edit)
layout.addLayout(memo_layout) layout.addLayout(memo_layout)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary") save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save) save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
@@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog):
"""저장""" """저장"""
# 시간 계산 (30분 단위) # 시간 계산 (30분 단위)
hours = self.hour_spin.value() 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 total_minutes = hours * 60 + minutes
if total_minutes == 0: if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.") QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_add_error'))
return return
date = self.date_edit.date().toString("yyyy-MM-dd") date = self.date_edit.date().toString("yyyy-MM-dd")
@@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"저장 완료", tr('msg.save_success.title'),
f"{hours}시간 {minutes}분이 적립되었습니다." tr('view.overtime.saved_earned', h=hours, m=minutes)
) )
self.accept() self.accept()
@@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog):
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
self.setWindowTitle("추가근무 수동 사용") self.setWindowTitle(tr('view.overtime.manual_used_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(360) self.setMinimumWidth(360)
@@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog):
# 제목 + 잔액 한 줄 # 제목 + 잔액 한 줄
header_layout = QHBoxLayout() header_layout = QHBoxLayout()
title = QLabel("추가근무 수동 사용") title = QLabel(tr('view.overtime.manual_used_title'))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
header_layout.addWidget(title) header_layout.addWidget(title)
header_layout.addStretch() header_layout.addStretch()
balance = self.db.get_total_overtime_balance() balance = self.db.get_total_overtime_balance()
hours = balance // 60 hours = balance // 60
minutes = 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") balance_label.setObjectName("badge_balance")
header_layout.addWidget(balance_label) header_layout.addWidget(balance_label)
layout.addLayout(header_layout) layout.addLayout(header_layout)
# 날짜 # 날짜
date_layout = QHBoxLayout() date_layout = QHBoxLayout()
date_label = QLabel("날짜:") date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label") date_label.setObjectName("field_label")
date_label.setFixedWidth(60) date_label.setFixedWidth(60)
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
@@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog):
# 시간 (30분 단위) # 시간 (30분 단위)
time_layout = QHBoxLayout() time_layout = QHBoxLayout()
time_label = QLabel("시간:") time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label") time_label.setObjectName("field_label")
time_label.setFixedWidth(60) time_label.setFixedWidth(60)
self.hour_spin = QSpinBox() self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23) 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 = 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(time_label)
time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo) time_layout.addWidget(self.minute_combo)
@@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog):
# 사유 # 사유
reason_layout = QHBoxLayout() reason_layout = QHBoxLayout()
reason_label = QLabel("사유:") reason_label = QLabel(tr('view.overtime.field_reason'))
reason_label.setObjectName("field_label") reason_label.setObjectName("field_label")
reason_label.setFixedWidth(60) reason_label.setFixedWidth(60)
self.reason_edit = QLineEdit() 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(reason_label)
reason_layout.addWidget(self.reason_edit) reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout) layout.addLayout(reason_layout)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary") save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save) save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button) button_layout.addWidget(cancel_button)
@@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog):
"""저장""" """저장"""
# 시간 계산 (30분 단위) # 시간 계산 (30분 단위)
hours = self.hour_spin.value() 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 total_minutes = hours * 60 + minutes
if total_minutes == 0: if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.") QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_use_error'))
return return
# 잔액 확인 # 잔액 확인
@@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog):
if total_minutes > balance: if total_minutes > balance:
QMessageBox.warning( QMessageBox.warning(
self, self,
"잔액 부족", tr('view.overtime.balance_short_title'),
f"사용 가능한 시간이 부족합니다.\n\n" tr('view.overtime.balance_short_body',
f"요청: {hours}시간 {minutes}분\n" req_h=hours, req_m=minutes,
f"잔액: {balance // 60}시간 {balance % 60}분" bal_h=balance // 60, bal_m=balance % 60)
) )
return return
date = self.date_edit.date().toString("yyyy-MM-dd") 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에 저장 # DB에 저장
self.db.add_overtime_usage(None, total_minutes, date, reason) self.db.add_overtime_usage(None, total_minutes, date, reason)
QMessageBox.information( QMessageBox.information(
self, self,
"저장 완료", tr('msg.save_success.title'),
f"{hours}시간 {minutes}분이 사용 처리되었습니다." tr('view.overtime.saved_used', h=hours, m=minutes)
) )
self.accept() self.accept()
+111
View File
@@ -0,0 +1,111 @@
"""
과거 일자 수동 추가 다이얼로그.
캘린더 우클릭 → "기록 추가"에서 호출. 출/퇴근 시각 + 점심/저녁 + 메모 입력.
"""
from __future__ import annotations
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QCheckBox, QLineEdit,
QMessageBox)
from PyQt5.QtCore import QTime, Qt
from ui.styles import apply_dark_titlebar
class PastRecordDialog(QDialog):
"""과거 일자 근무 기록 입력."""
def __init__(self, parent=None, date_str: str = ''):
super().__init__(parent)
self.date_str = date_str
self.setWindowTitle(f"기록 추가 — {date_str}")
self.setModal(True)
self.setFixedSize(380, 320)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
layout.addWidget(info)
# 출근
ci_row = QHBoxLayout()
ci_row.addWidget(QLabel("출근:"))
self.clock_in_edit = QTimeEdit()
self.clock_in_edit.setDisplayFormat("HH:mm")
self.clock_in_edit.setTime(QTime(9, 0))
ci_row.addWidget(self.clock_in_edit)
ci_row.addStretch()
layout.addLayout(ci_row)
# 퇴근
co_row = QHBoxLayout()
co_row.addWidget(QLabel("퇴근:"))
self.clock_out_check = QCheckBox("입력")
self.clock_out_check.setChecked(True)
self.clock_out_edit = QTimeEdit()
self.clock_out_edit.setDisplayFormat("HH:mm")
self.clock_out_edit.setTime(QTime(18, 0))
self.clock_out_check.toggled.connect(self.clock_out_edit.setEnabled)
co_row.addWidget(self.clock_out_check)
co_row.addWidget(self.clock_out_edit)
co_row.addStretch()
layout.addLayout(co_row)
# 점심/저녁
meal_row = QHBoxLayout()
self.lunch_check = QCheckBox("🍱 점심시간 포함")
self.lunch_check.setChecked(True)
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
meal_row.addWidget(self.lunch_check)
meal_row.addWidget(self.dinner_check)
meal_row.addStretch()
layout.addLayout(meal_row)
# 메모
layout.addWidget(QLabel("메모 (선택):"))
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
layout.addWidget(self.memo_edit)
layout.addStretch()
# 버튼
btn_row = QHBoxLayout()
btn_row.addStretch()
ok_btn = QPushButton("저장")
ok_btn.setObjectName("btn_primary")
ok_btn.clicked.connect(self._validate_and_accept)
cancel_btn = QPushButton("취소")
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
self.setLayout(layout)
apply_dark_titlebar(self)
def _validate_and_accept(self):
if self.clock_out_check.isChecked():
ci = self.clock_in_edit.time()
co = self.clock_out_edit.time()
if co <= ci:
QMessageBox.warning(self, "입력 오류",
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
return
self.accept()
def get_data(self) -> dict:
ci = self.clock_in_edit.time().toPyTime()
data = {
'clock_in': f"{ci.hour:02d}:{ci.minute:02d}:00",
'lunch': self.lunch_check.isChecked(),
'dinner': self.dinner_check.isChecked(),
'memo': self.memo_edit.text().strip(),
}
if self.clock_out_check.isChecked():
co = self.clock_out_edit.time().toPyTime()
data['clock_out'] = f"{co.hour:02d}:{co.minute:02d}:00"
return data
+183 -4
View File
@@ -22,6 +22,9 @@ from core.settings_keys import (
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS, THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK, DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
GOAL_OVERTIME_MAX_MONTHLY, GOAL_AVG_HOURS_DAILY,
GITEA_FEEDBACK_TOKEN, GITEA_FEEDBACK_ENABLED,
FONT_SCALE, HIGH_CONTRAST,
) )
from utils.csv_exporter import CSVExporter from utils.csv_exporter import CSVExporter
from ui.leave_view import AddLeaveDialog from ui.leave_view import AddLeaveDialog
@@ -86,6 +89,10 @@ class SettingsView(QDialog):
leave_group = self.create_leave_group() leave_group = self.create_leave_group()
layout.addWidget(leave_group) layout.addWidget(leave_group)
# 목표 설정 그룹
goal_group = self.create_goal_group()
layout.addWidget(goal_group)
# 공휴일 설정 # 공휴일 설정
holiday_group = self.create_holiday_group() holiday_group = self.create_holiday_group()
layout.addWidget(holiday_group) layout.addWidget(holiday_group)
@@ -317,6 +324,22 @@ class SettingsView(QDialog):
format_row.addStretch() format_row.addStretch()
layout.addLayout(format_row) 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 from core.i18n import available_languages, language_label
lang_row = QHBoxLayout() lang_row = QHBoxLayout()
@@ -398,6 +421,51 @@ class SettingsView(QDialog):
group.setLayout(layout) group.setLayout(layout)
return group return group
def create_goal_group(self) -> QGroupBox:
"""월간 목표 설정 그룹 (0=비활성)."""
group = QGroupBox("🎯 월간 목표 (0=비활성)")
layout = QVBoxLayout()
layout.setSpacing(6)
# 연장근무 상한
ot_row = QHBoxLayout()
ot_label = QLabel("월 연장근무 상한:")
ot_label.setFixedWidth(150)
self.goal_ot_h = QSpinBox()
self.goal_ot_h.setRange(0, 100)
self.goal_ot_h.setSuffix(" 시간")
self.goal_ot_h.setFixedWidth(100)
self.goal_ot_m = QSpinBox()
self.goal_ot_m.setRange(0, 59)
self.goal_ot_m.setSingleStep(30)
self.goal_ot_m.setSuffix(" 분")
self.goal_ot_m.setFixedWidth(90)
ot_row.addWidget(ot_label)
ot_row.addWidget(self.goal_ot_h)
ot_row.addWidget(self.goal_ot_m)
ot_row.addStretch()
layout.addLayout(ot_row)
# 일평균 목표
avg_row = QHBoxLayout()
avg_label = QLabel("일 평균 근무 목표:")
avg_label.setFixedWidth(150)
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
self.goal_avg.setRange(0, 24)
self.goal_avg.setSuffix(" 시간")
self.goal_avg.setFixedWidth(100)
avg_row.addWidget(avg_label)
avg_row.addWidget(self.goal_avg)
avg_row.addStretch()
layout.addLayout(avg_row)
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
note.setObjectName("note_text")
layout.addWidget(note)
group.setLayout(layout)
return group
def create_leave_group(self) -> QGroupBox: def create_leave_group(self) -> QGroupBox:
"""휴가 설정 그룹""" """휴가 설정 그룹"""
group = QGroupBox(tr('group.leave')) group = QGroupBox(tr('group.leave'))
@@ -690,6 +758,19 @@ class SettingsView(QDialog):
layout.addLayout(export_layout) layout.addLayout(export_layout)
# CSV 가져오기
import_layout = QHBoxLayout()
import_btn = QPushButton("📥 CSV 가져오기")
import_btn.setObjectName("btn_small")
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
import_btn.clicked.connect(self._import_csv)
import_layout.addWidget(import_btn)
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
import_label.setObjectName("note_text")
import_layout.addWidget(import_label)
import_layout.addStretch()
layout.addLayout(import_layout)
# DB 경로 설정 (클라우드 동기화 가능) # DB 경로 설정 (클라우드 동기화 가능)
db_path_layout = QHBoxLayout() db_path_layout = QHBoxLayout()
db_path_label = QLabel("DB 경로:") db_path_label = QLabel("DB 경로:")
@@ -711,6 +792,22 @@ class SettingsView(QDialog):
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.") self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
layout.addWidget(self.auto_break_check) layout.addWidget(self.auto_break_check)
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
feedback_layout = QHBoxLayout()
feedback_label = QLabel("Gitea 피드백:")
feedback_label.setFixedWidth(80)
self.gitea_token_edit = QLineEdit()
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
feedback_layout.addWidget(feedback_label)
feedback_layout.addWidget(self.gitea_token_edit, 1)
layout.addLayout(feedback_layout)
self.gitea_feedback_enabled_check = QCheckBox(
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
)
layout.addWidget(self.gitea_feedback_enabled_check)
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용) # 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용") self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
self.clock_in_unlock_check.setToolTip( self.clock_in_unlock_check.setToolTip(
@@ -808,6 +905,31 @@ class SettingsView(QDialog):
if hasattr(self, 'clock_in_unlock_check'): if hasattr(self, 'clock_in_unlock_check'):
self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False)) self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False))
# 목표
if hasattr(self, 'goal_ot_h'):
ot_min = int(settings.get(GOAL_OVERTIME_MAX_MONTHLY, 0) or 0)
self.goal_ot_h.setValue(ot_min // 60)
self.goal_ot_m.setValue(ot_min % 60)
if hasattr(self, 'goal_avg'):
self.goal_avg.setValue(int(float(settings.get(GOAL_AVG_HOURS_DAILY, 0) or 0)))
# Gitea 피드백
if hasattr(self, 'gitea_token_edit'):
self.gitea_token_edit.setText(self.db.get_setting(GITEA_FEEDBACK_TOKEN, '') or '')
if hasattr(self, 'gitea_feedback_enabled_check'):
self.gitea_feedback_enabled_check.setChecked(
settings.get(GITEA_FEEDBACK_ENABLED, False)
)
# 접근성
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.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
@@ -862,6 +984,49 @@ class SettingsView(QDialog):
# 남은 연차 계산 # 남은 연차 계산
self.update_remaining_leave() self.update_remaining_leave()
def _import_csv(self):
"""CSV 파일에서 근무 기록 일괄 가져오기."""
path, _ = QFileDialog.getOpenFileName(
self, "CSV 가져오기",
os.path.expanduser("~"),
"CSV files (*.csv);;All files (*.*)",
)
if not path:
return
try:
from utils.csv_importer import parse_csv, import_records
rows = parse_csv(path)
except (FileNotFoundError, ValueError) as e:
QMessageBox.critical(self, "파싱 실패", str(e))
return
if not rows:
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
return
reply = QMessageBox.question(
self,
"충돌 처리",
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
)
if reply == QMessageBox.Cancel:
return
policy = 'overwrite' if reply == QMessageBox.Yes else 'skip'
try:
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
except Exception as e:
QMessageBox.critical(self, "가져오기 실패", str(e))
return
QMessageBox.information(
self, "완료",
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
)
def _check_updates(self): def _check_updates(self):
"""설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임.""" """설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임."""
if self.parent_window and hasattr(self.parent_window, 'check_for_updates'): if self.parent_window and hasattr(self.parent_window, 'check_for_updates'):
@@ -911,6 +1076,18 @@ class SettingsView(QDialog):
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked() settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
if hasattr(self, 'clock_in_unlock_check'): if hasattr(self, 'clock_in_unlock_check'):
settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked() settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked()
if hasattr(self, 'goal_ot_h'):
settings[GOAL_OVERTIME_MAX_MONTHLY] = self.goal_ot_h.value() * 60 + self.goal_ot_m.value()
if hasattr(self, 'goal_avg'):
settings[GOAL_AVG_HOURS_DAILY] = self.goal_avg.value()
if hasattr(self, 'gitea_token_edit'):
self.db.set_setting(GITEA_FEEDBACK_TOKEN, self.gitea_token_edit.text().strip())
if hasattr(self, 'gitea_feedback_enabled_check'):
settings[GITEA_FEEDBACK_ENABLED] = self.gitea_feedback_enabled_check.isChecked()
if hasattr(self, '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'): if hasattr(self, 'language_combo'):
settings[LANGUAGE] = self.language_combo.currentData() settings[LANGUAGE] = self.language_combo.currentData()
@@ -937,16 +1114,18 @@ class SettingsView(QDialog):
if self.parent_window and hasattr(self.parent_window, 'reload_settings'): if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
self.parent_window.reload_settings() self.parent_window.reload_settings()
# 언어 변경 감지 → 재시작 제안 # 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장
if hasattr(self, 'language_combo'): if hasattr(self, 'language_combo'):
from core.i18n import get_language from core.i18n import get_language
from ui.i18n_runtime import set_language_and_retranslate
new_lang = self.language_combo.currentData() new_lang = self.language_combo.currentData()
if new_lang and new_lang != get_language(): if new_lang and new_lang != get_language():
set_language_and_retranslate(new_lang)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"재시작 필요 / Restart required", "재시작 / Restart",
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n" "주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
"Restart now to fully apply the language change?", "Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
+52 -1
View File
@@ -99,6 +99,11 @@ class StatsView(QDialog):
layout.setSpacing(6) layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4) layout.setContentsMargins(4, 4, 4, 4)
# 추정 급여 카드 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
self.salary_label.setVisible(False)
summary_group = QGroupBox(tr('stats.monthly_summary')) summary_group = QGroupBox(tr('stats.monthly_summary'))
summary_layout = QGridLayout() summary_layout = QGridLayout()
summary_layout.setSpacing(4) summary_layout.setSpacing(4)
@@ -124,6 +129,12 @@ class StatsView(QDialog):
summary_group.setLayout(summary_layout) summary_group.setLayout(summary_layout)
layout.addWidget(summary_group) layout.addWidget(summary_group)
layout.addWidget(self.salary_label)
# 목표 진행률
from ui.goal_widget import GoalWidget
self.goal_widget = GoalWidget(self.db)
layout.addWidget(self.goal_widget)
# 월간 차트 # 월간 차트
from ui.chart_widget import make_chart_widget from ui.chart_widget import make_chart_widget
@@ -154,7 +165,11 @@ class StatsView(QDialog):
pattern_group.setLayout(pattern_layout) pattern_group.setLayout(pattern_layout)
layout.addWidget(pattern_group) layout.addWidget(pattern_group)
layout.addStretch() # 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(self.clock_in_chart, 1)
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@@ -206,11 +221,47 @@ class StatsView(QDialog):
if hasattr(self, 'monthly_chart'): if hasattr(self, 'monthly_chart'):
draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', [])) draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', []))
# 추정 급여 (옵션 활성 시)
self._update_salary_estimate(monthly_stats.get('records', []))
# 목표 진행률
if hasattr(self, 'goal_widget'):
self.goal_widget.refresh()
# 패턴 분석 # 패턴 분석
self.analyze_patterns(monthly_stats.get('records', [])) self.analyze_patterns(monthly_stats.get('records', []))
def _update_salary_estimate(self, records):
"""월간 추정 급여 표시 (SALARY_ENABLED=true 일 때만)."""
if not hasattr(self, 'salary_label'):
return
if self.db.get_setting('salary_enabled', 'false').lower() != 'true':
self.salary_label.setVisible(False)
return
try:
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
except (ValueError, TypeError):
self.salary_label.setVisible(False)
return
if wage <= 0:
self.salary_label.setVisible(False)
return
from core.salary import estimate_pay, format_won
result = estimate_pay(records, wage, rate)
self.salary_label.setText(
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
)
self.salary_label.setVisible(True)
def analyze_patterns(self, records): def analyze_patterns(self, records):
"""패턴 분석""" """패턴 분석"""
# 출근 분포 차트는 데이터 유무와 무관하게 갱신 (빈 차트 표시)
if 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: if not records:
self.pattern_text.setText(tr('stats.no_data')) self.pattern_text.setText(tr('stats.no_data'))
return return
+89
View File
@@ -0,0 +1,89 @@
"""
퇴근 후 표시되는 "오늘 요약" 카드 위젯.
다음 출근 시 자동 숨김. 메인 화면 상단에 conditional하게 표시.
"""
from __future__ import annotations
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt5.QtCore import Qt
class TodaySummaryCard(QFrame):
"""퇴근 처리 직후 표시되는 요약 카드."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("today_summary_card")
self.setStyleSheet("""
QFrame#today_summary_card {
background-color: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.4);
border-radius: 8px;
padding: 6px;
}
QLabel { padding: 1px; }
""")
self.setVisible(False)
layout = QVBoxLayout()
layout.setContentsMargins(10, 6, 10, 6)
layout.setSpacing(2)
header = QHBoxLayout()
title = QLabel("📋 오늘의 요약")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title)
header.addStretch()
close_btn = QPushButton("✕")
close_btn.setFixedSize(20, 20)
close_btn.setStyleSheet("border: none; font-weight: bold;")
close_btn.clicked.connect(self.hide)
header.addWidget(close_btn)
layout.addLayout(header)
self.total_label = QLabel("")
self.detail_label = QLabel("")
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
layout.addWidget(self.total_label)
layout.addWidget(self.detail_label)
layout.addWidget(self.salary_label)
self.setLayout(layout)
def show_summary(self, total_hours: float, lunch_minutes: int,
break_minutes: int, overtime_actual: int,
overtime_earned: int, salary_text: str = "") -> None:
"""카드 내용 채우고 표시.
Args:
total_hours: 총 근무시간(시간)
lunch_minutes: 점심 시간(분)
break_minutes: 외출 시간(분)
overtime_actual: 실제 연장근무(분)
overtime_earned: 적립 연장근무(분)
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
"""
h = int(total_hours)
m = int((total_hours - h) * 60)
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
details = []
if lunch_minutes > 0:
details.append(f"점심 {lunch_minutes}분")
if break_minutes > 0:
details.append(f"외출 {break_minutes}분")
if overtime_actual > 0:
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
self.detail_label.setText(" · ".join(details) if details else "")
self.detail_label.setVisible(bool(details))
if salary_text:
self.salary_label.setText(f"💰 {salary_text}")
self.salary_label.setVisible(True)
else:
self.salary_label.setVisible(False)
self.setVisible(True)
+173
View File
@@ -0,0 +1,173 @@
"""
전역 예외 후킹 + Gitea Issues 자동 보고.
sys.excepthook을 등록해 처리되지 않은 예외를 가로채:
1. crash_log 테이블에 저장
2. 사용자에게 다이얼로그로 알림 + "Gitea에 보고" / "복사" 옵션
"""
from __future__ import annotations
import json
import sys
import traceback
import urllib.request
import urllib.error
from datetime import datetime
from typing import Optional
# 자체 호스팅 Gitea (updater_client와 동일)
GITEA_API = 'https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
def install_global_handler(db, app_version: str = 'unknown') -> None:
"""sys.excepthook 등록. db는 crash_log 저장용."""
original = sys.excepthook
def hook(exc_type, exc_value, exc_tb):
if exc_type is KeyboardInterrupt:
original(exc_type, exc_value, exc_tb)
return
try:
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
_log_crash(db, exc_type.__name__, str(exc_value), tb_str, app_version)
_show_dialog(db, exc_type.__name__, str(exc_value), tb_str, app_version)
except Exception:
pass # 후킹 자체 실패는 silent
original(exc_type, exc_value, exc_tb)
sys.excepthook = hook
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
"""crash_log 테이블에 기록."""
try:
conn = db.get_connection()
cursor = conn.cursor()
# 테이블 자동 생성 (마이그레이션 누락 대비)
cursor.execute('''
CREATE TABLE IF NOT EXISTS crash_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
exception_type TEXT,
message TEXT,
traceback TEXT,
app_version TEXT,
reported BOOLEAN DEFAULT 0
)
''')
cursor.execute('''
INSERT INTO crash_log (exception_type, message, traceback, app_version)
VALUES (?, ?, ?, ?)
''', (exc_type, message, tb, version))
conn.commit()
conn.close()
except Exception:
pass
def _show_dialog(db, exc_type: str, message: str, tb: str, version: str) -> None:
"""크래시 알림 + Gitea 보고/복사 버튼."""
try:
from PyQt5.QtWidgets import (QApplication, QMessageBox, QDialog,
QVBoxLayout, QLabel, QTextEdit,
QHBoxLayout, QPushButton, QLineEdit)
except ImportError:
return
app = QApplication.instance()
if app is None:
return
dlg = QDialog()
dlg.setWindowTitle("⚠️ 오류 발생")
dlg.setMinimumSize(560, 420)
layout = QVBoxLayout()
title = QLabel(f"<b>{exc_type}</b>: {message[:200]}")
title.setWordWrap(True)
layout.addWidget(title)
layout.addWidget(QLabel("무엇을 하다가 발생했나요? (선택)"))
user_note = QLineEdit()
user_note.setPlaceholderText("예: 출근 버튼 누른 직후")
layout.addWidget(user_note)
layout.addWidget(QLabel("기술 정보 (자동 보고에 포함):"))
tb_view = QTextEdit()
tb_view.setReadOnly(True)
tb_view.setFont(__import__('PyQt5.QtGui', fromlist=['QFont']).QFont('Consolas', 9))
tb_view.setText(tb[-3000:]) # 너무 긴 traceback 제한
layout.addWidget(tb_view, 1)
btn_row = QHBoxLayout()
cancel_btn = QPushButton("닫기")
copy_btn = QPushButton("📋 복사")
report_btn = QPushButton("📤 Gitea에 보고")
has_token = bool(db.get_setting('gitea_feedback_token', '') or '')
enabled = db.get_setting('gitea_feedback_enabled', 'false').lower() == 'true'
if not (has_token and enabled):
report_btn.setEnabled(False)
report_btn.setToolTip("설정 → 데이터 관리 → Gitea 피드백 토큰 입력 후 활성화 필요")
def do_copy():
clipboard = QApplication.clipboard()
text = (
f"## {exc_type}\n\n{message}\n\n"
f"**Version**: {version}\n**Note**: {user_note.text()}\n\n"
f"```\n{tb}\n```"
)
clipboard.setText(text)
copy_btn.setText("✓ 복사됨")
def do_report():
token = db.get_setting('gitea_feedback_token', '') or ''
if not token:
QMessageBox.warning(dlg, "토큰 없음", "Gitea PAT를 설정에서 먼저 입력하세요.")
return
title_str = f"[Auto] {exc_type}: {message[:80]}"
body = (
f"**Version**: `{version}`\n"
f"**Time**: {datetime.now().isoformat(timespec='seconds')}\n"
f"**User note**: {user_note.text() or '(none)'}\n\n"
f"### Traceback\n```\n{tb[-3000:]}\n```"
)
ok = _send_to_gitea(token, title_str, body)
if ok:
QMessageBox.information(dlg, "보고 완료", "Gitea Issues에 등록되었습니다.")
report_btn.setText("✓ 보고됨")
report_btn.setEnabled(False)
else:
QMessageBox.warning(dlg, "보고 실패", "네트워크 또는 토큰 권한 문제일 수 있습니다.")
cancel_btn.clicked.connect(dlg.reject)
copy_btn.clicked.connect(do_copy)
report_btn.clicked.connect(do_report)
btn_row.addWidget(cancel_btn)
btn_row.addStretch()
btn_row.addWidget(copy_btn)
btn_row.addWidget(report_btn)
layout.addLayout(btn_row)
dlg.setLayout(layout)
dlg.exec_()
def _send_to_gitea(token: str, title: str, body: str) -> bool:
"""Gitea Issues API로 issue 생성."""
payload = json.dumps({'title': title, 'body': body}).encode('utf-8')
req = urllib.request.Request(
f"{GITEA_API}/issues",
data=payload,
headers={
'Authorization': f'token {token}',
'Content-Type': 'application/json',
'User-Agent': USER_AGENT,
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
return False
+157
View File
@@ -0,0 +1,157 @@
"""
CSV 가져오기 — 우리 표준 포맷.
표준 포맷:
date,clock_in,clock_out,lunch_minutes,memo
2026-04-01,09:00:00,18:30:00,60,"메모"
- 헤더 첫 줄 필수
- date: YYYY-MM-DD
- clock_in/out: HH:MM:SS 또는 HH:MM
- lunch_minutes: 정수 (0이면 점심 미포함)
- memo: 선택 (따옴표 가능)
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
"""
from __future__ import annotations
import csv
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Iterator, Tuple
def parse_csv(path: str) -> List[Dict]:
"""CSV 파일을 dict 리스트로 파싱. 검증 실패 시 ValueError."""
rows = []
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"파일 없음: {path}")
with open(p, 'r', encoding='utf-8-sig', newline='') as f:
reader = csv.DictReader(f)
required = {'date', 'clock_in'}
if not required.issubset(reader.fieldnames or []):
raise ValueError(
f"헤더에 필수 필드 누락: {required - set(reader.fieldnames or [])}\n"
f"필수 헤더: date,clock_in,clock_out,lunch_minutes,memo"
)
for i, row in enumerate(reader, start=2): # 데이터 시작 줄 번호 (1=헤더)
try:
clean = _normalize_row(row)
rows.append(clean)
except ValueError as e:
raise ValueError(f"줄 {i}: {e}")
return rows
def _normalize_row(row: Dict) -> Dict:
"""단일 행 검증 + 정규화."""
date_str = (row.get('date') or '').strip()
if not date_str:
raise ValueError("date 비어있음")
try:
datetime.strptime(date_str, '%Y-%m-%d')
except ValueError:
raise ValueError(f"date 형식 오류: '{date_str}' (YYYY-MM-DD 필요)")
ci = _normalize_time(row.get('clock_in', '').strip(), 'clock_in')
co_raw = (row.get('clock_out') or '').strip()
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
lunch = 0
lm = (row.get('lunch_minutes') or '').strip()
if lm:
try:
lunch = int(lm)
if lunch < 0:
raise ValueError
except ValueError:
raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'")
memo = (row.get('memo') or '').strip()
return {
'date': date_str,
'clock_in': ci,
'clock_out': co,
'lunch_minutes': lunch,
'memo': memo,
}
def _normalize_time(s: str, field_name: str) -> str:
"""'HH:MM' 또는 'HH:MM:SS' → 'HH:MM:SS'."""
if not s:
raise ValueError(f"{field_name} 비어있음")
parts = s.split(':')
if len(parts) == 2:
s = f"{s}:00"
elif len(parts) != 3:
raise ValueError(f"{field_name} 형식 오류: '{s}' (HH:MM[:SS] 필요)")
try:
datetime.strptime(s, '%H:%M:%S')
except ValueError:
raise ValueError(f"{field_name} 시간 파싱 실패: '{s}'")
return s
def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int, int, int]:
"""파싱된 rows를 DB에 일괄 입력.
Args:
db: Database 인스턴스
rows: parse_csv 결과
on_conflict: 'skip' | 'overwrite'
Returns:
(added, updated, skipped) 수
"""
if on_conflict not in ('skip', 'overwrite'):
raise ValueError("on_conflict는 'skip' 또는 'overwrite'")
added = updated = skipped = 0
from core.time_calculator import TimeCalculator
work_minutes = db.get_work_minutes()
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
for row in rows:
existing = db.get_work_record(row['date'])
if existing and on_conflict == 'skip':
skipped += 1
continue
if existing and on_conflict == 'overwrite':
# 기존 레코드 삭제 후 재추가 (단순화)
conn = db.get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
conn.commit()
finally:
conn.close()
updated += 1
else:
added += 1
wid = db.add_work_record(row['date'], row['clock_in'], is_manual=True)
if row.get('clock_out'):
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
calc = TimeCalculator(work_minutes=work_minutes,
lunch_duration_minutes=row.get('lunch_minutes') or lunch_default)
include_lunch = (row.get('lunch_minutes') or 0) > 0
total = (co_dt - ci_dt).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci_dt, co_dt, include_lunch=include_lunch
)
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
if include_lunch:
db.update_lunch_break(row['date'], True)
if ot_earned > 0:
db.add_overtime_earned(wid, ot_earned, row['date'])
return added, updated, skipped
+115
View File
@@ -0,0 +1,115 @@
"""
Discord 웹훅 알림 (단방향 push).
URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안 막음).
"""
from __future__ import annotations
import json
import urllib.request
import urllib.error
from datetime import datetime
from typing import Optional, List
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
# 일반 브라우저 UA로 위장해야 통과.
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
# Discord embed 색상 (decimal)
COLOR_GREEN = 0x57F287
COLOR_BLUE = 0x5865F2
COLOR_YELLOW = 0xFEE75C
COLOR_PINK = 0xEB459E
COLOR_ORANGE = 0xED4245
def send(webhook_url: str, title: str, description: str,
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
timeout: int = 5) -> bool:
"""Discord 웹훅으로 embed 메시지 발송.
Args:
webhook_url: Discord webhook URL (https://discord.com/api/webhooks/{ID}/{TOKEN})
title, description: embed 본문
color: embed 좌측 색상 바 (10진수, 0xRRGGBB)
fields: [{"name": "필드명", "value": "값", "inline": True}, ...]
timeout: 요청 타임아웃(초)
Returns:
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
"""
if not webhook_url or not webhook_url.startswith('https://'):
return False
payload = {
"embeds": [{
"title": title,
"description": description,
"color": color,
"fields": fields or [],
"timestamp": datetime.utcnow().isoformat(),
"footer": {"text": "Clock-out Time Calculator"},
}]
}
data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
req = urllib.request.Request(
webhook_url, data=data,
headers={
'Content-Type': 'application/json; charset=utf-8',
'User-Agent': USER_AGENT,
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return 200 <= resp.status < 300
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
return False
def send_test(webhook_url: str) -> bool:
"""온보딩/설정에서 호출하는 테스트 메시지."""
return send(
webhook_url,
title="🔔 테스트 알림",
description="Clock-out Time Calculator의 Discord 연동이 정상 작동합니다.",
color=COLOR_GREEN,
)
def send_clock_in(webhook_url: str, time_str: str) -> bool:
return send(
webhook_url,
title="🟢 출근",
description=f"오늘 {time_str}에 출근 기록되었습니다.",
color=COLOR_GREEN,
)
def send_clock_out(webhook_url: str, time_str: str, total_hours: float,
overtime_minutes: int, overtime_earned: int) -> bool:
fields = [
{"name": "총 근무시간", "value": f"{total_hours:.1f}시간", "inline": True},
]
if overtime_minutes > 0:
fields.append({
"name": "연장근무",
"value": f"{overtime_minutes}분 → {overtime_earned}분 적립",
"inline": True,
})
color = COLOR_YELLOW if overtime_minutes > 0 else COLOR_BLUE
return send(
webhook_url,
title="✅ 퇴근",
description=f"오늘 {time_str}에 퇴근 처리되었습니다.",
color=color,
fields=fields,
)
def send_health_warning(webhook_url: str, hours_continuous: float) -> bool:
return send(
webhook_url,
title="🌿 휴식 권고",
description=f"{hours_continuous:.1f}시간 연속 근무 중입니다.\n잠시 일어나서 스트레칭하세요.",
color=COLOR_PINK,
)