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+ 아키텍처 반영
This commit is contained in:
parent
6a17876af1
commit
ff71886fd7
163
AGENTS.md
163
AGENTS.md
@ -1,20 +1,40 @@
|
||||
# Project Conventions and Operational Gotchas
|
||||
|
||||
## 🛠️ Setup & Execution
|
||||
- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature.
|
||||
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
|
||||
- **Run:** `python main.py`
|
||||
- **Module-level tests:**
|
||||
- **Module-level smoke:**
|
||||
- Event monitoring: `python core/event_monitor.py`
|
||||
- Time calculation: `python core/time_calculator.py`
|
||||
- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release.
|
||||
- **Integration tests** (all should be green before release):
|
||||
- `python _integration_test.py` — business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+)
|
||||
- `python _i18n_gui_test.py` — ko/en switch on real widgets
|
||||
- `python _gui_smoke_test.py` — widget instantiation
|
||||
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
|
||||
|
||||
## 🗄️ Architecture Notes (Core Business Logic)
|
||||
- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs.
|
||||
|
||||
### Database (10+ tables in `database.db`)
|
||||
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
|
||||
|
||||
### Invariants
|
||||
- **`work_records.date` UNIQUE** — one row per workday.
|
||||
- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out.
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours).
|
||||
- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect.
|
||||
- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
|
||||
- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
|
||||
- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox).
|
||||
|
||||
### Settings system
|
||||
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
||||
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
||||
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
||||
|
||||
### i18n
|
||||
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
|
||||
- Sentence formatting via Python `str.format(**kwargs)`.
|
||||
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
|
||||
|
||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||
|
||||
@ -22,46 +42,135 @@
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
|
||||
|
||||
### 2. Hot-path caching
|
||||
`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`.
|
||||
`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`.
|
||||
|
||||
### 3. Time format separation
|
||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
||||
|
||||
### 4. Workday boundary
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover.
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic.
|
||||
|
||||
### 5. Migration idempotency
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query.
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.
|
||||
|
||||
### 6. Single-file deployment
|
||||
`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build.
|
||||
|
||||
### 7. Updater handoff
|
||||
`updater.py` is standalone (no PyQt). Args: `--pid <main_pid> --new <new_main.exe> --target <current_main.exe>`. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater.
|
||||
|
||||
## 🧩 Module Map
|
||||
|
||||
### `core/`
|
||||
- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`).
|
||||
- `time_calculator.py` — `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit).
|
||||
- `event_monitor.py` — Win Event IDs 6005/4624/6006.
|
||||
- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable.
|
||||
- `i18n.py` — `_DICT` ko/en + `_HELP_HTML` (6 tabs).
|
||||
- `salary.py` — `estimate_pay(records, hourly_wage, overtime_rate=1.5)`.
|
||||
- `settings_keys.py` — All setting keys as constants.
|
||||
- `version.py` — `__version__` single source of truth.
|
||||
|
||||
### `ui/`
|
||||
- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts.
|
||||
- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
|
||||
- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
|
||||
- `mini_widget.py` — always-on-top frameless.
|
||||
- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button.
|
||||
- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel).
|
||||
- `today_summary.py` — post-clockout card.
|
||||
- `goal_widget.py` — monthly progress bars (overtime cap, daily avg).
|
||||
- `meal_time_dialog.py` — lunch/dinner real start-end input.
|
||||
- `past_record_dialog.py` — calendar right-click "add past record".
|
||||
- `leave_calendar_view.py` — color-coded leave (green/yellow/purple).
|
||||
- `accessibility.py` — `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`.
|
||||
- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
|
||||
|
||||
### `ui/controllers/`
|
||||
- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
|
||||
- `auto_lunch.py` — toggles lunch after 4 hours since clock-in.
|
||||
- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays.
|
||||
|
||||
### `utils/`
|
||||
- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API.
|
||||
- `lock_detector.py` — `OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
|
||||
- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally.
|
||||
- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`.
|
||||
- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
|
||||
- `csv_exporter.py` — same format.
|
||||
- `crash_handler.py` — `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report.
|
||||
- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`.
|
||||
- `system_tray.py` — tray menu with i18n labels.
|
||||
- `time_format.py` — `format_hours_minutes(minutes)`.
|
||||
- `debug_log.py` — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`.
|
||||
- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver.
|
||||
|
||||
### Top-level
|
||||
- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow.
|
||||
- `updater.py` — Standalone PID wait + file replace + relaunch.
|
||||
- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`.
|
||||
- `updater.spec` — Standalone updater build.
|
||||
- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.
|
||||
|
||||
## ⚙️ Build Process
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean main.spec
|
||||
# Manual two-step
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
||||
|
||||
# Or one-shot
|
||||
.\release.ps1 v2.7.0
|
||||
```
|
||||
|
||||
- Output: `dist/main.exe` (console disabled, UPX compressed)
|
||||
- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`)
|
||||
- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first.
|
||||
- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env.
|
||||
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
||||
- `holidays` package only baked in if installed in build env.
|
||||
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
||||
|
||||
## 🚦 External Integrations
|
||||
|
||||
- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design.
|
||||
- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<version>`. Repo must be public.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
|
||||
- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only.
|
||||
- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
|
||||
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
|
||||
## 🐞 Past Incidents
|
||||
## 🐞 Past Incidents (do NOT re-introduce)
|
||||
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce.
|
||||
- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling.
|
||||
- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters.
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
|
||||
- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`.
|
||||
- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor).
|
||||
- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
|
||||
- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
|
||||
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
|
||||
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`.
|
||||
- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
|
||||
- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
|
||||
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
|
||||
|
||||
## 🌐 i18n Coverage Status
|
||||
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels.
|
||||
- **Partially translated**: settings_view sub-labels (입력란 placeholder 등), calendar_view detail labels.
|
||||
- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated.
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
|
||||
- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
|
||||
- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).
|
||||
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`.
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`.
|
||||
|
||||
## 🚢 Release Flow ([release.ps1](release.ps1))
|
||||
|
||||
```
|
||||
0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
|
||||
1. Bump core/version.py
|
||||
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
|
||||
3. PyInstaller (updater.spec → staging copy → main.spec)
|
||||
4. ZIP packaging (main.exe + updater.exe)
|
||||
5. Git commit (version.py + CHANGELOG.md) + tag + push
|
||||
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
|
||||
7. Asset upload (main.exe, updater.exe, ZIP)
|
||||
```
|
||||
|
||||
`--DryRun` previews without git push or API calls.
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.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종)
|
||||
|
||||
156
CLAUDE.md
156
CLAUDE.md
@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable).
|
||||
**Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases.
|
||||
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`.
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`.
|
||||
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md).
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Build and Run
|
||||
|
||||
@ -20,46 +20,73 @@ python main.py
|
||||
python core/event_monitor.py
|
||||
python core/time_calculator.py
|
||||
|
||||
# Production build → dist/main.exe
|
||||
# Production build → dist/main.exe (78MB, embeds updater.exe)
|
||||
python -m PyInstaller --clean updater.spec # build first — main.spec datas references it
|
||||
python -m PyInstaller --clean main.spec
|
||||
|
||||
# Integration tests (35 + 5 + view scenarios)
|
||||
python _integration_test.py
|
||||
python _i18n_gui_test.py
|
||||
python _gui_smoke_test.py
|
||||
```
|
||||
# Tests
|
||||
python _integration_test.py # business-logic scenarios
|
||||
python _i18n_gui_test.py # ko/en GUI verification
|
||||
python _gui_smoke_test.py # widget instantiation
|
||||
python -m pytest tests # unit tests
|
||||
|
||||
PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build.
|
||||
# Release (one-shot to Gitea)
|
||||
$env:GITEA_TOKEN = '<PAT>'
|
||||
.\release.ps1 v2.7.0
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core (`core/`)
|
||||
- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`.
|
||||
- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`.
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required.
|
||||
- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en.
|
||||
- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`.
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings.
|
||||
### core/
|
||||
- **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness.
|
||||
- **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property.
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
|
||||
- **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold.
|
||||
- **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0).
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
|
||||
- **[version.py](core/version.py)** — `__version__` single source of truth.
|
||||
|
||||
### UI (`ui/`)
|
||||
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R).
|
||||
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`.
|
||||
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button.
|
||||
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible.
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension).
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing.
|
||||
### ui/
|
||||
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run.
|
||||
- **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog.
|
||||
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import.
|
||||
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`.
|
||||
- **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
|
||||
- **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
|
||||
- **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input.
|
||||
- **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click).
|
||||
- **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar.
|
||||
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display.
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button.
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`.
|
||||
- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay.
|
||||
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n).
|
||||
|
||||
### Utils (`utils/`)
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy.
|
||||
- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked).
|
||||
- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`.
|
||||
- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production.
|
||||
### ui/controllers/
|
||||
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
|
||||
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
|
||||
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
|
||||
|
||||
### 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.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n.
|
||||
|
||||
### Time-off accounting in `update_display()`
|
||||
### Top-level
|
||||
- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow.
|
||||
- **[updater.py](updater.py)** — Standalone helper. `--pid <main_pid> --new <new_exe> --target <target_exe>`. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback.
|
||||
- **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps).
|
||||
- **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it).
|
||||
- **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`.
|
||||
|
||||
## Time-off accounting in `update_display()`
|
||||
|
||||
Critical invariant — preserve in any change:
|
||||
```python
|
||||
@ -72,49 +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
|
||||
```
|
||||
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`.
|
||||
|
||||
### Workday rollover
|
||||
|
||||
`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006).
|
||||
|
||||
## Database invariants
|
||||
|
||||
- `work_records.date` UNIQUE (one row/day).
|
||||
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label.
|
||||
- `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25).
|
||||
- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code.
|
||||
- `work_records.date` UNIQUE.
|
||||
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions.
|
||||
- `leave_records.days` is FLOAT (1.0/0.5/0.25).
|
||||
- Balance: `SUM(bank.earned) - SUM(usage.used)`.
|
||||
- `notification_log` for daily dedupe (channel+event_type+date).
|
||||
- `crash_log` for unhandled exceptions.
|
||||
|
||||
## Settings system
|
||||
|
||||
Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`:
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only)
|
||||
Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`:
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor)
|
||||
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
|
||||
|
||||
Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup.
|
||||
Migration sentinels prevent re-running.
|
||||
|
||||
## i18n
|
||||
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal.
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장.
|
||||
|
||||
Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text).
|
||||
Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched.
|
||||
|
||||
## Conventions and gotchas
|
||||
## Conventions
|
||||
|
||||
- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types.
|
||||
- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`.
|
||||
- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover.
|
||||
- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min.
|
||||
- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture.
|
||||
- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon).
|
||||
- **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict.
|
||||
- 24h `datetime` internal. 12h conversion only in `format_time()`.
|
||||
- 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min.
|
||||
- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
|
||||
- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
|
||||
|
||||
## Tests
|
||||
|
||||
- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets.
|
||||
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
|
||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_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).
|
||||
|
||||
176
INSTALL.md
176
INSTALL.md
@ -1,24 +1,45 @@
|
||||
# 설치 가이드
|
||||
|
||||
## 1. Python 설치
|
||||
## 일반 사용자 — 빌드된 .exe로 설치 (권장)
|
||||
|
||||
소스 빌드 없이 즉시 사용하려면 Gitea Releases에서 받으세요.
|
||||
|
||||
1. https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator/releases
|
||||
2. 최신 릴리스의 **main.exe** (단일 파일) 다운로드
|
||||
3. 원하는 폴더에 두고 더블클릭으로 실행
|
||||
4. 첫 실행 시 5단계 온보딩 위저드가 안내
|
||||
|
||||
`main.exe` 안에 `updater.exe`가 내장되어 있어 첫 실행 시 같은 폴더로 자동 추출됩니다.
|
||||
이후 새 버전이 올라오면 앱이 알림 → 동의 → 자동 다운로드·교체·재시작합니다.
|
||||
|
||||
> 옵션: 직접 실행하지 않고 ZIP을 받으면 `main.exe + updater.exe`가 같이 들어 있습니다.
|
||||
|
||||
## 개발자 — 소스에서 실행
|
||||
|
||||
### 1. Python 설치
|
||||
|
||||
Python 3.9 이상이 필요합니다.
|
||||
|
||||
### Windows
|
||||
#### Windows
|
||||
1. https://www.python.org/downloads/ 방문
|
||||
2. "Download Python 3.x.x" 클릭
|
||||
3. 설치 시 **"Add Python to PATH"** 체크 필수!
|
||||
3. 설치 시 **"Add Python to PATH"** 체크 필수
|
||||
|
||||
확인:
|
||||
```bash
|
||||
python --version
|
||||
```
|
||||
|
||||
## 2. 프로젝트 다운로드
|
||||
### 2. 프로젝트 다운로드
|
||||
|
||||
프로젝트를 다운로드하거나 압축을 해제합니다.
|
||||
```bash
|
||||
git clone https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator.git
|
||||
cd Clock_out_Time_Calculator
|
||||
```
|
||||
|
||||
## 3. 패키지 설치
|
||||
또는 ZIP을 받아 압축 해제.
|
||||
|
||||
### 3. 패키지 설치
|
||||
|
||||
프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다.
|
||||
|
||||
@ -26,66 +47,26 @@ python --version
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 설치되는 패키지:
|
||||
1. **PyQt5** - GUI 프레임워크
|
||||
2. **pywin32** - Windows API 접근
|
||||
3. **python-dateutil** - 날짜 계산
|
||||
4. **matplotlib** - 그래프
|
||||
5. **plyer** - 알림 시스템
|
||||
6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함)
|
||||
#### 설치되는 패키지 (requirements.txt)
|
||||
1. **PyQt5** — GUI 프레임워크
|
||||
2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지)
|
||||
3. **python-dateutil** — 날짜 계산
|
||||
4. **matplotlib** — 통계 차트
|
||||
5. **plyer** — 시스템 알림
|
||||
6. **holidays** — 한국 공휴일 자동 등록 (음력 명절 포함)
|
||||
|
||||
## 4. 리소스 다운로드 (선택)
|
||||
|
||||
리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다.
|
||||
|
||||
```bash
|
||||
python download_resources.py
|
||||
```
|
||||
|
||||
이 스크립트는 무료 리소스 다운로드 링크를 안내합니다.
|
||||
|
||||
### 수동 다운로드:
|
||||
|
||||
#### 아이콘
|
||||
다음 사이트에서 다운로드:
|
||||
- **Flaticon**: https://www.flaticon.com/
|
||||
- **Material Design Icons**: https://materialdesignicons.com/
|
||||
- **Icons8**: https://icons8.com/
|
||||
|
||||
다운로드 후 `resources/icons/` 폴더에 저장
|
||||
|
||||
필요한 아이콘:
|
||||
- `app_icon.ico` (512x512)
|
||||
- `clock.png`, `timer.png`, `lunch.png`
|
||||
- `calendar.png`, `statistics.png`, `vacation.png`
|
||||
- `settings.png`, `notification.png`
|
||||
|
||||
#### 사운드
|
||||
다음 사이트에서 다운로드:
|
||||
- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천)
|
||||
- **Freesound**: https://freesound.org/
|
||||
- **Zapsplat**: https://www.zapsplat.com/
|
||||
|
||||
다운로드 후 `resources/sounds/` 폴더에 저장
|
||||
|
||||
필요한 사운드:
|
||||
- `clock_out_alarm.wav` - 퇴근시간 알림
|
||||
- `notification.wav` - 일반 알림
|
||||
- `success.wav` - 성공 효과음
|
||||
|
||||
## 5. 실행
|
||||
### 4. 실행
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 관리자 권한으로 실행 (권장)
|
||||
#### 관리자 권한으로 실행 (권장)
|
||||
|
||||
Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다.
|
||||
|
||||
1. **방법 1**: cmd를 관리자 권한으로 실행
|
||||
- Windows 키 + X
|
||||
- "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택
|
||||
- Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)"
|
||||
- 프로젝트 폴더로 이동: `cd "경로"`
|
||||
- `python main.py` 실행
|
||||
|
||||
@ -93,11 +74,39 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있
|
||||
- `python main.py`를 실행하는 배치 파일(.bat) 생성
|
||||
- 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크
|
||||
|
||||
## 6. 첫 실행
|
||||
### 5. 첫 실행
|
||||
|
||||
1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성
|
||||
2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지
|
||||
3. 감지된 시간이 출근시간으로 설정됨
|
||||
1. 실행 시 자동으로 데이터베이스(`database.db`) 생성
|
||||
2. 5단계 온보딩 위저드 표시
|
||||
- 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → Discord 웹훅(옵션)
|
||||
3. 위저드 완료 후 메인 화면 진입
|
||||
4. 이후 실행에서는 위저드 없이 바로 시작
|
||||
|
||||
### 6. 단축근무자 설정 (예: 7시간 30분)
|
||||
|
||||
온보딩에서 미설정한 경우:
|
||||
1. 설정(`Ctrl+,`) → 근무 시간 → **근무 패턴**
|
||||
2. "단축근무 7시간 30분 (점심 30분)" 또는 사용자 정의 선택
|
||||
3. 시·분 직접 입력 가능 (5분 단위)
|
||||
4. 저장 → 즉시 메인 화면 반영
|
||||
|
||||
## 클라우드 동기화 (여러 PC 공용)
|
||||
|
||||
OneDrive / Dropbox 폴더에 DB를 두면 자동 동기화됩니다 (WAL 모드).
|
||||
|
||||
1. OneDrive/Dropbox 안에 `database.db` 위치 결정
|
||||
2. 설정 → 데이터 관리 → DB 경로 → 변경
|
||||
3. 기존 DB를 새 위치로 복사 → 재시작
|
||||
4. 다른 PC에서도 같은 경로 지정하면 데이터 공유
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 변수 | 용도 |
|
||||
|------|------|
|
||||
| `CLOCKOUT_DEBUG=1` | `~/.clockout_logs/debug.log`에 디버그 로그 출력 |
|
||||
| `CLOCKOUT_DEBUG_DIR=경로` | 로그 저장 위치 변경 |
|
||||
| `GITEA_TOKEN` | 릴리스 발행 시 PAT (개발자용) |
|
||||
| `CODE_SIGN_CERT` / `CODE_SIGN_PASS` | Authenticode 인증서 경로/암호 (개발자용) |
|
||||
|
||||
## 문제 해결
|
||||
|
||||
@ -119,42 +128,57 @@ https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||
|
||||
### 이벤트 뷰어 접근 불가
|
||||
- 관리자 권한으로 실행
|
||||
- 또는 설정에서 수동 입력 모드 사용
|
||||
- 또는 메인 화면 출근시각 옆 ✏️ 버튼으로 수동 입력
|
||||
|
||||
### Discord 웹훅 "실패" 표시
|
||||
- v2.3.3 이전 버전에서 발생 — Cloudflare가 Python User-Agent 차단
|
||||
- 최신 버전으로 업데이트하면 해결 (브라우저 UA로 우회)
|
||||
|
||||
### 온보딩 위저드를 다시 보고 싶음
|
||||
- 도움말(F1) → "🚀 온보딩 다시 보기"
|
||||
|
||||
## 업그레이드
|
||||
|
||||
### .exe 사용자
|
||||
- 앱이 자동 감지 → 알림 → 동의 → 자동 처리
|
||||
- 수동 트리거: F5 또는 설정 → 데이터 관리 → "업데이트 확인"
|
||||
|
||||
### 소스 사용자
|
||||
```bash
|
||||
git pull
|
||||
pip install --upgrade -r requirements.txt
|
||||
```
|
||||
|
||||
## 제거
|
||||
|
||||
1. 프로젝트 폴더 삭제
|
||||
2. 패키지 제거 (선택):
|
||||
```bash
|
||||
pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer
|
||||
```
|
||||
1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스)
|
||||
2. 데이터 보존하려면 `database.db`만 별도 백업
|
||||
3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제)
|
||||
|
||||
## 프로덕션 빌드 (PyInstaller)
|
||||
|
||||
소스 없이 실행 파일만 배포하려면:
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB)
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터)
|
||||
# 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드)
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
|
||||
# updater.exe를 staging 폴더로 복사 (main.spec --clean 시 보호)
|
||||
mkdir -p build/staging
|
||||
cp dist/updater.exe build/staging/
|
||||
|
||||
# 메인 앱 빌드 (updater.exe 임베드 포함)
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB)
|
||||
```
|
||||
|
||||
배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요.
|
||||
자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다.
|
||||
또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화.
|
||||
|
||||
빌드 시 주의:
|
||||
- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행
|
||||
- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력
|
||||
- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경
|
||||
- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출)
|
||||
|
||||
## 다음 단계
|
||||
|
||||
설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요!
|
||||
설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요.
|
||||
개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요.
|
||||
|
||||
36
README.md
36
README.md
@ -45,9 +45,11 @@
|
||||
|
||||
### 7. 통계·분석
|
||||
- 주간/월간 요약 + matplotlib 차트
|
||||
- 일별 근무시간 + 연장 누적 막대 그래프
|
||||
- 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁)
|
||||
- 요일별 평균 근무시간
|
||||
- 근무 패턴 인사이트 (정적 통계 요약)
|
||||
- 출근 시각 분포 히스토그램 (30분 단위 + 평균선)
|
||||
- 근무 패턴 인사이트
|
||||
- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약)
|
||||
|
||||
### 8. 공휴일 관리
|
||||
- 한국 공휴일 자동 등록 (`holidays` 패키지)
|
||||
@ -69,13 +71,35 @@
|
||||
- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작
|
||||
- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거
|
||||
- 실패 시 자동 롤백
|
||||
- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출)
|
||||
|
||||
### 12. 다국어 지원 (i18n)
|
||||
- 한국어 / English 전환
|
||||
- 알림 메시지·UI 라벨 28개 카테고리
|
||||
### 12. 첫 실행 온보딩 위저드
|
||||
- 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시
|
||||
- 기존 사용자: 자동 완료 처리 + 도움말(F1) → "🚀 온보딩 다시 보기"
|
||||
|
||||
### 13. 사용자 친화 기능
|
||||
- **메인 화면 인라인 편집** — 출퇴근 시각 라벨 클릭으로 즉시 수정
|
||||
- **퇴근 후 "오늘 요약" 카드** — 총 근무·점심·외출·연장·추정급여 한눈에
|
||||
- **장시간 근무 휴식 권고** — 4시간 연속 근무 시 토스트 + Discord push
|
||||
- **점심/저녁 실제 시간 입력** — 버튼 우클릭 → 시작·종료 시각
|
||||
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
|
||||
- **월간 목표** — 연장근무 상한 / 일평균 목표 + 진행률 게이지
|
||||
- **CSV 가져오기** — 표준 포맷으로 타 도구에서 마이그레이션
|
||||
- **자동 Crash Report** — 예외 발생 시 Gitea Issues 자동 등록 (옵션)
|
||||
- **주간 자동 리포트** — 월요일 첫 출근 시 지난주 요약 push
|
||||
- **휴가 캘린더** — 종일/반차/반반차 색상 구분
|
||||
- **Discord 웹훅 알림** — 출퇴근/휴식권고/주간리포트 모바일 push (옵션, 서버 0)
|
||||
|
||||
### 14. 접근성
|
||||
- 글꼴 크기 100% / 125% / 150%
|
||||
- 고대비 모드 (검정 배경 + 노란 텍스트)
|
||||
|
||||
### 16. 다국어 지원 (i18n)
|
||||
- 한국어 / English 전환 (재시작 또는 즉시 갱신)
|
||||
- 알림 메시지·UI 라벨 30+ 카테고리
|
||||
- HelpView 6개 탭 ko/en HTML 콘텐츠
|
||||
|
||||
### 13. 단축키
|
||||
### 17. 단축키
|
||||
- `Ctrl+O` 출/퇴근 토글
|
||||
- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글
|
||||
- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말
|
||||
|
||||
@ -485,6 +485,234 @@ def s35_lock():
|
||||
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
|
||||
# ============================================================
|
||||
|
||||
@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
|
||||
def s36_onboarding_new():
|
||||
db = fresh_db('s36')
|
||||
assert db.get_setting('onboarding_completed') == 'false'
|
||||
|
||||
|
||||
@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
|
||||
def s37_onboarding_existing():
|
||||
db = fresh_db('s37')
|
||||
# work_record 1건 추가 후 마이그레이션 재실행
|
||||
today = date.today().isoformat()
|
||||
db.add_work_record(today, '09:00:00', is_manual=True)
|
||||
# 다시 호출 (init_database에서 호출되는 헬퍼)
|
||||
db.migrate_v23_onboarding_for_existing()
|
||||
assert db.get_setting('onboarding_completed') == 'true'
|
||||
|
||||
|
||||
@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
|
||||
def s38_salary_zero_wage():
|
||||
from core.salary import estimate_pay
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
|
||||
assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0
|
||||
|
||||
|
||||
@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
|
||||
def s39_salary_basic():
|
||||
from core.salary import estimate_pay
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 8.0, 'overtime_minutes': 30}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=1.5,
|
||||
)
|
||||
# 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
|
||||
# 연장 = 0.5h × 10000 × 1.5 = 7500
|
||||
assert abs(out['base'] - 75000) < 0.01, out['base']
|
||||
assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
|
||||
assert abs(out['total'] - 82500) < 0.01
|
||||
|
||||
|
||||
@case("S40. salary.format_won: 콤마 + '원' 접미사")
|
||||
def s40_format_won():
|
||||
from core.salary import format_won
|
||||
assert format_won(0) == '0원'
|
||||
assert format_won(1234567) == '1,234,567원'
|
||||
assert format_won(82500.4) == '82,500원'
|
||||
|
||||
|
||||
@case("S41. CSV 가져오기: 표준 포맷 파싱")
|
||||
def s41_csv_parse():
|
||||
from utils.csv_importer import parse_csv
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,첫째날\n")
|
||||
f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
|
||||
rows = parse_csv(p)
|
||||
os.remove(p)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS)
|
||||
assert rows[0]['lunch_minutes'] == 60
|
||||
assert rows[1]['lunch_minutes'] == 30
|
||||
assert rows[1]['memo'] == '단축'
|
||||
|
||||
|
||||
@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
|
||||
def s42_csv_invalid():
|
||||
from utils.csv_importer import parse_csv
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("not-a-date,09:00,18:00,60,\n")
|
||||
try:
|
||||
parse_csv(p)
|
||||
assert False, "should have raised"
|
||||
except ValueError:
|
||||
pass
|
||||
finally:
|
||||
os.remove(p)
|
||||
|
||||
|
||||
@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
|
||||
def s43_csv_skip():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s43')
|
||||
# 기존 레코드 1건
|
||||
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
|
||||
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,중복\n")
|
||||
f.write("2026-04-02,09:00,18:00,60,신규\n")
|
||||
|
||||
rows = parse_csv(p)
|
||||
added, updated, skipped = import_records(db, rows, on_conflict='skip')
|
||||
os.remove(p)
|
||||
assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
|
||||
# 기존 레코드 보존 확인 (08:30 그대로)
|
||||
rec = db.get_work_record('2026-04-01')
|
||||
assert rec['clock_in'] == '08:30:00'
|
||||
|
||||
|
||||
@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
|
||||
def s44_csv_overwrite():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s44')
|
||||
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
|
||||
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")
|
||||
|
||||
rows = parse_csv(p)
|
||||
added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
|
||||
os.remove(p)
|
||||
assert updated == 1 and added == 0
|
||||
rec = db.get_work_record('2026-04-01')
|
||||
assert rec['clock_in'] == '09:00:00'
|
||||
|
||||
|
||||
@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
|
||||
def s45_notification_dedupe():
|
||||
db = fresh_db('s45')
|
||||
assert not db.has_notification_today('discord', 'weekly_report')
|
||||
db.log_notification('discord', 'weekly_report', payload='test', success=True)
|
||||
assert db.has_notification_today('discord', 'weekly_report')
|
||||
# 다른 event_type은 별개
|
||||
assert not db.has_notification_today('discord', 'clock_in')
|
||||
|
||||
|
||||
@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
|
||||
def s46_meal_record():
|
||||
db = fresh_db('s46')
|
||||
today = date.today().isoformat()
|
||||
# 오늘이 아닌 날짜로 add (work_record 미존재 OK)
|
||||
db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
|
||||
db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
|
||||
# get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
|
||||
('2026-04-01',))
|
||||
assert cur.fetchone()[0] == 60
|
||||
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
|
||||
('2026-04-01',))
|
||||
assert cur.fetchone()[0] == 30
|
||||
conn.close()
|
||||
|
||||
|
||||
@case("S47. crash_log 자동 생성 + 기록")
|
||||
def s47_crash_log():
|
||||
from utils.crash_handler import _log_crash
|
||||
db = fresh_db('s47')
|
||||
_log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
|
||||
assert cur.fetchone()[0] == 1
|
||||
cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
|
||||
msg, ver = cur.fetchone()
|
||||
assert msg == 'sample message' and ver == 'v2.6.0'
|
||||
conn.close()
|
||||
|
||||
|
||||
@case("S48. updater_client.is_newer: 정확한 semver 비교")
|
||||
def s48_updater_compare():
|
||||
from utils.updater_client import is_newer
|
||||
assert is_newer('v2.7.0', '2.6.0')
|
||||
assert is_newer('2.6.1', 'v2.6.0')
|
||||
assert not is_newer('v2.6.0', 'v2.6.0')
|
||||
assert not is_newer('v2.5.0', 'v2.6.0')
|
||||
assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교
|
||||
|
||||
|
||||
@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
|
||||
def s49_discord_empty():
|
||||
from utils.discord_webhook import send, send_test
|
||||
assert send('', 't', 'd') is False
|
||||
assert send('http://invalid', 't', 'd') is False # https:// 아님
|
||||
assert send_test('') is False
|
||||
|
||||
|
||||
@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
|
||||
def s50_goals():
|
||||
db = fresh_db('s50')
|
||||
# 기본값 확인 (0)
|
||||
assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
|
||||
assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
|
||||
# 활성화
|
||||
db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
|
||||
assert db.get_setting_int('goal_overtime_max_monthly') == 1200
|
||||
assert db.get_setting_int('goal_avg_hours_daily') == 8
|
||||
|
||||
|
||||
@case("S51. 글꼴 크기 / 고대비 설정 키")
|
||||
def s51_accessibility_keys():
|
||||
db = fresh_db('s51')
|
||||
# 기본값
|
||||
assert db.get_setting_float('font_scale', 1.0) == 1.0
|
||||
assert db.get_setting_bool('high_contrast', False) is False
|
||||
# 변경
|
||||
db.save_settings({'font_scale': 1.5, 'high_contrast': True})
|
||||
assert db.get_setting_float('font_scale') == 1.5
|
||||
assert db.get_setting_bool('high_contrast') is True
|
||||
|
||||
|
||||
@case("S52. CSV import + overtime 적립까지 정상 동작")
|
||||
def s52_csv_overtime():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s52')
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
# 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
|
||||
f.write("2026-04-01,09:00,19:30,60,연장근무\n")
|
||||
rows = parse_csv(p)
|
||||
added, _, _ = import_records(db, rows, on_conflict='skip')
|
||||
os.remove(p)
|
||||
assert added == 1
|
||||
bal = db.get_total_overtime_balance()
|
||||
assert bal == 90, f"overtime balance: {bal}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Run all
|
||||
# ============================================================
|
||||
@ -520,6 +748,23 @@ def main():
|
||||
s33_short_overtime()
|
||||
s34_format()
|
||||
s35_lock()
|
||||
s36_onboarding_new()
|
||||
s37_onboarding_existing()
|
||||
s38_salary_zero_wage()
|
||||
s39_salary_basic()
|
||||
s40_format_won()
|
||||
s41_csv_parse()
|
||||
s42_csv_invalid()
|
||||
s43_csv_skip()
|
||||
s44_csv_overwrite()
|
||||
s45_notification_dedupe()
|
||||
s46_meal_record()
|
||||
s47_crash_log()
|
||||
s48_updater_compare()
|
||||
s49_discord_empty()
|
||||
s50_goals()
|
||||
s51_accessibility_keys()
|
||||
s52_csv_overtime()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
|
||||
196
core/i18n.py
196
core/i18n.py
@ -103,6 +103,7 @@ _DICT = {
|
||||
'msg.no_record.body': '오늘 출근 기록이 없습니다.',
|
||||
'msg.confirm_delete.title': '삭제 확인',
|
||||
'msg.no_data.title': '데이터 없음',
|
||||
'msg.manual_added': '수동 추가',
|
||||
|
||||
# === 트레이 ===
|
||||
'tray.open': '프로그램 열기',
|
||||
@ -153,6 +154,103 @@ _DICT = {
|
||||
'help.tab_leave': '🌴 연차/휴가',
|
||||
'help.tab_break': '🚪 외출/저녁',
|
||||
'help.tab_faq': '❓ 자주 묻는 질문',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
|
||||
'dlg.clock_in.label': '출근시간:',
|
||||
'dlg.clock_in.quick': '빠른 선택:',
|
||||
'dlg.clock_in.btn_now': '현재',
|
||||
|
||||
# === break_view ===
|
||||
'dlg.break.edit_title': '외출 기록 수정',
|
||||
'dlg.break.out_label': '외출 시간:',
|
||||
'dlg.break.in_label': '복귀 시간:',
|
||||
'dlg.break.reason_label': '사유:',
|
||||
'view.break.today_title': '오늘의 외출 기록',
|
||||
'view.break.col_out': '외출 시간',
|
||||
'view.break.col_in': '복귀 시간',
|
||||
'view.break.col_duration': '소요 시간',
|
||||
'view.break.col_reason': '사유',
|
||||
'view.break.in_progress': '진행중',
|
||||
'view.break.total_zero': '총 외출 시간: 0분',
|
||||
'view.break.total_fmt': '총 외출 시간: {h}시간 {m}분',
|
||||
'view.break.total_min_only': '총 외출 시간: {m}분',
|
||||
'view.break.duration_fmt': '{h}시간 {m}분',
|
||||
'view.break.duration_min_only': '{m}분',
|
||||
'view.break.delete_confirm': '이 외출 기록을 삭제하시겠습니까?',
|
||||
'btn.refresh': '새로고침',
|
||||
'btn.edit_short': '수정',
|
||||
'btn.delete_short': '삭제',
|
||||
|
||||
# === overtime_view ===
|
||||
'view.overtime.title': '연장근무 내역',
|
||||
'view.overtime.balance_zero': '잔액: 0분',
|
||||
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
|
||||
'view.overtime.earned_group': '💰 적립 내역',
|
||||
'view.overtime.used_group': '📤 사용 내역',
|
||||
'view.overtime.col_date': '날짜',
|
||||
'view.overtime.col_earned': '적립',
|
||||
'view.overtime.col_used': '사용',
|
||||
'view.overtime.col_memo': '메모',
|
||||
'view.overtime.col_reason': '사유',
|
||||
'view.overtime.btn_add_earned': '➕ 수동 적립',
|
||||
'view.overtime.btn_add_used': '➕ 수동 사용',
|
||||
'view.overtime.menu_delete': '❌ 삭제',
|
||||
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
|
||||
'view.overtime.manual_earned_title': '추가근무 수동 적립',
|
||||
'view.overtime.manual_used_title': '추가근무 수동 사용',
|
||||
'view.overtime.field_date': '날짜:',
|
||||
'view.overtime.field_time': '시간:',
|
||||
'view.overtime.field_memo': '메모:',
|
||||
'view.overtime.field_reason': '사유:',
|
||||
'view.overtime.unit_hour_suffix': '시간',
|
||||
'view.overtime.minute_0': '0분',
|
||||
'view.overtime.minute_30': '30분',
|
||||
'view.overtime.placeholder_memo': '선택사항',
|
||||
'view.overtime.placeholder_reason': '예: 개인 사유',
|
||||
'view.overtime.zero_add_error': '0분은 추가할 수 없습니다.',
|
||||
'view.overtime.zero_use_error': '0분은 사용할 수 없습니다.',
|
||||
'view.overtime.balance_short_title': '잔액 부족',
|
||||
'view.overtime.balance_short_body': '사용 가능한 시간이 부족합니다.\n\n요청: {req_h}시간 {req_m}분\n잔액: {bal_h}시간 {bal_m}분',
|
||||
'view.overtime.saved_earned': '{h}시간 {m}분이 적립되었습니다.',
|
||||
'view.overtime.saved_used': '{h}시간 {m}분이 사용 처리되었습니다.',
|
||||
|
||||
# === leave_view ===
|
||||
'view.leave.title': '연차 관리',
|
||||
'view.leave.balance_zero': '잔여: 0일',
|
||||
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
|
||||
'view.leave.btn_set_balance': '잔여 설정',
|
||||
'view.leave.used_group': '📤 사용 내역',
|
||||
'view.leave.col_date': '날짜',
|
||||
'view.leave.col_type': '구분',
|
||||
'view.leave.col_used': '사용',
|
||||
'view.leave.col_reason': '사유',
|
||||
'view.leave.btn_add': '➕ 연차 사용 추가',
|
||||
'view.leave.btn_calendar': '📅 캘린더 보기',
|
||||
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
|
||||
'view.leave.set_title': '연차 시간 설정',
|
||||
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
|
||||
'view.leave.set_done_title': '설정 완료',
|
||||
'view.leave.set_done_body': '연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다.',
|
||||
'view.leave.add_title': '연차 사용 기록 추가',
|
||||
'view.leave.field_date': '날짜:',
|
||||
'view.leave.field_type': '구분:',
|
||||
'view.leave.field_hours': '시간:',
|
||||
'view.leave.field_reason': '사유:',
|
||||
'view.leave.type_annual': '연차',
|
||||
'view.leave.type_half': '반차',
|
||||
'view.leave.type_quarter': '반반차',
|
||||
'view.leave.type_hourly': '시간',
|
||||
'view.leave.placeholder_reason': '예) 개인 사유, 병원 방문 등',
|
||||
'view.leave.note_auto_deduct': '※ 잔여 연차가 자동 차감됩니다.',
|
||||
'view.leave.short_title': '잔여 연차 부족',
|
||||
'view.leave.short_body': '잔여 연차가 부족합니다.\n현재 잔여: {balance}일\n사용 요청: {req}일',
|
||||
'view.leave.confirm_title': '연차 사용 기록 추가',
|
||||
'view.leave.confirm_body': '날짜: {date}\n구분: {type}\n사용: {days}일 ({hours}시간)\n사유: {reason}\n\n이 기록을 추가하시겠습니까?',
|
||||
'view.leave.added_title': '추가 완료',
|
||||
'view.leave.added_body': '{days}일 ({hours}시간)의 연차 사용이 기록되었습니다.',
|
||||
'view.leave.error_title': '오류',
|
||||
'view.leave.error_body': '연차 기록 추가 중 오류가 발생했습니다:\n{err}',
|
||||
},
|
||||
'en': {
|
||||
# === Menu/Buttons ===
|
||||
@ -244,6 +342,7 @@ _DICT = {
|
||||
'msg.no_record.body': 'No clock-in record for today.',
|
||||
'msg.confirm_delete.title': 'Confirm Delete',
|
||||
'msg.no_data.title': 'No Data',
|
||||
'msg.manual_added': 'Manual',
|
||||
|
||||
# === Tray ===
|
||||
'tray.open': 'Open Program',
|
||||
@ -294,6 +393,103 @@ _DICT = {
|
||||
'help.tab_leave': '🌴 Leave',
|
||||
'help.tab_break': '🚪 Break/Dinner',
|
||||
'help.tab_faq': '❓ FAQ',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': "Enter today's clock-in time",
|
||||
'dlg.clock_in.label': 'Clock-in time:',
|
||||
'dlg.clock_in.quick': 'Quick pick:',
|
||||
'dlg.clock_in.btn_now': 'Now',
|
||||
|
||||
# === break_view ===
|
||||
'dlg.break.edit_title': 'Edit Break Record',
|
||||
'dlg.break.out_label': 'Break out:',
|
||||
'dlg.break.in_label': 'Return:',
|
||||
'dlg.break.reason_label': 'Reason:',
|
||||
'view.break.today_title': "Today's Break Records",
|
||||
'view.break.col_out': 'Break out',
|
||||
'view.break.col_in': 'Return',
|
||||
'view.break.col_duration': 'Duration',
|
||||
'view.break.col_reason': 'Reason',
|
||||
'view.break.in_progress': 'In progress',
|
||||
'view.break.total_zero': 'Total break: 0 min',
|
||||
'view.break.total_fmt': 'Total break: {h}h {m}m',
|
||||
'view.break.total_min_only': 'Total break: {m} min',
|
||||
'view.break.duration_fmt': '{h}h {m}m',
|
||||
'view.break.duration_min_only': '{m} min',
|
||||
'view.break.delete_confirm': 'Delete this break record?',
|
||||
'btn.refresh': 'Refresh',
|
||||
'btn.edit_short': 'Edit',
|
||||
'btn.delete_short': 'Delete',
|
||||
|
||||
# === overtime_view ===
|
||||
'view.overtime.title': 'Overtime History',
|
||||
'view.overtime.balance_zero': 'Balance: 0 min',
|
||||
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
|
||||
'view.overtime.earned_group': '💰 Earned',
|
||||
'view.overtime.used_group': '📤 Used',
|
||||
'view.overtime.col_date': 'Date',
|
||||
'view.overtime.col_earned': 'Earned',
|
||||
'view.overtime.col_used': 'Used',
|
||||
'view.overtime.col_memo': 'Memo',
|
||||
'view.overtime.col_reason': 'Reason',
|
||||
'view.overtime.btn_add_earned': '➕ Manual Earn',
|
||||
'view.overtime.btn_add_used': '➕ Manual Use',
|
||||
'view.overtime.menu_delete': '❌ Delete',
|
||||
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
|
||||
'view.overtime.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}',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.6.0'
|
||||
__version__ = '2.7.0'
|
||||
|
||||
110
tests/test_crash_handler.py
Normal file
110
tests/test_crash_handler.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
utils.crash_handler 단위 테스트.
|
||||
|
||||
GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from utils.crash_handler import _log_crash, _send_to_gitea
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db')
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
d = Database(p)
|
||||
yield d
|
||||
try:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestLogCrash:
|
||||
def test_creates_table_and_inserts(self, db):
|
||||
_log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT exception_type, message, app_version FROM crash_log")
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 'TestExc'
|
||||
assert row[1] == 'msg'
|
||||
assert row[2] == 'v2.6.0'
|
||||
|
||||
def test_table_idempotent_creation(self, db):
|
||||
# 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS)
|
||||
_log_crash(db, 'A', 'a', 't', 'v1')
|
||||
_log_crash(db, 'B', 'b', 't', 'v1')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM crash_log")
|
||||
count = cur.fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 2
|
||||
|
||||
def test_silent_on_db_error(self, db):
|
||||
# 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음)
|
||||
broken = MagicMock()
|
||||
broken.get_connection.side_effect = RuntimeError('boom')
|
||||
# raise되면 안 됨
|
||||
_log_crash(broken, 'X', 'x', 'tb', 'v')
|
||||
|
||||
|
||||
class TestSendToGitea:
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 201
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = _send_to_gitea('fake_token', 'title', 'body')
|
||||
assert ok is True
|
||||
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
# PAT 헤더 확인
|
||||
assert req.headers.get('Authorization') == 'token fake_token'
|
||||
# User-Agent 위장
|
||||
assert 'Mozilla' in req.headers.get('User-agent', '')
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_4xx_returns_false(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 401
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
assert _send_to_gitea('bad', 't', 'b') is False
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_network_error(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert _send_to_gitea('t', 't', 'b') is False
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_payload_json(self, mock_urlopen):
|
||||
import json as _json
|
||||
resp = MagicMock()
|
||||
resp.status = 201
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
_send_to_gitea('tok', 'TITLE', 'BODY')
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = _json.loads(req.data.decode('utf-8'))
|
||||
assert body['title'] == 'TITLE'
|
||||
assert body['body'] == 'BODY'
|
||||
127
tests/test_csv_importer.py
Normal file
127
tests/test_csv_importer.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""
|
||||
utils.csv_importer 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
||||
|
||||
|
||||
class TestNormalizeTime:
|
||||
def test_hh_mm_to_hh_mm_ss(self):
|
||||
assert _normalize_time('09:00', 'clock_in') == '09:00:00'
|
||||
|
||||
def test_hh_mm_ss_unchanged(self):
|
||||
assert _normalize_time('09:00:00', 'clock_in') == '09:00:00'
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('', 'clock_in')
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('foo', 'clock_in')
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('25:00', 'clock_in')
|
||||
|
||||
|
||||
class TestNormalizeRow:
|
||||
def test_basic_row(self):
|
||||
row = {
|
||||
'date': '2026-04-01',
|
||||
'clock_in': '09:00',
|
||||
'clock_out': '18:00',
|
||||
'lunch_minutes': '60',
|
||||
'memo': '메모',
|
||||
}
|
||||
out = _normalize_row(row)
|
||||
assert out['date'] == '2026-04-01'
|
||||
assert out['clock_in'] == '09:00:00'
|
||||
assert out['clock_out'] == '18:00:00'
|
||||
assert out['lunch_minutes'] == 60
|
||||
assert out['memo'] == '메모'
|
||||
|
||||
def test_optional_clock_out(self):
|
||||
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '0', 'memo': ''}
|
||||
out = _normalize_row(row)
|
||||
assert out['clock_out'] is None
|
||||
|
||||
def test_invalid_date(self):
|
||||
row = {'date': 'not-a-date', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '0', 'memo': ''}
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_row(row)
|
||||
|
||||
def test_negative_lunch_minutes(self):
|
||||
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '-30', 'memo': ''}
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_row(row)
|
||||
|
||||
|
||||
class TestParseCsv:
|
||||
def _write(self, content: str) -> str:
|
||||
f = tempfile.NamedTemporaryFile('w', encoding='utf-8',
|
||||
delete=False, suffix='.csv', newline='')
|
||||
f.write(content)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
def test_valid_csv(self):
|
||||
path = self._write(
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,첫째날\n"
|
||||
"2026-04-02,09:30:00,17:30:00,30,단축\n"
|
||||
)
|
||||
try:
|
||||
rows = parse_csv(path)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['lunch_minutes'] == 60
|
||||
assert rows[1]['memo'] == '단축'
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_utf8_bom(self):
|
||||
# 엑셀 저장본 호환
|
||||
path = self._write('\ufeff' +
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,첫째날\n"
|
||||
)
|
||||
try:
|
||||
rows = parse_csv(path)
|
||||
assert len(rows) == 1
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_missing_required_header(self):
|
||||
path = self._write("date,memo\n2026-04-01,foo\n")
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_csv(path)
|
||||
assert 'clock_in' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_file_not_found(self):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_csv('/nonexistent/file.csv')
|
||||
|
||||
def test_line_number_in_error(self):
|
||||
path = self._write(
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,ok\n"
|
||||
"bad-date,09:00,18:00,60,broken\n"
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_csv(path)
|
||||
assert '줄 3' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
129
tests/test_discord_webhook.py
Normal file
129
tests/test_discord_webhook.py
Normal file
@ -0,0 +1,129 @@
|
||||
"""
|
||||
utils.discord_webhook 단위 테스트.
|
||||
|
||||
네트워크 호출은 mock — 실제 Discord API는 절대 건드리지 않음.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.discord_webhook import (
|
||||
send, send_test, send_clock_in, send_clock_out, send_health_warning,
|
||||
USER_AGENT, COLOR_GREEN, COLOR_BLUE,
|
||||
)
|
||||
|
||||
|
||||
class TestSendInputValidation:
|
||||
def test_empty_url_returns_false(self):
|
||||
assert send('', 'title', 'desc') is False
|
||||
|
||||
def test_non_https_url_returns_false(self):
|
||||
# 보안상 http:// 거부
|
||||
assert send('http://example.com/webhook', 'title', 'desc') is False
|
||||
assert send('ftp://example.com', 'title', 'desc') is False
|
||||
|
||||
def test_none_url_returns_false(self):
|
||||
assert send(None, 'title', 'desc') is False
|
||||
|
||||
|
||||
class TestSendNetwork:
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 204
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = send('https://discord.com/api/webhooks/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
tests/test_i18n_runtime.py
Normal file
106
tests/test_i18n_runtime.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""
|
||||
ui.i18n_runtime 단위 테스트.
|
||||
|
||||
QApplication이 필요해서 offscreen으로.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def qapp():
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def i18n():
|
||||
from core import i18n
|
||||
from ui import i18n_runtime
|
||||
saved_lang = i18n.get_language()
|
||||
yield i18n_runtime
|
||||
i18n_runtime.clear()
|
||||
i18n.set_language(saved_lang)
|
||||
|
||||
|
||||
def test_register_applies_initial_text(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save')
|
||||
assert label.text() == '💾 저장'
|
||||
|
||||
|
||||
def test_retranslate_after_language_change(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.close')
|
||||
assert label.text() == '닫기'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == 'Close'
|
||||
|
||||
i18n.set_language_and_retranslate('ko')
|
||||
assert label.text() == '닫기'
|
||||
|
||||
|
||||
def test_setter_kwarg_for_window_title(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QDialog
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
dlg = QDialog()
|
||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||
assert dlg.windowTitle() == '⚙️ 설정'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert dlg.windowTitle() == '⚙️ Settings'
|
||||
|
||||
|
||||
def test_post_callback_applied(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||
assert label.text() == '[💾 저장]'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == '[💾 Save]'
|
||||
|
||||
|
||||
def test_dead_widget_pruned(qapp, i18n):
|
||||
"""삭제된 위젯은 retranslate에서 자동 제외 (RuntimeError 안 남)."""
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.cancel')
|
||||
label.deleteLater()
|
||||
label = None # weakref 끊기
|
||||
|
||||
# Qt 이벤트 처리 한 번 강제로 (deleteLater 처리)
|
||||
qapp.processEvents()
|
||||
|
||||
# 죽은 위젯이 있어도 예외 없이 실행돼야 함
|
||||
i18n.set_language_and_retranslate('en')
|
||||
i18n.set_language_and_retranslate('ko')
|
||||
|
||||
|
||||
def test_kwargs_format(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
# 'tray.tooltip_remaining': '퇴근까지: {time}'
|
||||
i18n.register(label, 'tray.tooltip_remaining', kwargs={'time': '01:23'})
|
||||
assert '01:23' in label.text()
|
||||
98
tests/test_salary.py
Normal file
98
tests/test_salary.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""
|
||||
core.salary 단위 테스트 — 포괄임금제 외 시급 추정.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.salary import estimate_pay, format_won
|
||||
|
||||
|
||||
class TestEstimatePay:
|
||||
def test_zero_wage_returns_zero(self):
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_negative_wage_returns_zero(self):
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 0}], -1000)
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_empty_records(self):
|
||||
out = estimate_pay([], 10000)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_basic_8h_no_overtime(self):
|
||||
# 8h 정규 × 10000 = 80000
|
||||
out = estimate_pay([{'total_hours': 8.0, 'overtime_minutes': 0}], 10000)
|
||||
assert out['base'] == 80000
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 80000
|
||||
|
||||
def test_8h_with_30min_overtime(self):
|
||||
# 정규 = 7.5h × 10000 = 75000
|
||||
# 연장 = 0.5h × 10000 × 1.5 = 7500
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 8.0, 'overtime_minutes': 30}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=1.5,
|
||||
)
|
||||
assert out['base'] == pytest.approx(75000)
|
||||
assert out['overtime'] == pytest.approx(7500)
|
||||
assert out['total'] == pytest.approx(82500)
|
||||
|
||||
def test_custom_overtime_rate(self):
|
||||
# 연장 = 1h × 10000 × 2.0 = 20000
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 9.0, 'overtime_minutes': 60}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=2.0,
|
||||
)
|
||||
assert out['overtime'] == pytest.approx(20000)
|
||||
assert out['base'] == pytest.approx(80000)
|
||||
|
||||
def test_aggregated_multiple_records(self):
|
||||
records = [
|
||||
{'total_hours': 8.0, 'overtime_minutes': 0},
|
||||
{'total_hours': 9.0, 'overtime_minutes': 60},
|
||||
{'total_hours': 8.5, 'overtime_minutes': 30},
|
||||
]
|
||||
out = estimate_pay(records, hourly_wage=10000)
|
||||
# base_hours = 8 + 8 + 8 = 24h
|
||||
# overtime_hours = 0 + 1 + 0.5 = 1.5h
|
||||
assert out['base_hours'] == pytest.approx(24.0)
|
||||
assert out['overtime_hours'] == pytest.approx(1.5)
|
||||
assert out['base'] == pytest.approx(240000)
|
||||
assert out['overtime'] == pytest.approx(22500) # 1.5 * 10000 * 1.5
|
||||
|
||||
def test_missing_keys_default_zero(self):
|
||||
out = estimate_pay([{}], 10000)
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_overtime_minutes_zero_when_negative_total(self):
|
||||
# total - overtime이 음수가 되면 base는 0으로 클램프
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 0.3, 'overtime_minutes': 60}], # 0.3h - 1h = -0.7
|
||||
hourly_wage=10000,
|
||||
)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == pytest.approx(15000)
|
||||
|
||||
|
||||
class TestFormatWon:
|
||||
@pytest.mark.parametrize("amount,expected", [
|
||||
(0, '0원'),
|
||||
(1000, '1,000원'),
|
||||
(1234567, '1,234,567원'),
|
||||
(999, '999원'),
|
||||
(82500.4, '82,500원'), # round
|
||||
(82500.6, '82,501원'),
|
||||
])
|
||||
def test_format(self, amount, expected):
|
||||
assert format_won(amount) == expected
|
||||
@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("외출 기록 수정")
|
||||
self.setWindowTitle(tr('dlg.break.edit_title'))
|
||||
self.setFixedSize(380, 180)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 외출 시간
|
||||
out_layout = QHBoxLayout()
|
||||
out_label = QLabel("외출 시간:")
|
||||
out_label = QLabel(tr('dlg.break.out_label'))
|
||||
out_label.setFixedWidth(80)
|
||||
self.out_time_edit = QTimeEdit()
|
||||
self.out_time_edit.setDisplayFormat("HH:mm:ss")
|
||||
@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 복귀 시간
|
||||
in_layout = QHBoxLayout()
|
||||
in_label = QLabel("복귀 시간:")
|
||||
in_label = QLabel(tr('dlg.break.in_label'))
|
||||
in_label.setFixedWidth(80)
|
||||
self.in_time_edit = QTimeEdit()
|
||||
self.in_time_edit.setDisplayFormat("HH:mm:ss")
|
||||
@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
reason_layout = QHBoxLayout()
|
||||
reason_label = QLabel("사유:")
|
||||
reason_label = QLabel(tr('dlg.break.reason_label'))
|
||||
reason_label.setFixedWidth(80)
|
||||
self.reason_edit = QLineEdit()
|
||||
reason_layout.addWidget(reason_label)
|
||||
@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
cancel_button = QPushButton("취소")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
|
||||
save_button.clicked.connect(self.accept)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
@ -128,7 +128,7 @@ class BreakView(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("오늘의 외출 기록")
|
||||
title = QLabel(tr('view.break.today_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
@ -136,7 +136,13 @@ class BreakView(QDialog):
|
||||
# 외출 리스트 테이블
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""])
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr('view.break.col_out'),
|
||||
tr('view.break.col_in'),
|
||||
tr('view.break.col_duration'),
|
||||
tr('view.break.col_reason'),
|
||||
"",
|
||||
])
|
||||
|
||||
# 테이블 설정
|
||||
header = self.table.horizontalHeader()
|
||||
@ -152,7 +158,7 @@ class BreakView(QDialog):
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# 총 외출 시간 표시
|
||||
self.total_label = QLabel("총 외출 시간: 0분")
|
||||
self.total_label = QLabel(tr('view.break.total_zero'))
|
||||
self.total_label.setObjectName("section_title")
|
||||
self.total_label.setAlignment(Qt.AlignRight)
|
||||
layout.addWidget(self.total_label)
|
||||
@ -160,8 +166,8 @@ class BreakView(QDialog):
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.refresh_button = QPushButton("새로고침")
|
||||
close_button = QPushButton("닫기")
|
||||
self.refresh_button = QPushButton(tr('btn.refresh'))
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
|
||||
self.refresh_button.clicked.connect(self.load_break_records)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@ -190,7 +196,7 @@ class BreakView(QDialog):
|
||||
if break_in:
|
||||
self.table.setItem(i, 1, QTableWidgetItem(break_in))
|
||||
else:
|
||||
item = QTableWidgetItem("진행중")
|
||||
item = QTableWidgetItem(tr('view.break.in_progress'))
|
||||
item.setForeground(Qt.red)
|
||||
self.table.setItem(i, 1, item)
|
||||
|
||||
@ -199,7 +205,10 @@ class BreakView(QDialog):
|
||||
if total_minutes:
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분"
|
||||
if hours > 0:
|
||||
duration_str = tr('view.break.duration_fmt', h=hours, m=minutes)
|
||||
else:
|
||||
duration_str = tr('view.break.duration_min_only', m=minutes)
|
||||
self.table.setItem(i, 2, QTableWidgetItem(duration_str))
|
||||
else:
|
||||
self.table.setItem(i, 2, QTableWidgetItem("-"))
|
||||
@ -214,8 +223,8 @@ class BreakView(QDialog):
|
||||
action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
action_layout.setSpacing(5)
|
||||
|
||||
edit_button = QPushButton("수정")
|
||||
delete_button = QPushButton("삭제")
|
||||
edit_button = QPushButton(tr('btn.edit_short'))
|
||||
delete_button = QPushButton(tr('btn.delete_short'))
|
||||
|
||||
edit_button.setFixedSize(50, 25)
|
||||
delete_button.setFixedSize(50, 25)
|
||||
@ -237,9 +246,9 @@ class BreakView(QDialog):
|
||||
minutes = total_minutes % 60
|
||||
|
||||
if hours > 0:
|
||||
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분")
|
||||
self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
|
||||
else:
|
||||
self.total_label.setText(f"총 외출 시간: {minutes}분")
|
||||
self.total_label.setText(tr('view.break.total_min_only', m=minutes))
|
||||
|
||||
def edit_record(self, record_id):
|
||||
"""외출 기록 수정"""
|
||||
@ -277,8 +286,8 @@ class BreakView(QDialog):
|
||||
"""외출 기록 삭제"""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"삭제 확인",
|
||||
"이 외출 기록을 삭제하시겠습니까?",
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.break.delete_confirm'),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
|
||||
@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 안내 문구
|
||||
info_label = QLabel("오늘의 출근시간을 입력해주세요")
|
||||
info_label = QLabel(tr('dlg.clock_in.prompt'))
|
||||
info_label.setObjectName("field_label")
|
||||
info_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# 시간 입력
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel("출근시간:")
|
||||
time_label = QLabel(tr('dlg.clock_in.label'))
|
||||
time_label.setObjectName("field_label")
|
||||
|
||||
self.time_edit = QTimeEdit()
|
||||
@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
|
||||
|
||||
# 빠른 선택 버튼
|
||||
quick_layout = QHBoxLayout()
|
||||
quick_label = QLabel("빠른 선택:")
|
||||
quick_label = QLabel(tr('dlg.clock_in.quick'))
|
||||
quick_label.setObjectName("field_label")
|
||||
|
||||
btn_8am = QPushButton("08:00")
|
||||
btn_9am = QPushButton("09:00")
|
||||
btn_10am = QPushButton("10:00")
|
||||
btn_now = QPushButton("현재")
|
||||
btn_now = QPushButton(tr('dlg.clock_in.btn_now'))
|
||||
|
||||
for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
|
||||
btn.setMinimumHeight(30)
|
||||
@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
ok_button = QPushButton("확인")
|
||||
ok_button = QPushButton(tr('btn.confirm'))
|
||||
ok_button.setObjectName("btn_primary")
|
||||
ok_button.setMinimumHeight(40)
|
||||
ok_button.clicked.connect(self.accept)
|
||||
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.setMinimumHeight(40)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
|
||||
56
ui/controllers/meal_controller.py
Normal file
56
ui/controllers/meal_controller.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""
|
||||
점심/저녁 토글 컨트롤러.
|
||||
|
||||
main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status /
|
||||
update_dinner_status 가 합쳐져 있던 것을 분리. 1Hz hot path 외 사용자 액션 응답.
|
||||
|
||||
단위 테스트가 가능하도록 window 의존성을 명시적으로 받음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class MealController:
|
||||
"""점심/저녁 토글 + 상태 라벨 갱신."""
|
||||
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
|
||||
# -------- 토글 --------
|
||||
def toggle_lunch(self) -> None:
|
||||
w = self.window
|
||||
w.lunch_break_enabled = w.lunch_button.isChecked()
|
||||
self.refresh_lunch_label()
|
||||
|
||||
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
|
||||
if w.lunch_break_enabled:
|
||||
w.auto_lunch_applied_today = True
|
||||
|
||||
if w.is_clocked_in:
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.update_lunch_break(today, w.lunch_break_enabled)
|
||||
|
||||
def toggle_dinner(self) -> None:
|
||||
w = self.window
|
||||
w.dinner_break_enabled = w.dinner_button.isChecked()
|
||||
self.refresh_dinner_label()
|
||||
|
||||
if w.is_clocked_in:
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.update_dinner_break(today, w.dinner_break_enabled)
|
||||
|
||||
# -------- 라벨 --------
|
||||
def refresh_lunch_label(self) -> None:
|
||||
w = self.window
|
||||
w.lunch_button.setText(
|
||||
tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add')
|
||||
)
|
||||
|
||||
def refresh_dinner_label(self) -> None:
|
||||
w = self.window
|
||||
w.dinner_button.setText(
|
||||
tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add')
|
||||
)
|
||||
94
ui/i18n_runtime.py
Normal file
94
ui/i18n_runtime.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""
|
||||
런타임 i18n 재번역 — 재시작 없이 언어 전환.
|
||||
|
||||
사용법:
|
||||
from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate
|
||||
|
||||
label = QLabel(tr('label.foo'))
|
||||
register(label, 'label.foo') # 약한 참조로 등록
|
||||
|
||||
button = QPushButton()
|
||||
register(button, 'btn.bar', kwargs={'name': 'X'})
|
||||
|
||||
# 그룹박스 제목 등 setter가 다른 경우
|
||||
register(group, 'group.work_time', setter='setTitle')
|
||||
|
||||
# 윈도우 제목
|
||||
register(dialog, 'window.foo', setter='setWindowTitle')
|
||||
|
||||
언어 변경:
|
||||
set_language_and_retranslate('en')
|
||||
|
||||
각 widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는
|
||||
키는 kwargs를 함께 등록하면 retranslate 시 같은 인자로 재계산.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import weakref
|
||||
from typing import Any, Callable, List, Tuple, Optional
|
||||
|
||||
from core.i18n import tr, set_language as _set_language
|
||||
|
||||
# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트
|
||||
# weakref가 죽으면 다음 retranslate 시 정리.
|
||||
_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = []
|
||||
|
||||
|
||||
def register(widget: Any, key: str, *, setter: str = 'setText',
|
||||
kwargs: Optional[dict] = None,
|
||||
post: Optional[Callable[[str], str]] = None) -> None:
|
||||
"""위젯을 retranslate 대상으로 등록.
|
||||
|
||||
Args:
|
||||
widget: PyQt 위젯 (setText/setTitle/setWindowTitle 등 지원)
|
||||
key: i18n 키
|
||||
setter: 호출할 메서드명 (기본 setText)
|
||||
kwargs: tr()에 전달할 format 인자 (정적인 경우만)
|
||||
post: 번역 후 한 번 더 가공할 콜백 — 예: 이모지 prefix
|
||||
"""
|
||||
# 약한 참조 — 위젯 삭제 시 자동 GC
|
||||
try:
|
||||
ref = weakref.ref(widget)
|
||||
except TypeError:
|
||||
# weakref 미지원 객체는 retranslate 불가
|
||||
return
|
||||
_registry.append((ref, key, setter, kwargs or {}, post))
|
||||
# 초기 적용
|
||||
_apply(widget, key, setter, kwargs or {}, post)
|
||||
|
||||
|
||||
def retranslate_all() -> None:
|
||||
"""모든 등록된 위젯에 현재 언어로 텍스트 재적용."""
|
||||
global _registry
|
||||
alive = []
|
||||
for ref, key, setter, kw, post in _registry:
|
||||
widget = ref()
|
||||
if widget is None:
|
||||
continue # 죽은 위젯은 빼버림
|
||||
try:
|
||||
_apply(widget, key, setter, kw, post)
|
||||
alive.append((ref, key, setter, kw, post))
|
||||
except RuntimeError:
|
||||
# Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스
|
||||
continue
|
||||
_registry = alive
|
||||
|
||||
|
||||
def set_language_and_retranslate(lang: str) -> None:
|
||||
"""언어 전환 + 즉시 재번역."""
|
||||
_set_language(lang)
|
||||
retranslate_all()
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""레지스트리 비우기 (테스트용)."""
|
||||
_registry.clear()
|
||||
|
||||
|
||||
def _apply(widget: Any, key: str, setter: str, kw: dict,
|
||||
post: Optional[Callable[[str], str]]) -> None:
|
||||
text = tr(key, **kw)
|
||||
if post:
|
||||
text = post(text)
|
||||
fn = getattr(widget, setter, None)
|
||||
if callable(fn):
|
||||
fn(text)
|
||||
@ -40,27 +40,32 @@ class LeaveView(QDialog):
|
||||
|
||||
# 제목 + 잔액 + 설정 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("연차 관리")
|
||||
title = QLabel(tr('view.leave.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
self.balance_label = QLabel("잔여: 0일")
|
||||
self.balance_label = QLabel(tr('view.leave.balance_zero'))
|
||||
self.balance_label.setObjectName("badge_leave")
|
||||
header_layout.addWidget(self.balance_label)
|
||||
set_balance_button = QPushButton("잔여 설정")
|
||||
set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
|
||||
set_balance_button.clicked.connect(self.set_balance)
|
||||
header_layout.addWidget(set_balance_button)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 사용 내역
|
||||
used_group = QGroupBox("📤 사용 내역")
|
||||
used_group = QGroupBox(tr('view.leave.used_group'))
|
||||
used_layout = QVBoxLayout()
|
||||
used_layout.setSpacing(4)
|
||||
used_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.used_table = QTableWidget()
|
||||
self.used_table.setColumnCount(4)
|
||||
self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"])
|
||||
self.used_table.setHorizontalHeaderLabels([
|
||||
tr('view.leave.col_date'),
|
||||
tr('view.leave.col_type'),
|
||||
tr('view.leave.col_used'),
|
||||
tr('view.leave.col_reason'),
|
||||
])
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
@ -77,15 +82,15 @@ class LeaveView(QDialog):
|
||||
|
||||
# 버튼들
|
||||
button_layout = QHBoxLayout()
|
||||
add_leave_button = QPushButton("➕ 연차 사용 추가")
|
||||
add_leave_button = QPushButton(tr('view.leave.btn_add'))
|
||||
add_leave_button.clicked.connect(self.add_leave_record)
|
||||
button_layout.addWidget(add_leave_button)
|
||||
|
||||
cal_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("닫기")
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
layout.addLayout(button_layout)
|
||||
@ -102,7 +107,8 @@ class LeaveView(QDialog):
|
||||
# 잔액 업데이트
|
||||
balance = self.db.get_leave_balance()
|
||||
hours = balance * 8
|
||||
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
|
||||
self.balance_label.setText(tr('view.leave.balance_fmt',
|
||||
days=balance, hours=hours))
|
||||
|
||||
# 사용 내역 로드 (잔액 조정 제외)
|
||||
records = self.db.get_leave_records(exclude_bulk=True)
|
||||
@ -144,7 +150,7 @@ class LeaveView(QDialog):
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction("삭제", self)
|
||||
delete_action = QAction(tr('btn.delete_short'), self)
|
||||
delete_action.triggered.connect(self.delete_leave_record)
|
||||
menu.addAction(delete_action)
|
||||
menu.exec_(self.used_table.viewport().mapToGlobal(position))
|
||||
@ -164,11 +170,10 @@ class LeaveView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"삭제 확인",
|
||||
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
|
||||
f"날짜: {date_item.text()}\n"
|
||||
f"구분: {type_item.text()}\n"
|
||||
f"사용: {days_item.text()}",
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.leave.delete_confirm_body',
|
||||
date=date_item.text(), type=type_item.text(),
|
||||
days=days_item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -183,13 +188,12 @@ class LeaveView(QDialog):
|
||||
|
||||
hours, ok = QInputDialog.getDouble(
|
||||
self,
|
||||
"연차 시간 설정",
|
||||
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
|
||||
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
|
||||
tr('view.leave.set_title'),
|
||||
tr('view.leave.set_prompt'),
|
||||
current_hours,
|
||||
0.0,
|
||||
999.0,
|
||||
1 # 소수점 첫째자리까지 (0.5 단위)
|
||||
1
|
||||
)
|
||||
|
||||
if ok:
|
||||
@ -200,8 +204,8 @@ class LeaveView(QDialog):
|
||||
self.db.set_leave_balance(days)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"설정 완료",
|
||||
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
|
||||
tr('view.leave.set_done_title'),
|
||||
tr('view.leave.set_done_body', days=days, hours=hours)
|
||||
)
|
||||
self.load_data()
|
||||
|
||||
@ -223,7 +227,7 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("연차 사용 기록 추가")
|
||||
self.setWindowTitle(tr('view.leave.add_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -232,14 +236,14 @@ class AddLeaveDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("연차 사용 기록 추가")
|
||||
title = QLabel(tr('view.leave.add_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 날짜 + 구분 한 줄
|
||||
row1 = QHBoxLayout()
|
||||
date_label = QLabel("날짜:")
|
||||
date_label = QLabel(tr('view.leave.field_date'))
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(40)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -248,14 +252,14 @@ class AddLeaveDialog(QDialog):
|
||||
row1.addWidget(date_label)
|
||||
row1.addWidget(self.date_edit)
|
||||
row1.addSpacing(8)
|
||||
type_label = QLabel("구분:")
|
||||
type_label = QLabel(tr('view.leave.field_type'))
|
||||
type_label.setObjectName("field_label")
|
||||
type_label.setFixedWidth(40)
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.addItem("연차", "annual")
|
||||
self.type_combo.addItem("반차", "half")
|
||||
self.type_combo.addItem("반반차", "quarter")
|
||||
self.type_combo.addItem("시간", "hourly")
|
||||
self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
|
||||
self.type_combo.addItem(tr('view.leave.type_half'), "half")
|
||||
self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
|
||||
self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
|
||||
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
|
||||
row1.addWidget(type_label)
|
||||
row1.addWidget(self.type_combo)
|
||||
@ -263,14 +267,14 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
# 사용 시간 (시간 연차용)
|
||||
hours_layout = QHBoxLayout()
|
||||
hours_label = QLabel("시간:")
|
||||
hours_label = QLabel(tr('view.leave.field_hours'))
|
||||
hours_label.setObjectName("field_label")
|
||||
hours_label.setFixedWidth(40)
|
||||
self.hours_spin = QDoubleSpinBox()
|
||||
self.hours_spin.setRange(0.5, 8.0)
|
||||
self.hours_spin.setSingleStep(0.5)
|
||||
self.hours_spin.setValue(1.0)
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.hours_spin.setSuffix(' ' + tr('label.unit_hour'))
|
||||
self.hours_spin.setEnabled(False)
|
||||
hours_layout.addWidget(hours_label)
|
||||
hours_layout.addWidget(self.hours_spin)
|
||||
@ -278,27 +282,27 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
memo_layout = QHBoxLayout()
|
||||
memo_label = QLabel("사유:")
|
||||
memo_label = QLabel(tr('view.leave.field_reason'))
|
||||
memo_label.setObjectName("field_label")
|
||||
memo_label.setFixedWidth(40)
|
||||
self.memo_input = QLineEdit()
|
||||
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
|
||||
self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
|
||||
memo_layout.addWidget(memo_label)
|
||||
memo_layout.addWidget(self.memo_input)
|
||||
layout.addLayout(memo_layout)
|
||||
|
||||
# 안내
|
||||
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
|
||||
info_label = QLabel(tr('view.leave.note_auto_deduct'))
|
||||
info_label.setObjectName("note_text")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save_record)
|
||||
button_layout.addWidget(save_button)
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
@ -336,8 +340,8 @@ class AddLeaveDialog(QDialog):
|
||||
if current_balance < days:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"잔여 연차 부족",
|
||||
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
|
||||
tr('view.leave.short_title'),
|
||||
tr('view.leave.short_body', balance=current_balance, req=days)
|
||||
)
|
||||
return
|
||||
|
||||
@ -345,12 +349,10 @@ class AddLeaveDialog(QDialog):
|
||||
hours = days * 8
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"연차 사용 기록 추가",
|
||||
f"날짜: {date}\n"
|
||||
f"구분: {leave_type_name}\n"
|
||||
f"사용: {days}일 ({hours}시간)\n"
|
||||
f"사유: {memo if memo else '(없음)'}\n\n"
|
||||
f"이 기록을 추가하시겠습니까?",
|
||||
tr('view.leave.confirm_title'),
|
||||
tr('view.leave.confirm_body',
|
||||
date=date, type=leave_type_name, days=days, hours=hours,
|
||||
reason=(memo if memo else '-')),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -360,15 +362,15 @@ class AddLeaveDialog(QDialog):
|
||||
self.db.use_leave(days, date, leave_type_name, memo)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"추가 완료",
|
||||
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
|
||||
tr('view.leave.added_title'),
|
||||
tr('view.leave.added_body', days=days, hours=hours)
|
||||
)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"오류",
|
||||
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
|
||||
tr('view.leave.error_title'),
|
||||
tr('view.leave.error_body', err=str(e))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -81,13 +81,15 @@ class MainWindow(QMainWindow):
|
||||
self.notifier = Notifier(self, db=self.db)
|
||||
self.notifier.notification_signal.connect(self.show_notification)
|
||||
|
||||
# 책임 분리된 컨트롤러들 (1Hz hot path)
|
||||
# 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
|
||||
from ui.controllers.lock_monitor import LockMonitor
|
||||
from ui.controllers.auto_lunch import AutoLunchManager
|
||||
from ui.controllers.notification_orchestrator import NotificationOrchestrator
|
||||
from ui.controllers.meal_controller import MealController
|
||||
self._lock_monitor = LockMonitor(self)
|
||||
self._auto_lunch = AutoLunchManager(self)
|
||||
self._notif_orch = NotificationOrchestrator(self)
|
||||
self._meal = MealController(self)
|
||||
|
||||
# 시스템 트레이
|
||||
self.tray_icon = SystemTrayIcon(self)
|
||||
@ -184,7 +186,11 @@ class MainWindow(QMainWindow):
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
from core.version import __version__
|
||||
from ui.i18n_runtime import register
|
||||
self._app_version = __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.setMinimumSize(480, 520)
|
||||
|
||||
@ -326,6 +332,14 @@ class MainWindow(QMainWindow):
|
||||
help_button = QPushButton(tr('menu.help'))
|
||||
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]:
|
||||
bottom_layout.addWidget(btn)
|
||||
|
||||
@ -750,40 +764,20 @@ class MainWindow(QMainWindow):
|
||||
self.date_label.setText(date_str)
|
||||
|
||||
def toggle_lunch_break(self):
|
||||
"""점심시간 토글"""
|
||||
self.lunch_break_enabled = self.lunch_button.isChecked()
|
||||
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)
|
||||
"""점심시간 토글 — MealController 위임."""
|
||||
self._meal.toggle_lunch()
|
||||
|
||||
def toggle_dinner_break(self):
|
||||
"""저녁시간 토글"""
|
||||
self.dinner_break_enabled = self.dinner_button.isChecked()
|
||||
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)
|
||||
"""저녁시간 토글 — MealController 위임."""
|
||||
self._meal.toggle_dinner()
|
||||
|
||||
def update_lunch_status(self):
|
||||
"""점심시간 상태 업데이트"""
|
||||
self.lunch_button.setText(
|
||||
tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add')
|
||||
)
|
||||
"""점심시간 상태 업데이트 — MealController 위임."""
|
||||
self._meal.refresh_lunch_label()
|
||||
|
||||
def update_dinner_status(self):
|
||||
"""저녁시간 상태 업데이트"""
|
||||
self.dinner_button.setText(
|
||||
tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add')
|
||||
)
|
||||
"""저녁시간 상태 업데이트 — MealController 위임."""
|
||||
self._meal.refresh_dinner_label()
|
||||
|
||||
def update_overtime_balance(self):
|
||||
"""연장근무 잔액 업데이트"""
|
||||
|
||||
@ -38,24 +38,28 @@ class OvertimeView(QDialog):
|
||||
|
||||
# 제목 + 잔액 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("연장근무 내역")
|
||||
title = QLabel(tr('view.overtime.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
self.balance_label = QLabel("잔액: 0분")
|
||||
self.balance_label = QLabel(tr('view.overtime.balance_zero'))
|
||||
self.balance_label.setObjectName("badge_balance")
|
||||
header_layout.addWidget(self.balance_label)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 적립 내역
|
||||
earned_group = QGroupBox("💰 적립 내역")
|
||||
earned_group = QGroupBox(tr('view.overtime.earned_group'))
|
||||
earned_layout = QVBoxLayout()
|
||||
earned_layout.setSpacing(4)
|
||||
earned_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.earned_table = QTableWidget()
|
||||
self.earned_table.setColumnCount(3)
|
||||
self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"])
|
||||
self.earned_table.setHorizontalHeaderLabels([
|
||||
tr('view.overtime.col_date'),
|
||||
tr('view.overtime.col_earned'),
|
||||
tr('view.overtime.col_memo'),
|
||||
])
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
@ -64,7 +68,7 @@ class OvertimeView(QDialog):
|
||||
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
earned_layout.addWidget(self.earned_table)
|
||||
|
||||
add_earned_button = QPushButton("➕ 수동 적립")
|
||||
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
||||
add_earned_button.clicked.connect(self.add_earned_record)
|
||||
earned_layout.addWidget(add_earned_button)
|
||||
|
||||
@ -72,14 +76,18 @@ class OvertimeView(QDialog):
|
||||
layout.addWidget(earned_group)
|
||||
|
||||
# 사용 내역
|
||||
used_group = QGroupBox("📤 사용 내역")
|
||||
used_group = QGroupBox(tr('view.overtime.used_group'))
|
||||
used_layout = QVBoxLayout()
|
||||
used_layout.setSpacing(4)
|
||||
used_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.used_table = QTableWidget()
|
||||
self.used_table.setColumnCount(3)
|
||||
self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"])
|
||||
self.used_table.setHorizontalHeaderLabels([
|
||||
tr('view.overtime.col_date'),
|
||||
tr('view.overtime.col_used'),
|
||||
tr('view.overtime.col_reason'),
|
||||
])
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
@ -90,7 +98,7 @@ class OvertimeView(QDialog):
|
||||
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
|
||||
used_layout.addWidget(self.used_table)
|
||||
|
||||
add_used_button = QPushButton("➕ 수동 사용")
|
||||
add_used_button = QPushButton(tr('view.overtime.btn_add_used'))
|
||||
add_used_button.clicked.connect(self.add_used_record)
|
||||
used_layout.addWidget(add_used_button)
|
||||
|
||||
@ -98,7 +106,7 @@ class OvertimeView(QDialog):
|
||||
layout.addWidget(used_group)
|
||||
|
||||
# 닫기 버튼
|
||||
close_button = QPushButton("닫기")
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.clicked.connect(self.close)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
@ -110,23 +118,21 @@ class OvertimeView(QDialog):
|
||||
balance = self.db.get_total_overtime_balance()
|
||||
hours = balance // 60
|
||||
minutes = balance % 60
|
||||
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
|
||||
self.balance_label.setText(tr('view.overtime.balance_fmt',
|
||||
h=hours, m=minutes, total=balance))
|
||||
|
||||
# 적립 내역 로드
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT ob.date, ob.earned_minutes,
|
||||
CASE
|
||||
WHEN ob.work_record_id IS NULL THEN '수동 추가'
|
||||
ELSE COALESCE(wr.memo, '')
|
||||
END as memo
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
|
||||
FROM overtime_bank ob
|
||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||
ORDER BY ob.date DESC
|
||||
''')
|
||||
earned_records = cursor.fetchall()
|
||||
manual_label = tr('msg.manual_added')
|
||||
|
||||
self.earned_table.setRowCount(len(earned_records))
|
||||
for i, record in enumerate(earned_records):
|
||||
@ -136,12 +142,17 @@ class OvertimeView(QDialog):
|
||||
minutes = record[1]
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
|
||||
if hours > 0:
|
||||
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
|
||||
else:
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(39, 174, 96)) # 초록색
|
||||
|
||||
memo_item = QTableWidgetItem(record[2] or "")
|
||||
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
||||
memo_item = QTableWidgetItem(memo_text)
|
||||
|
||||
self.earned_table.setItem(i, 0, date_item)
|
||||
self.earned_table.setItem(i, 1, time_item)
|
||||
@ -166,7 +177,10 @@ class OvertimeView(QDialog):
|
||||
minutes = record[2]
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
|
||||
if hours > 0:
|
||||
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
|
||||
else:
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
@ -188,7 +202,7 @@ class OvertimeView(QDialog):
|
||||
|
||||
# 컨텍스트 메뉴 생성
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction("❌ 삭제", self)
|
||||
delete_action = QAction(tr('view.overtime.menu_delete'), self)
|
||||
delete_action.triggered.connect(self.delete_used_record)
|
||||
menu.addAction(delete_action)
|
||||
|
||||
@ -213,11 +227,10 @@ class OvertimeView(QDialog):
|
||||
# 확인 메시지
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"삭제 확인",
|
||||
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
|
||||
f"날짜: {date_item.text()}\n"
|
||||
f"시간: {time_item.text()}\n"
|
||||
f"사유: {reason_item.text()}",
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.overtime.delete_confirm_body',
|
||||
date=date_item.text(), time=time_item.text(),
|
||||
reason=reason_item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("추가근무 수동 적립")
|
||||
self.setWindowTitle(tr('view.overtime.manual_earned_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("추가근무 수동 적립")
|
||||
title = QLabel(tr('view.overtime.manual_earned_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 날짜
|
||||
date_layout = QHBoxLayout()
|
||||
date_label = QLabel("날짜:")
|
||||
date_label = QLabel(tr('view.overtime.field_date'))
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(60)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
# 시간 (30분 단위)
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel("시간:")
|
||||
time_label = QLabel(tr('view.overtime.field_time'))
|
||||
time_label.setObjectName("field_label")
|
||||
time_label.setFixedWidth(60)
|
||||
self.hour_spin = QSpinBox()
|
||||
self.hour_spin.setRange(0, 23)
|
||||
self.hour_spin.setSuffix("시간")
|
||||
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
|
||||
self.minute_combo = QComboBox()
|
||||
self.minute_combo.addItems(["0분", "30분"])
|
||||
self.minute_combo.addItems([tr('view.overtime.minute_0'),
|
||||
tr('view.overtime.minute_30')])
|
||||
time_layout.addWidget(time_label)
|
||||
time_layout.addWidget(self.hour_spin)
|
||||
time_layout.addWidget(self.minute_combo)
|
||||
@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
# 메모
|
||||
memo_layout = QHBoxLayout()
|
||||
memo_label = QLabel("메모:")
|
||||
memo_label = QLabel(tr('view.overtime.field_memo'))
|
||||
memo_label.setObjectName("field_label")
|
||||
memo_label.setFixedWidth(60)
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("선택사항")
|
||||
self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo'))
|
||||
memo_layout.addWidget(memo_label)
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
layout.addLayout(memo_layout)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save)
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(save_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
"""저장"""
|
||||
# 시간 계산 (30분 단위)
|
||||
hours = self.hour_spin.value()
|
||||
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
|
||||
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
|
||||
total_minutes = hours * 60 + minutes
|
||||
|
||||
if total_minutes == 0:
|
||||
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
|
||||
QMessageBox.warning(self, tr('msg.input_error.title'),
|
||||
tr('view.overtime.zero_add_error'))
|
||||
return
|
||||
|
||||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||||
@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"저장 완료",
|
||||
f"{hours}시간 {minutes}분이 적립되었습니다."
|
||||
tr('msg.save_success.title'),
|
||||
tr('view.overtime.saved_earned', h=hours, m=minutes)
|
||||
)
|
||||
self.accept()
|
||||
|
||||
@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle("추가근무 수동 사용")
|
||||
self.setWindowTitle(tr('view.overtime.manual_used_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 제목 + 잔액 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("추가근무 수동 사용")
|
||||
title = QLabel(tr('view.overtime.manual_used_title'))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
balance = self.db.get_total_overtime_balance()
|
||||
hours = balance // 60
|
||||
minutes = balance % 60
|
||||
balance_label = QLabel(f"잔액: {hours}시간 {minutes}분")
|
||||
if hours > 0:
|
||||
balance_text = tr('view.break.duration_fmt', h=hours, m=minutes)
|
||||
else:
|
||||
balance_text = tr('view.break.duration_min_only', m=minutes)
|
||||
balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}")
|
||||
balance_label.setObjectName("badge_balance")
|
||||
header_layout.addWidget(balance_label)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 날짜
|
||||
date_layout = QHBoxLayout()
|
||||
date_label = QLabel("날짜:")
|
||||
date_label = QLabel(tr('view.overtime.field_date'))
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(60)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 시간 (30분 단위)
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel("시간:")
|
||||
time_label = QLabel(tr('view.overtime.field_time'))
|
||||
time_label.setObjectName("field_label")
|
||||
time_label.setFixedWidth(60)
|
||||
self.hour_spin = QSpinBox()
|
||||
self.hour_spin.setRange(0, 23)
|
||||
self.hour_spin.setSuffix("시간")
|
||||
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
|
||||
self.minute_combo = QComboBox()
|
||||
self.minute_combo.addItems(["0분", "30분"])
|
||||
self.minute_combo.addItems([tr('view.overtime.minute_0'),
|
||||
tr('view.overtime.minute_30')])
|
||||
time_layout.addWidget(time_label)
|
||||
time_layout.addWidget(self.hour_spin)
|
||||
time_layout.addWidget(self.minute_combo)
|
||||
@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
reason_layout = QHBoxLayout()
|
||||
reason_label = QLabel("사유:")
|
||||
reason_label = QLabel(tr('view.overtime.field_reason'))
|
||||
reason_label.setObjectName("field_label")
|
||||
reason_label.setFixedWidth(60)
|
||||
self.reason_edit = QLineEdit()
|
||||
self.reason_edit.setPlaceholderText("예: 개인 사유")
|
||||
self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason'))
|
||||
reason_layout.addWidget(reason_label)
|
||||
reason_layout.addWidget(self.reason_edit)
|
||||
layout.addLayout(reason_layout)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save)
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(save_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
"""저장"""
|
||||
# 시간 계산 (30분 단위)
|
||||
hours = self.hour_spin.value()
|
||||
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
|
||||
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
|
||||
total_minutes = hours * 60 + minutes
|
||||
|
||||
if total_minutes == 0:
|
||||
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
|
||||
QMessageBox.warning(self, tr('msg.input_error.title'),
|
||||
tr('view.overtime.zero_use_error'))
|
||||
return
|
||||
|
||||
# 잔액 확인
|
||||
@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
if total_minutes > balance:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"잔액 부족",
|
||||
f"사용 가능한 시간이 부족합니다.\n\n"
|
||||
f"요청: {hours}시간 {minutes}분\n"
|
||||
f"잔액: {balance // 60}시간 {balance % 60}분"
|
||||
tr('view.overtime.balance_short_title'),
|
||||
tr('view.overtime.balance_short_body',
|
||||
req_h=hours, req_m=minutes,
|
||||
bal_h=balance // 60, bal_m=balance % 60)
|
||||
)
|
||||
return
|
||||
|
||||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||||
reason = self.reason_edit.text().strip() or "수동 사용"
|
||||
reason = self.reason_edit.text().strip() or tr('msg.manual_added')
|
||||
|
||||
# DB에 저장
|
||||
self.db.add_overtime_usage(None, total_minutes, date, reason)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"저장 완료",
|
||||
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
|
||||
tr('msg.save_success.title'),
|
||||
tr('view.overtime.saved_used', h=hours, m=minutes)
|
||||
)
|
||||
self.accept()
|
||||
|
||||
|
||||
@ -1114,16 +1114,18 @@ class SettingsView(QDialog):
|
||||
if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
|
||||
self.parent_window.reload_settings()
|
||||
|
||||
# 언어 변경 감지 → 재시작 제안
|
||||
# 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장
|
||||
if hasattr(self, 'language_combo'):
|
||||
from core.i18n import get_language
|
||||
from ui.i18n_runtime import set_language_and_retranslate
|
||||
new_lang = self.language_combo.currentData()
|
||||
if new_lang and new_lang != get_language():
|
||||
set_language_and_retranslate(new_lang)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"재시작 필요 / Restart required",
|
||||
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n"
|
||||
"Restart now to fully apply the language change?",
|
||||
"재시작 / Restart",
|
||||
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
|
||||
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user