Compare commits

...

10 Commits
v2.9.0 ... main

Author SHA1 Message Date
KINDNICK
d8c6a9d784 docs(AGENTS): note UTF-8 handling for version.py in release 2026-06-16 10:54:22 +09:00
KINDNICK
71161b2707 fix(release): preserve UTF-8 when rewriting core/version.py 2026-06-16 10:53:52 +09:00
KINDNICK
63b0e324b9 i18n: complete UI/achievement keying, fix help_view tr() and i18n syntax errors 2026-06-16 10:52:57 +09:00
KINDNICK
f5751460e3 v2.11.2: 통계 차트 frozen 실제 수정(numpy _multiarray_tests) + 도전과제 라이트 가독성
- fix: frozen main.exe에서 numpy.core._multiarray_tests 누락으로 matplotlib import 실패 → 차트 폴백. spec에 hiddenimport 추가 + numpy.testing 제외 제거 (디버그 로그로 원인 확인)
- fix: 도전과제 라이트 테마 헤더 숫자/배지/진행 텍스트/바 대비 개선

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:22:00 +09:00
KINDNICK
e7e85dcf7b v2.11.1: 통계 차트(frozen) 수정 + 통계/도움말/도전과제 테마 대응
- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅)
- fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종
- dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:08:24 +09:00
KINDNICK
130c61ea62 test: disable holiday auto-sync in _integration_test (fixes S2/S31 WinError 32 temp-DB lock)
Database.__init__의 공휴일 동기화 백그라운드 스레드가 SQLite 연결을 잡고 있어
임시 DB os.remove가 실패하던 문제. 문서화된 CLOCKOUT_DISABLE_HOLIDAY_SYNC 플래그를 테스트 시작 시 설정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:27:43 +09:00
KINDNICK
5fb8655a47 v2.11.0: UI 전면 다크 리디자인 + 라인 아이콘 + 적립 가드/삭제
- 모던 다크 미니멀 테마(NanumSquare 번들, 단일 accent #4DABF7, 8px radius, flat 버튼, 다크 기본값)
- 라인 아이콘 시스템(ui/icons.py, QtSvg) — 앱 전반 이모지 교체
- 다크 깨짐 수정: 테이블 헤더/코너 흰색, 도움말 탭 흰 라인, 트레이/미니위젯 메뉴
- fix: 자동 적립 OFF가 자동 퇴근 경로에서 무시되던 버그(게이팅)
- feat: 연장근무 적립 기록 삭제(우클릭)
- 테스트 3건 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:21:54 +09:00
KINDNICK
da5f91984b v2.10.2: 휴일 근무 카운터 초 표시 수정
원격 v2.10.1(cmd창 깜빡임 hotfix) 위에 rebase → 2.10.2.
- 휴일 분기 표시용 remaining을 초 정밀도 timedelta로 분리
  (적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
- pytest 189 + 통합 53 green
2026-05-16 18:16:45 +09:00
68893236+KINDNICK@users.noreply.github.com
3db4ed2351 v2.10.1: 업데이트 시 cmd 창 깜빡임 제거 (hotfix)
- updater.spec: console=True → console=False (windowed 빌드)
- updater.py: stderr 출력을 ~/.clockout_logs/updater.log 파일 폴백으로 전환
  (windowed 모드라도 진단 로그 보존). 모든 단계 타임스탬프 기록.
- updater.py launch(): subprocess.Popen에 CREATE_NO_WINDOW 플래그 추가
- utils/updater_client.py apply_update(): 같은 패턴으로 CREATE_NO_WINDOW 추가
  main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:33:55 +09:00
KINDNICK
97dd4e39f7 v2.10.0: \uc815\ubd80 \ud2b9\uc77c\uc815\ubcf4 API \uc5f0\ub3d9 + \uc77c \uc790\ub3d9 \ub3d9\uae30\ud654
\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0
\uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback.

- utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item)
- Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain
- migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654
  (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var)
- Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8

Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green
\ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green

\uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5,
fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
2026-05-01 13:51:33 +09:00
67 changed files with 5008 additions and 1733 deletions

610
AGENTS.md
View File

@ -1,176 +1,476 @@
# Project Conventions and Operational Gotchas # Clock-out Time Calculator — Agent Guide
## 🛠️ Setup & Execution > Last verified against the working tree at version **2.11.2** (`core/version.py`).
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays). > This file is written for AI coding agents who need to understand, modify, build, or release the project. When in doubt, prefer the facts in this file over older documentation; this guide was produced by exploring the actual codebase, running the tests, and reading the build scripts.
- **Run:** `python main.py`
- **Module-level smoke:**
- Event monitoring: `python core/event_monitor.py`
- Time calculation: `python core/time_calculator.py`
- **Integration tests** (all should be green before release):
- `python _integration_test.py` — business-logic scenarios (35+ for v2.02.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) ---
### Database (10+ tables in `database.db`) ## 1. Project Overview
`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 **Clock-out Time Calculator** (Korean: 퇴근시간 계산기) is a Windows desktop productivity application written in Python with PyQt5. It tracks a user's workday, automatically detects clock-in time from Windows Event Log / boot time, counts down to the expected clock-out time in real time, banks overtime in configurable units, manages annual leave, and provides statistics, notifications, Discord integration, and automatic self-updates.
- **`work_records.date` UNIQUE** — one row per workday.
- **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 - **Primary language:** Python 3.9+
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings. - **GUI framework:** PyQt5
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed). - **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`. - **Packaging:** PyInstaller (`main.exe` + `updater.exe`)
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running. - **Distribution:** Gitea Releases on a self-hosted instance
- **Current version:** `2.11.2` (single source of truth: `core/version.py`)
- **Repository:** `kindnick/Clock_out_Time_Calculator`
### i18n The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
- `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) ---
### 1. Time-off subtraction order in `update_display()` ## 2. Technology Stack
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 | Layer | Technology |
`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`. |-------|------------|
| Language | Python 3.9+ |
| GUI | PyQt5 ≥ 5.15 |
| Charts | matplotlib (QtAgg backend) |
| Windows integration | pywin32 (event log), ctypes (screen-lock detection) |
| Date / recurrence | python-dateutil |
| Notifications | plyer (system toast) + PyQt signals |
| Holidays | optional `holidays` package; government API + fixed-date fallback |
| Packaging | PyInstaller 2-step build |
| Testing | pytest + standalone integration/GUI smoke scripts |
| Fonts | Bundled NanumSquare TTF/OTF files; Malgun Gothic fallback |
### 3. Time format separation Dependencies are declared in `requirements.txt`:
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
### 4. Workday boundary ```text
`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. PyQt5>=5.15.0
pywin32>=305
python-dateutil>=2.8.0
matplotlib>=3.4.0
plyer>=2.0.0
holidays>=0.40
```
### 5. Migration idempotency Install with:
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 ```bash
# Manual two-step pip install -r requirements.txt
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB) ```
`pywin32` is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms.
---
## 3. Directory Structure and Module Map
```text
Clock-out Time Calculator/
├── main.py # Application entry point / bootstrap
├── updater.py # Standalone update helper process (stdlib only)
├── main.spec # PyInstaller spec for main.exe
├── updater.spec # PyInstaller spec for updater.exe
├── release.ps1 # One-shot release script (PowerShell)
├── requirements.txt # Python dependencies
├── pytest.ini # pytest configuration
├── run_as_admin.bat # Convenience launcher
├── core/ # Business logic and data access
│ ├── database.py # SQLite schema, migrations, CRUD
│ ├── time_calculator.py # Pure time-math engine
│ ├── event_monitor.py # Windows Event Log clock-in detection
│ ├── notifier.py # Notification rule engine
│ ├── salary.py # Optional pay estimation
│ ├── i18n.py # Korean/English translation dictionaries
│ ├── settings_keys.py # Setting key constants
│ ├── achievements.py # 357 achievement definitions + evaluator
│ ├── recurring_leaves.py # Recurring leave pattern expansion
│ └── version.py # __version__ single source of truth
├── ui/ # PyQt5 views and widgets
│ ├── main_window.py # Central 1 Hz main window
│ ├── styles.py # Theme colors and QSS
│ ├── dark_components.py # Reusable dark-styled widgets
│ ├── icons.py # Icon resource helpers
│ ├── i18n_runtime.py # Runtime retranslation registry
│ ├── settings_view.py # Settings dialog
│ ├── stats_view.py # Weekly/monthly/pattern statistics
│ ├── chart_widget.py # matplotlib QtAgg wrapper + fallback
│ ├── calendar_view.py # Work-record calendar
│ ├── leave_calendar_view.py # Color-coded leave calendar
│ ├── break_view.py # Break history dialog
│ ├── overtime_view.py # Overtime bank/usage dialog
│ ├── leave_view.py # Leave management dialog
│ ├── recurring_leave_dialog.py
│ ├── schedule_view.py # Schedule view
│ ├── clock_in_dialog.py # Manual clock-in dialog
│ ├── meal_time_dialog.py # Lunch/dinner start-end input
│ ├── past_record_dialog.py # Add past record dialog
│ ├── achievements_view.py # Achievements browser
│ ├── onboarding_view.py # 5-step first-run wizard
│ ├── today_summary.py # Post-clock-out card
│ ├── goal_widget.py # Monthly goal progress bars
│ ├── mini_widget.py # Always-on-top frameless widget
│ ├── help_view.py # 6-tab help dialog
│ ├── accessibility.py # Font scale / high-contrast helpers
│ └── controllers/ # Thin controllers split from MainWindow
│ ├── lock_monitor.py # Screen-lock auto-break / unlock clock-in
│ ├── auto_lunch.py # Auto-lunch after 4 hours
│ ├── notification_orchestrator.py # 5-min-tick orchestrator
│ └── meal_controller.py # Lunch/dinner toggle handling
├── utils/ # Helpers and integrations
│ ├── backup.py # Daily SQLite backup with 7-rotation
│ ├── lock_detector.py # Win32 screen-lock detection
│ ├── discord_webhook.py # Discord embed pushes
│ ├── csv_importer.py # CSV import (standard format)
│ ├── csv_exporter.py # CSV export
│ ├── crash_handler.py # Global excepthook + optional Gitea report
│ ├── updater_client.py # Gitea Releases API client
│ ├── system_tray.py # System tray menu
│ ├── time_format.py # format_hours_minutes helper
│ ├── font_loader.py # NanumSquare font loading
│ ├── resource_manager.py # PyInstaller _MEIPASS path resolver
│ ├── debug_log.py # CLOCKOUT_DEBUG gated logging
│ └── holiday_api.py # Korean holiday API client
├── tests/ # pytest unit tests (13 files)
├── font/ # Bundled NanumSquare fonts
├── resources/ # Icons and resource links
├── analysis/ # Currently empty (only __init__.py)
├── build/ # Build staging directory
└── dist/ # Built EXEs and release ZIPs
```
> **Note:** `utils/http_api.py` is referenced in older documentation but is **not present** in the current working tree.
---
## 4. How to Build, Run, and Smoke-Test
### Development run
```bash
python main.py
```
`main.py` inserts the project root into `sys.path`, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches `MainWindow`.
A convenience batch file is provided:
```bash
run_as_admin.bat
```
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
### Module-level smoke tests
```bash
python core/event_monitor.py
python core/time_calculator.py
```
These run lightweight self-tests when invoked as scripts.
### Production build (manual two-step)
```bash
python -m PyInstaller --clean updater.spec
mkdir -p build/staging && cp dist/updater.exe build/staging/ mkdir -p build/staging && cp dist/updater.exe build/staging/
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater) python -m PyInstaller --clean main.spec
# Or one-shot
.\release.ps1 v2.7.0
``` ```
- `dist/main.exe` running → `PermissionError`. Kill it first. - `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
- `holidays` package only baked in if installed in build env. - `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`).
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`. - The staging copy is critical: `main.spec --clean` wipes `dist/`, so without `build/staging/updater.exe` the updater would be deleted mid-build.
## 🚦 External Integrations ### Production build (one-shot)
- **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. ```bash
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA). .\release.ps1 v2.11.2
- **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 (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.
- **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 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')`. 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. The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`.
### Build gotchas
- Kill any running `main.exe` before building or PyInstaller will fail with `PermissionError`.
- The `holidays` package is only baked into the EXE if it is installed in the build environment.
- Optional code signing is supported via `$env:CODE_SIGN_CERT` (.pfx path) and `$env:CODE_SIGN_PASS`.
- `main.spec` lists several `hiddenimports` that are easy to break: `holidays`, `holidays.countries.south_korea`, `win32evtlog`, `win32evtlogutil`, `matplotlib.backends.backend_qtagg`, `matplotlib.backends.backend_qt5agg`, `PyQt5.QtSvg`, `PyQt5.sip`, and `numpy.core._multiarray_tests`.
---
## 5. Testing Strategy
The project uses three layers of tests.
### 5.1 Unit tests (pytest)
Configuration: `pytest.ini`
```bash
python -m pytest tests/ -v --tb=short
```
There are **13 test files** under `tests/`:
- `test_time_calculator.py` — clock-out, overtime truncation, holiday overtime, day-type detection
- `test_database.py` — settings, migrations, leave calculations, consecutive OT days
- `test_csv_importer.py` — CSV parsing, validation, conflict handling
- `test_salary.py` — pay estimation and won formatting
- `test_recurring_leaves.py` — pattern parsing and expansion
- `test_crash_handler.py` — crash log insertion and Gitea reporting (mocked)
- `test_discord_webhook.py` — URL validation, payload shape, network errors
- `test_holiday_api.py` — API response parsing and error handling
- `test_i18n.py` — language switching, missing-key fallback, interpolation
- `test_i18n_runtime.py` — runtime retranslation of Qt widgets
- `test_overtime_accrual_guard.py` — auto-overtime rollover guard
- `test_updater.py` — version parsing, Gitea API URL, update logic
`tests/conftest.py` sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` so the background holiday-sync thread does not hold open temporary DB files during test cleanup.
**Current status:** `194 passed`.
### 5.2 Integration scenarios
```bash
python _integration_test.py
```
A standalone script with a custom `@case` decorator. It runs **53 scenarios** (S1S52, S52AE) covering fresh-install migrations, work-pattern calculations, overtime banking, leave, weekends/holidays, notifications, backup, settings sync, i18n, CSV import/export, salary, crash log, updater semver, Discord guards, goals, and accessibility keys.
**Current status:** `PASS: 53 FAIL: 0 WARN: 0`.
### 5.3 GUI smoke tests
```bash
python _i18n_gui_test.py # Korean/English label switching
python _gui_smoke_test.py # Widget instantiation
```
Both use `QT_QPA_PLATFORM=offscreen` so no real windows appear.
- `_i18n_gui_test.py`: 5 cases, **all passing**.
- `_gui_smoke_test.py`: 8 cases, **all passing**.
### 5.4 Pre-release test command summary
```bash
python -m pytest tests/ -q
python _integration_test.py
python _i18n_gui_test.py
python _gui_smoke_test.py
```
`release.ps1` runs the first two by default (skippable with `-SkipTests`).
---
## 6. Code Style and Conventions
### Identifiers
- Classes: `PascalCase`
- Functions / variables: `snake_case`
- Module-level constants (especially setting keys): `UPPER_CASE`
### Settings keys
All setting keys are defined as constants in `core/settings_keys.py`. Import and use the constants; **never use raw strings** for new logic. When adding a new key, also add a default value in `Database.init_default_settings()`.
### Imports
Order is typically: standard library → third-party → project modules. Several newer modules use `from __future__ import annotations`.
### Comments and docstrings
Code identifiers are English, but inline comments and docstrings are predominantly Korean. New code should follow the existing bilingual style: English identifiers, Korean explanatory comments.
### UI construction
- Build widgets in `init_ui()`.
- Use helper methods such as `create_*_group()` for readability.
- Set `objectName` for QSS styling.
- Always verify `self.setLayout(main_layout)` is at the **end** of `init_ui()`, not accidentally indented into a method body.
### Type hints
Use type hints on public DB and helper methods (`-> int`, `-> Optional[Dict]`, `-> List[Dict]`, etc.).
### String formatting
Use f-strings for internal messages. For user-visible text, use the i18n API:
```python
from core.i18n import tr
tr('key', name=value)
```
### No enforced linter
There is no `pyproject.toml`, `.flake8`, or `setup.cfg`. Formatting is informal; keep line lengths reasonable and match the surrounding style.
---
## 7. Critical Invariants (MUST PRESERVE)
### 7.1 Time-off subtraction order in `MainWindow.update_display()`
Pass the actual `break_minutes` to `TimeCalculator.calculate_remaining_time()`, then subtract `total_time_off = overtime_used + leave_used` from the resulting `timedelta` **after** the call. **Never** mutate `break_minutes` to `break_minutes - overtime_used` before calling. Doing so previously caused a "+29h remaining time" bug.
```python
break_minutes = self.db.get_total_break_minutes_today()
overtime_used_today = self.db.get_today_overtime_usage()
leave_used_today = self.db.get_today_leave_minutes()
total_time_off = overtime_used_today + leave_used_today
remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_minutes)
remaining -= timedelta(minutes=total_time_off)
```
### 7.2 Hot-path caching
`update_display()` runs at **1 Hz**. DB reads inside it must be cached or throttled:
- `cached_time_format` in `MainWindow`
- `AutoLunchManager` caches enabled/non-working state
- `NotificationOrchestrator` gates periodic checks to 5-minute buckets
- Use `_set_text_if_changed()` to avoid useless repaints
### 7.3 24-hour internal time
All calculation uses 24-hour `datetime`. 12-hour conversion (Korean "오전"/"오후") happens only in `MainWindow.format_time()`.
### 7.4 Workday boundary
`workday_boundary_hour` defaults to 6. Overnight work stays on the previous day's record until that hour. Do not naively use `date.today()` in time logic.
### 7.5 Database invariants
- `work_records.date` is `UNIQUE`.
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are **NULLable** for manual entries. Never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
- `leave_records.days` is `REAL` (1.0 / 0.5 / 0.25 / hourly).
- Canonical time unit is **minutes** (`work_minutes`). `work_hours` is a derived/floor-synced property. Use `int(minutes) // 60` for hours, not `round()`.
- Overtime balance = `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` plus any `INITIAL_OVERTIME_MINUTES`.
- WAL mode + 5-second busy timeout is enabled for cloud-sync friendliness (OneDrive/Dropbox).
### 7.6 Migration idempotency
Every `migrate_*()` method must early-return if already applied. Use sentinel settings or `IF NOT EXISTS`. Examples: `balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`.
### 7.7 Settings auto-sync pairs
`Database.save_settings()` automatically keeps these pairs in sync:
- `WORK_MINUTES ↔ WORK_HOURS` (floor division)
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
### 7.8 Single-file deployment and updater handoff
- `main.exe` embeds `updater.exe` via `main.spec` data files.
- `_ensure_updater_extracted()` in `main.py` extracts the embedded updater on first launch from `sys._MEIPASS`.
- Protect the staging copy at `build/staging/updater.exe`.
- `updater.py` is **standalone** (no Qt/network deps). It accepts `--pid`, `--new`, and `--target`, waits for the PID, swaps files with `.bak` rollback, and relaunches.
---
## 8. Security Considerations
### 8.1 HTTP API
`utils/http_api.py` is **not present** in the current tree. If it is reintroduced, it must bind to `127.0.0.1` only and remain read-only. Never expose it externally.
### 8.2 Discord webhook
- URL is validated against official Discord webhook domains (`discord.com`, `discordapp.com`, canary/ptb).
- A browser User-Agent is mandatory; Cloudflare blocks the default Python UA.
- Push failures are silent so they do not break the app.
### 8.3 Auto-update
- Update check polls a self-hosted Gitea API: `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`.
- Downloads `main_new.exe` next to the running executable; `updater.exe` performs the swap with `.bak` rollback.
- Update apply only works in frozen builds; development `.py` runs are notified but not modified.
### 8.4 DB path handling and cloud sync
- `main.py` and `MainWindow` both bootstrap with the default DB first, read `DB_PATH_OVERRIDE`, then reopen with the override path. Do not break this order.
- WAL mode + busy timeout tolerates OneDrive/Dropbox sync.
### 8.5 Crash reporting
- Gitea issue reporting is **opt-in** via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
- Tokens are stored as plain strings in the SQLite `settings` table. The UI uses `QLineEdit.Password` echo mode.
### 8.6 Single instance
Multiple copies are prevented via `QLocalServer` named `ClockOutCalculatorInstance`.
### 8.7 Debug logging
`utils/debug_log.py` only emits output when `CLOCKOUT_DEBUG=1` (or `true`/`yes`).
---
## 9. External Integrations
- **Auto-update** (`utils/updater_client.py`): polls the Gitea Releases API. User-Agent: `ClockOutCalculator-Updater/1.0`.
- **Discord webhook** (`utils/discord_webhook.py`): optional one-direction push. Browser User-Agent required.
- **Gitea Issues** (`utils/crash_handler.py`): optional crash reporting; opt-in only.
- **Cloud sync via `DB_PATH_OVERRIDE`**: settings stores the DB path; the app reopens the override path after reading it.
- **`holidays` package**: `Database.add_korean_holidays_auto()` returns `-1` if the package is missing, and the UI falls back to `add_korean_holidays()` (8 fixed dates).
- **Korean holiday API** (`utils/holiday_api.py`): 공공데이터포털 특일정보 API 키는 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수에서 읽음. 소스코드/바이너리에 키를 하드코딩하지 않음.
---
## 10. i18n Conventions
- API: `tr(key, **kwargs)` and `tr_html(key)` from `core/i18n.py`.
- Translations live in `_DICT['ko']` and `_DICT['en']`. **Both languages must receive any new key.**
- Sentence interpolation uses Python `str.format(**kwargs)`.
- HelpView HTML content is in `_HELP_HTML`.
- Runtime retranslation is supported via `ui/i18n_runtime.py` using a weakref registry. Register widgets with `register(widget, key, setter='setText', kwargs={}, post=None)` and call `set_language_and_retranslate(lang)` to update them.
- UI files (`ui/`) and achievement metadata (`core/achievements.py`) are fully key-based.
- Remaining P4 internal-data hardcoding (not user-facing labels) includes DB-stored `leave_type` values and Korean holiday names in `core/database.py`.
- A language change may still prompt a restart for widgets not registered for runtime retranslate.
---
## 11. Release Flow
`release.ps1 vX.Y.Z` performs the full release locally:
1. **Pre-checks**: verify `$env:GITEA_TOKEN`, no running `main.exe`, no existing tag, no uncommitted changes (unless `-DryRun`).
2. **Bump version**: rewrite `core/version.py`.
3. **Tests**: run `pytest tests/` and `python _integration_test.py` (skippable with `-SkipTests`).
4. **Build**: `updater.spec``build/staging/``main.spec`.
5. **Code signing** (optional): sign both EXEs if `$env:CODE_SIGN_CERT` is set.
6. **ZIP packaging**: `dist/ClockOutCalculator-vX.Y.Z.zip` containing `main.exe` and `updater.exe`.
7. **Git commit + tag + push**: commit `core/version.py` and `CHANGELOG.md`.
8. **Gitea Release POST**: read `CHANGELOG.md` with UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section.
9. **Asset upload**: `main.exe`, `updater.exe`, and the ZIP.
Use `-DryRun` to preview without git push or API calls.
---
## 12. Past Incidents (Do Not Re-introduce)
| Incident | Root cause / fix |
|----------|------------------|
| +29h remaining time | Mutating `break_minutes -= overtime_used` before calculation. Fixed by subtracting `total_time_off` after `calculate_remaining_time`. |
| Manual overtime invisible | Filtering `work_record_id IS NOT NULL`. Now show all rows and label NULL as "수동 추가" / "Manual". |
| `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` because Python rounds half to even. Use `int(minutes) // 60`. |
| FK enforcement rollback | `PRAGMA foreign_keys=ON` conflicted with existing manual overtime records. Kept WAL + timeout, no FK enforcement. |
| Discord 403 / Cloudflare 1010 | Default Python UA blocked. Fixed with browser UA. |
| Help dialog blank | `self.setLayout(main_layout)` indented into `_reopen_onboarding`. Verify `setLayout()` is at the end of `init_ui()`. |
| `dist/updater.exe` wiped by `--clean` | Solved by staging copy at `build/staging/updater.exe`. |
| Onboarding auto-skipped | Existing users with `work_records` were auto-completed. Added "Re-run Onboarding" button to Help. |
| PowerShell 5.1 ANSI | `Get-Content`/`Set-Content` default to ANSI and mangle Korean in `CHANGELOG.md` and `core/version.py`. Use `[System.IO.File]::ReadAllText`/`WriteAllText(path, UTF8)`. |
| PowerShell NativeCommandError | Use `Invoke-Native` helper with `$ErrorActionPreference = 'Continue'` and explicit `$LASTEXITCODE`. |
| Frozen chart numpy failure | Added `numpy.core._multiarray_tests` to `main.spec` hiddenimports. |
---
## 13. Additional References
- `README.md` — end-user feature overview (Korean/English mixed).
- `CLAUDE.md` — additional Korean-language guidance for this project.
- `INSTALL.md` — installation and troubleshooting instructions.
- `CHANGELOG.md` — release notes in Korean.
- `resources/resource_links.md` — links to external resources.
When modifying code, update this `AGENTS.md` if you change any conventions, build steps, module map, or external integrations described here.

View File

@ -4,6 +4,126 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.12.0] — 2026-06-16
### Added — 전체 i18n 키화 완료
- **UI 전체 사용자 대면 문자열 i18n 키화**`ui/` 디렉터리 22개 파일의 버튼/라벨/메시지박스/차트/온볼딩/설정/통계 등을 `tr()` 기반으로 전환, `core/i18n.py`에 ko/en 번역 추가.
- **도전과제 메타데이터 i18n**`core/achievements.py`의 모든 도전과제 이름/설명을 `achieve.{code}.name/desc` 키로 분리하고 영문 번역 추가.
- **차트 위젯 라벨 키화** — matplotlib 차트의 축/툴팁/범례/빈 기록 메시지 등을 언어별로 표시.
- **반복 연차 패턴 설명 키화**`core/recurring_leaves.describe_pattern()`이 요일/주기 접두사를 i18n 키로 조합.
### Fixed
- `ui/help_view.py`의 "온보팅 다시 보기" 버튼이 `tr()` 호출이 아닌 리터럴 문자열로 잘못 들어가던 버그 수정.
- `core/i18n.py` 영문 achievement 번역 중 `Children's`/`Teachers'` 작은따옴표로 인한 SyntaxError 수정.
- `_i18n_gui_test.py``CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 추가하여 백그라운드 휴일 동기화 스레드로 인한 세그멘테이션 폴트 방지.
### Changed
- **공공데이터포털 공휴일 API 키 외부화**`utils/holiday_api.py``CLOCKOUT_HOLIDAY_API_KEY` 환경변수를 사용하도록 변경.
- `ui/main_window.py` 1Hz 핫패스 DB 호출 캐싱 추가.
- `utils/csv_importer.py` overwrite 시 `overtime_usage`도 함께 삭제.
- `ui/controllers/lock_monitor.py` 컨텍스트 매니저 적용 및 race condition 처리 개선.
## [2.11.2] — 2026-06-04
### Fixed
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
## [2.11.1] — 2026-06-04
### Fixed
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget``backend_qt5agg`
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
폴백) + 실패 원인 로깅, `main.spec``backend_qtagg`/`PyQt5.sip` 명시.
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제**`dark_components`
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
## [2.11.0] — 2026-06-04
### Changed — UI 전면 다크 리디자인
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
`main.spec`에 동봉
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
### Added
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec``PyQt5.QtSvg` 포함)
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
(`Database.delete_overtime_earned`)
### Fixed
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
### Tests
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
## [2.10.2] — 2026-05-16
### Fixed
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
그대로 표시에 사용 → 초 정보 소실
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
## [2.10.1] — 2026-05-01
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
- **`updater.spec`**: `console=True``console=False` (windowed 빌드).
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
에 타임스탬프 + 결과 기록.
- **`updater.py launch()`**: `subprocess.Popen``CREATE_NO_WINDOW` 플래그 추가
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
## [2.10.0] — 2026-05-01
### Added — 정부 공휴일 API 자동 동기화
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
- 키는 dev 본인 계정의 특일정보 API 한정 키
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
- sentinel: `settings['holidays_synced_date']`
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
### Changed
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
### Tests
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
- pytest: 175 → **189**
### 주의
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
## [2.9.0] — 2026-05-01 ## [2.9.0] — 2026-05-01
### Fixed — 휴일 hot-path 버그 (사용자 보고) ### Fixed — 휴일 hot-path 버그 (사용자 보고)

View File

@ -11,6 +11,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Qt platform plugin: offscreen으로 실제 창 안 뜨게 # Qt platform plugin: offscreen으로 실제 창 안 뜨게
os.environ['QT_QPA_PLATFORM'] = 'offscreen' os.environ['QT_QPA_PLATFORM'] = 'offscreen'
# 백그라운드 휴일 동기화 스레드 비활성화 (DB lock / segfault 방지)
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication(sys.argv) app = QApplication.instance() or QApplication(sys.argv)
@ -87,7 +90,7 @@ def test_stats_view():
from ui.stats_view import StatsView from ui.stats_view import StatsView
dlg = StatsView(db=db) dlg = StatsView(db=db)
# 데이터 없어도 정상 로드 # 데이터 없어도 정상 로드
assert dlg.weekly_total_hours.text() is not None assert dlg.weekly_total_card is not None
dlg.deleteLater() dlg.deleteLater()
@ -101,12 +104,25 @@ def test_main_window_init():
"""MainWindow 초기화 — 가장 무거운 케이스""" """MainWindow 초기화 — 가장 무거운 케이스"""
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인 # QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
from ui.main_window import MainWindow from ui.main_window import MainWindow
w = MainWindow() from datetime import date as _date
# MainWindow load_today_data에서 QMessageBox를 띄우지 않도록 오늘 출근 기록을 미리 삽입
today = _date.today().isoformat()
conn = db.get_connection()
try:
conn.execute("DELETE FROM work_records WHERE date = ?", (today,))
conn.execute(
"INSERT INTO work_records (date, clock_in, clock_out, lunch_break, dinner_break) VALUES (?, ?, ?, ?, ?)",
(today, '09:00:00', '18:00:00', 0, 0)
)
conn.commit()
finally:
conn.close()
w = MainWindow(db=db)
# 기본 상태 # 기본 상태
assert w.is_clocked_in == False assert w.is_clocked_in == False # 퇴근 완료 기록이므로 False
assert w.lunch_break_enabled == False assert w.lunch_break_enabled == False
# auto_lunch 캐시 초기 None # auto_lunch 캐시 초기 None (AutoLunchManager 낶)
assert w._auto_lunch_enabled_cache is None assert w._auto_lunch._enabled_cache is None
# 단축키 7개 등록되었는지 # 단축키 7개 등록되었는지
from PyQt5.QtWidgets import QShortcut from PyQt5.QtWidgets import QShortcut
shortcuts = w.findChildren(QShortcut) shortcuts = w.findChildren(QShortcut)

View File

@ -9,6 +9,7 @@ import sys
import tempfile import tempfile
os.environ['QT_QPA_PLATFORM'] = 'offscreen' os.environ['QT_QPA_PLATFORM'] = 'offscreen'
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox

View File

@ -15,6 +15,11 @@ from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
PASS = [] PASS = []
FAIL = [] FAIL = []
WARN = [] WARN = []

View File

@ -17,6 +17,7 @@
- notification_log: 휴식 권고 카운트 - notification_log: 휴식 권고 카운트
""" """
from __future__ import annotations from __future__ import annotations
from core.i18n import tr
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Callable, Optional, List, Tuple from typing import Callable, Optional, List, Tuple
@ -426,40 +427,40 @@ def _bool_eval(condition_fn):
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ---- # ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
_STREAK_DEFS = [ _STREAK_DEFS = [
# (code, name, desc, target, evaluator, tier, icon) # (code, name, desc, target, evaluator, tier, icon)
('streak_first', '첫걸음', '첫 출근 기록', 1, ('streak_first', tr('achieve.streak_first.name'), tr('achieve.streak_first.desc'), 1,
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'), _bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
('streak_3', '뿌리내림', '3일 연속 영업일 출근', 3, ('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3,
_make_streak_eval(3), TIER_BRONZE, '🌱'), _make_streak_eval(3), TIER_BRONZE, '🌱'),
('streak_5', '첫 주 완주', '5 영업일 연속 출근', 5, ('streak_5', tr('achieve.streak_5.name'), tr('achieve.streak_5.desc'), 5,
_make_streak_eval(5), TIER_SILVER, '📅'), _make_streak_eval(5), TIER_SILVER, '📅'),
('streak_7_cal', '7일 연속', '주말 포함 7일 연속 출근', 7, ('streak_7_cal', tr('achieve.streak_7_cal.name'), tr('achieve.streak_7_cal.desc'), 7,
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'), _make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
('streak_10', '2주 연속', '10 영업일 연속 출근', 10, ('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10,
_make_streak_eval(10), TIER_SILVER, '💪'), _make_streak_eval(10), TIER_SILVER, '💪'),
('streak_22', '한 달 개근', '한 달 영업일 100% 출근 (22일)', 22, ('streak_22', tr('achieve.streak_22.name'), tr('achieve.streak_22.desc'), 22,
_make_streak_eval(22), TIER_GOLD, '🏔️'), _make_streak_eval(22), TIER_GOLD, '🏔️'),
('streak_50', '50일 연속', '50 영업일 연속 출근', 50, ('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50,
_make_streak_eval(50), TIER_GOLD, '🎯'), _make_streak_eval(50), TIER_GOLD, '🎯'),
('streak_100', '100일 연속', '100 영업일 연속 출근', 100, ('streak_100', tr('achieve.streak_100.name'), tr('achieve.streak_100.desc'), 100,
_make_streak_eval(100), TIER_PLATINUM, '💎'), _make_streak_eval(100), TIER_PLATINUM, '💎'),
('streak_quarter', '분기 완주', '약 65 영업일 (3개월)', 65, ('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65,
_make_streak_eval(65), TIER_PLATINUM, '🏆'), _make_streak_eval(65), TIER_PLATINUM, '🏆'),
('streak_half_year', '반년 마라톤', '약 130 영업일 (6개월)', 130, ('streak_half_year', tr('achieve.streak_half_year.name'), tr('achieve.streak_half_year.desc'), 130,
_make_streak_eval(130), TIER_PLATINUM, '👑'), _make_streak_eval(130), TIER_PLATINUM, '👑'),
('streak_year', '1년 풀 시즌', '약 260 영업일 (1년)', 260, ('streak_year', tr('achieve.streak_year.name'), tr('achieve.streak_year.desc'), 260,
_make_streak_eval(260), TIER_LEGEND, '🌟'), _make_streak_eval(260), TIER_LEGEND, '🌟'),
('streak_200', '사이언스', '200 영업일 연속', 200, ('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200,
_make_streak_eval(200), TIER_LEGEND, '🌌'), _make_streak_eval(200), TIER_LEGEND, '🌌'),
('streak_365_cal', '불사신', '365일 달력 연속', 365, ('streak_365_cal', tr('achieve.streak_365_cal.name'), tr('achieve.streak_365_cal.desc'), 365,
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'), _make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
('streak_resilience', '회복력', '결근 후 다음날 즉시 출근 (자동: 달력 streak 깨진 후 재시작)', 1, ('streak_resilience', tr('achieve.streak_resilience.name'), tr('achieve.streak_resilience.desc'), 1,
_bool_eval(lambda db: _consecutive_workdays(db) >= 1 _bool_eval(lambda db: _consecutive_workdays(db) >= 1
and _count_work_records(db) >= 5), TIER_BRONZE, ''), and _count_work_records(db) >= 5), TIER_BRONZE, ''),
('streak_total_100', '누적 100회', '누적 출근 100회', 100, ('streak_total_100', tr('achieve.streak_total_100.name'), tr('achieve.streak_total_100.desc'), 100,
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'), _make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
('streak_total_500', '누적 500회', '누적 출근 500회', 500, ('streak_total_500', tr('achieve.streak_total_500.name'), tr('achieve.streak_total_500.desc'), 500,
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'), _make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
('streak_total_1000', '누적 1000회', '누적 출근 1000회', 1000, ('streak_total_1000', tr('achieve.streak_total_1000.name'), tr('achieve.streak_total_1000.desc'), 1000,
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'), _make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
] ]
@ -476,37 +477,37 @@ def _count_weekday_clockins(db, weekday: int) -> int:
_STREAK_DEFS.extend([ _STREAK_DEFS.extend([
('streak_monday_10', '월요일 정복', '월요일 10주 연속 출근', 10, ('streak_monday_10', tr('achieve.streak_monday_10.name'), tr('achieve.streak_monday_10.desc'), 10,
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'), _make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
('streak_friday_10', '금요일 무결', '금요일 10주 연속 출근', 10, ('streak_friday_10', tr('achieve.streak_friday_10.name'), tr('achieve.streak_friday_10.desc'), 10,
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'), _make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
]) ])
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ---- # ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
_PUNCTUAL_DEFS = [ _PUNCTUAL_DEFS = [
('punc_before_8_1', '얼리버드', '08:00 이전 출근 1회', 1, ('punc_before_8_1', tr('achieve.punc_before_8_1.name'), tr('achieve.punc_before_8_1.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'), _make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
('punc_before_8_10', '참새족', '08:00 이전 10회', 10, ('punc_before_8_10', tr('achieve.punc_before_8_10.name'), tr('achieve.punc_before_8_10.desc'), 10,
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'), _make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
('punc_before_8_30', '일찍 자고 일찍', '08:00 이전 30회', 30, ('punc_before_8_30', tr('achieve.punc_before_8_30.name'), tr('achieve.punc_before_8_30.desc'), 30,
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'), _make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
('punc_before_6_1', '새벽잠 없음', '06:00 이전 1회', 1, ('punc_before_6_1', tr('achieve.punc_before_6_1.name'), tr('achieve.punc_before_6_1.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'), _make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
('punc_before_6_10', '어둠을 가르는 자', '06:00 이전 10회', 10, ('punc_before_6_10', tr('achieve.punc_before_6_10.name'), tr('achieve.punc_before_6_10.desc'), 10,
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'), _make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
('punc_before_5', '새벽 챔피언', '05:00 이전 출근', 1, ('punc_before_5', tr('achieve.punc_before_5.name'), tr('achieve.punc_before_5.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'), _make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
('punc_at_9', '9시 정각', '09:00 정각(±1분) 출근 1회', 1, ('punc_at_9', tr('achieve.punc_at_9.name'), tr('achieve.punc_at_9.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1), _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
TIER_BRONZE, '🎯'), TIER_BRONZE, '🎯'),
('punc_at_9_5', '완벽한 9시', '09:00 정각(±1분) 5회', 5, ('punc_at_9_5', tr('achieve.punc_at_9_5.name'), tr('achieve.punc_at_9_5.desc'), 5,
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5), _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
TIER_SILVER, '🏹'), TIER_SILVER, '🏹'),
('punc_late_5min', '5분 늦음', '09:00~09:05 출근 1회 (자조)', 1, ('punc_late_5min', tr('achieve.punc_late_5min.name'), tr('achieve.punc_late_5min.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1), _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
TIER_BRONZE, '🛌'), TIER_BRONZE, '🛌'),
('punc_at_909', '운명의 시각', '09:09 출근 (시크릿)', 1, ('punc_at_909', tr('achieve.punc_at_909.name'), tr('achieve.punc_at_909.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1), _make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
TIER_GOLD, '🎰'), TIER_GOLD, '🎰'),
] ]
@ -525,76 +526,76 @@ def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> i
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ---- # ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
_BALANCE_DEFS = [ _BALANCE_DEFS = [
('bal_first_punct', '첫 칼퇴', '정시 퇴근 첫 달성', 1, ('bal_first_punct', tr('achieve.bal_first_punct.name'), tr('achieve.bal_first_punct.desc'), 1,
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'), _make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
('bal_punct_10', '칼퇴러', '정시 퇴근 10회', 10, ('bal_punct_10', tr('achieve.bal_punct_10.name'), tr('achieve.bal_punct_10.desc'), 10,
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'), _make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
('bal_punct_30', '칼퇴 챔프', '정시 퇴근 30회', 30, ('bal_punct_30', tr('achieve.bal_punct_30.name'), tr('achieve.bal_punct_30.desc'), 30,
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'), _make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
('bal_punct_100', '진정한 자유', '정시 퇴근 100회', 100, ('bal_punct_100', tr('achieve.bal_punct_100.name'), tr('achieve.bal_punct_100.desc'), 100,
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'), _make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
('bal_punct_300', '워라밸 마스터', '정시 퇴근 300회', 300, ('bal_punct_300', tr('achieve.bal_punct_300.name'), tr('achieve.bal_punct_300.desc'), 300,
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'), _make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
] ]
# ---- 4. 연장근무 적립 ---- # ---- 4. 연장근무 적립 ----
_OT_BANK_DEFS = [ _OT_BANK_DEFS = [
('ot_first_30m', '첫 30분', '첫 연장 적립', 30, ('ot_first_30m', tr('achieve.ot_first_30m.name'), tr('achieve.ot_first_30m.desc'), 30,
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'), _make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
('ot_total_60m', '1시간 적금', '누적 1시간 적립', 60, ('ot_total_60m', tr('achieve.ot_total_60m.name'), tr('achieve.ot_total_60m.desc'), 60,
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'), _make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
('ot_total_5h', '5시간 적립', '누적 5시간', 300, ('ot_total_5h', tr('achieve.ot_total_5h.name'), tr('achieve.ot_total_5h.desc'), 300,
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'), _make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
('ot_total_10h', '10시간 적립', '누적 10시간', 600, ('ot_total_10h', tr('achieve.ot_total_10h.name'), tr('achieve.ot_total_10h.desc'), 600,
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'), _make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
('ot_total_25h', '25시간 적립', '누적 25시간', 1500, ('ot_total_25h', tr('achieve.ot_total_25h.name'), tr('achieve.ot_total_25h.desc'), 1500,
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'), _make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
('ot_total_50h', '50시간 적립', '누적 50시간', 3000, ('ot_total_50h', tr('achieve.ot_total_50h.name'), tr('achieve.ot_total_50h.desc'), 3000,
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'), _make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
('ot_total_100h', '마라토너', '누적 100시간 (걱정 메시지)', 6000, ('ot_total_100h', tr('achieve.ot_total_100h.name'), tr('achieve.ot_total_100h.desc'), 6000,
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'), _make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
('ot_total_200h', '워크홀릭 경고', '누적 200시간 (경고)', 12000, ('ot_total_200h', tr('achieve.ot_total_200h.name'), tr('achieve.ot_total_200h.desc'), 12000,
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'), _make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
('ot_total_300h', '위험 신호', '누적 300시간 (강한 경고)', 18000, ('ot_total_300h', tr('achieve.ot_total_300h.name'), tr('achieve.ot_total_300h.desc'), 18000,
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'), _make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
('ot_total_500h', '응급실 단골', '누적 500시간 (자조)', 30000, ('ot_total_500h', tr('achieve.ot_total_500h.name'), tr('achieve.ot_total_500h.desc'), 30000,
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'), _make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
] ]
# ---- 5. 연장근무 사용 ---- # ---- 5. 연장근무 사용 ----
_OT_USE_DEFS = [ _OT_USE_DEFS = [
('use_first', '첫 휴식', '적립 첫 사용', 1, ('use_first', tr('achieve.use_first.name'), tr('achieve.use_first.desc'), 1,
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'), _bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
('use_total_5h', '선물 사용', '누적 5시간 사용', 300, ('use_total_5h', tr('achieve.use_total_5h.name'), tr('achieve.use_total_5h.desc'), 300,
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'), _make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
('use_total_25h', '휴식의 가치', '누적 25시간 사용', 1500, ('use_total_25h', tr('achieve.use_total_25h.name'), tr('achieve.use_total_25h.desc'), 1500,
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'), _make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
('use_total_50h', '회복 마스터', '누적 50시간 사용', 3000, ('use_total_50h', tr('achieve.use_total_50h.name'), tr('achieve.use_total_50h.desc'), 3000,
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'), _make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
('use_total_100h', '마사지', '누적 100시간 사용', 6000, ('use_total_100h', tr('achieve.use_total_100h.name'), tr('achieve.use_total_100h.desc'), 6000,
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'), _make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
] ]
# ---- 6. 연차 ---- # ---- 6. 연차 ----
_LEAVE_DEFS = [ _LEAVE_DEFS = [
('leave_first', '첫 연차', '첫 연차 사용', 1, ('leave_first', tr('achieve.leave_first.name'), tr('achieve.leave_first.desc'), 1,
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'), _make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
('leave_half', '첫 반차', '0.5일 연차 사용', 1, ('leave_half', tr('achieve.leave_half.name'), tr('achieve.leave_half.desc'), 1,
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'), _bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
('leave_quarter', '시간 연차', '0.25일 연차 사용', 1, ('leave_quarter', tr('achieve.leave_quarter.name'), tr('achieve.leave_quarter.desc'), 1,
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'), _bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
('leave_streak_3', '미니 휴가', '연속 3일 연차', 3, ('leave_streak_3', tr('achieve.leave_streak_3.name'), tr('achieve.leave_streak_3.desc'), 3,
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'), _make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
('leave_streak_5', '본격 휴가', '연속 5일 연차', 5, ('leave_streak_5', tr('achieve.leave_streak_5.name'), tr('achieve.leave_streak_5.desc'), 5,
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'), _make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
('leave_streak_7', '장거리 휴가', '연속 7일 이상 연차', 7, ('leave_streak_7', tr('achieve.leave_streak_7.name'), tr('achieve.leave_streak_7.desc'), 7,
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'), _make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
('leave_total_10', '연차 10회', '연차 기록 10건', 10, ('leave_total_10', tr('achieve.leave_total_10.name'), tr('achieve.leave_total_10.desc'), 10,
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'), _make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
('leave_sick', '병가', 'sick 타입 연차 사용', 1, ('leave_sick', tr('achieve.leave_sick.name'), tr('achieve.leave_sick.desc'), 1,
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1), _make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
TIER_BRONZE, '🏥'), TIER_BRONZE, '🏥'),
] ]
@ -602,22 +603,22 @@ _LEAVE_DEFS = [
# ---- 7. 식사 (점심/저녁) ---- # ---- 7. 식사 (점심/저녁) ----
_MEAL_DEFS = [ _MEAL_DEFS = [
('meal_lunch_first', '첫 점심 등록', '점심 첫 토글', 1, ('meal_lunch_first', tr('achieve.meal_lunch_first.name'), tr('achieve.meal_lunch_first.desc'), 1,
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'), _make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
('meal_lunch_30', '점심 마스터', '점심 등록 30회', 30, ('meal_lunch_30', tr('achieve.meal_lunch_30.name'), tr('achieve.meal_lunch_30.desc'), 30,
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'), _make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
('meal_lunch_100', '점심 챔프', '점심 등록 100회', 100, ('meal_lunch_100', tr('achieve.meal_lunch_100.name'), tr('achieve.meal_lunch_100.desc'), 100,
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'), _make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
('meal_dinner_first', '첫 저녁 등록', '저녁 첫 토글', 1, ('meal_dinner_first', tr('achieve.meal_dinner_first.name'), tr('achieve.meal_dinner_first.desc'), 1,
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'), _make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
('meal_dinner_10', '저녁 단골', '저녁 등록 10회 (경고)', 10, ('meal_dinner_10', tr('achieve.meal_dinner_10.name'), tr('achieve.meal_dinner_10.desc'), 10,
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'), _make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
('meal_dinner_30', '야식 단골', '저녁 등록 30회 (경고)', 30, ('meal_dinner_30', tr('achieve.meal_dinner_30.name'), tr('achieve.meal_dinner_30.desc'), 30,
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'), _make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
('meal_lunch_actual', '실측 점심', '실제 점심 시각 입력', 1, ('meal_lunch_actual', tr('achieve.meal_lunch_actual.name'), tr('achieve.meal_lunch_actual.desc'), 1,
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1), _make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
TIER_BRONZE, '⏱️'), TIER_BRONZE, '⏱️'),
('meal_dinner_actual', '실측 저녁', '실제 저녁 시각 입력', 1, ('meal_dinner_actual', tr('achieve.meal_dinner_actual.name'), tr('achieve.meal_dinner_actual.desc'), 1,
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1), _make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
TIER_BRONZE, ''), TIER_BRONZE, ''),
] ]
@ -625,13 +626,13 @@ _MEAL_DEFS = [
# ---- 8. 외출 ---- # ---- 8. 외출 ----
_BREAK_DEFS = [ _BREAK_DEFS = [
('break_first', '첫 외출', '첫 외출 시작', 1, ('break_first', tr('achieve.break_first.name'), tr('achieve.break_first.desc'), 1,
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1), _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
TIER_BRONZE, '🚶'), TIER_BRONZE, '🚶'),
('break_10', '외출 챔프', '외출 10회', 10, ('break_10', tr('achieve.break_10.name'), tr('achieve.break_10.desc'), 10,
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10), _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
TIER_SILVER, '🚪'), TIER_SILVER, '🚪'),
('break_50', '산책러', '외출 50회', 50, ('break_50', tr('achieve.break_50.name'), tr('achieve.break_50.desc'), 50,
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50), _make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
TIER_GOLD, '🚶‍♂️'), TIER_GOLD, '🚶‍♂️'),
] ]
@ -639,39 +640,39 @@ _BREAK_DEFS = [
# ---- 9. 시간대별 ---- # ---- 9. 시간대별 ----
_TIME_SLOT_DEFS = [ _TIME_SLOT_DEFS = [
('slot_in_06', '06시대 출근', '06:00-06:59 출근 1회', 1, ('slot_in_06', tr('achieve.slot_in_06.name'), tr('achieve.slot_in_06.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1), _make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
TIER_BRONZE, '🌅'), TIER_BRONZE, '🌅'),
('slot_in_07', '07시대 출근', '07:00-07:59 출근 1회', 1, ('slot_in_07', tr('achieve.slot_in_07.name'), tr('achieve.slot_in_07.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1), _make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
TIER_BRONZE, '🌄'), TIER_BRONZE, '🌄'),
('slot_in_08', '08시대 출근', '08:00-08:59 출근 1회', 1, ('slot_in_08', tr('achieve.slot_in_08.name'), tr('achieve.slot_in_08.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1), _make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
TIER_BRONZE, '☀️'), TIER_BRONZE, '☀️'),
('slot_in_10', '10시대 출근', '10시대 출근 (지각/유연근무)', 1, ('slot_in_10', tr('achieve.slot_in_10.name'), tr('achieve.slot_in_10.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1), _make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
TIER_BRONZE, '🕙'), TIER_BRONZE, '🕙'),
('slot_in_11', '11시대 출근', '11시대 출근 (자조)', 1, ('slot_in_11', tr('achieve.slot_in_11.name'), tr('achieve.slot_in_11.desc'), 1,
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1), _make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
TIER_SILVER, '🕦'), TIER_SILVER, '🕦'),
('slot_out_19', '19시대 퇴근', '19시대 퇴근 10회 (경고)', 10, ('slot_out_19', tr('achieve.slot_out_19.name'), tr('achieve.slot_out_19.desc'), 10,
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10), _make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
TIER_SILVER, '🌆'), TIER_SILVER, '🌆'),
('slot_out_20', '20시대 퇴근', '20시대 퇴근 10회 (경고)', 10, ('slot_out_20', tr('achieve.slot_out_20.name'), tr('achieve.slot_out_20.desc'), 10,
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10), _make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
TIER_GOLD, '🌌'), TIER_GOLD, '🌌'),
('slot_out_21', '21시대 퇴근', '21시대 퇴근 5회 (경고)', 5, ('slot_out_21', tr('achieve.slot_out_21.name'), tr('achieve.slot_out_21.desc'), 5,
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5), _make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
TIER_GOLD, '🌑'), TIER_GOLD, '🌑'),
('slot_out_22', '22시대 퇴근', '22시대 퇴근 1회 (경고)', 1, ('slot_out_22', tr('achieve.slot_out_22.name'), tr('achieve.slot_out_22.desc'), 1,
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1), _make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
TIER_PLATINUM, '🦉'), TIER_PLATINUM, '🦉'),
('slot_out_23', '23시대 퇴근', '23시대 퇴근 1회 (경고)', 1, ('slot_out_23', tr('achieve.slot_out_23.name'), tr('achieve.slot_out_23.desc'), 1,
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1), _make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
TIER_PLATINUM, '🦇'), TIER_PLATINUM, '🦇'),
('slot_midnight', '자정 퇴근', '자정 이후 퇴근 (경고)', 1, ('slot_midnight', tr('achieve.slot_midnight.name'), tr('achieve.slot_midnight.desc'), 1,
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'), _make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
('slot_midnight_3', '올빼미 트리오', '자정 이후 퇴근 3회 (경고)', 3, ('slot_midnight_3', tr('achieve.slot_midnight_3.name'), tr('achieve.slot_midnight_3.desc'), 3,
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'), _make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
] ]
@ -692,50 +693,50 @@ def _count_clockouts_in_hour(db, hour: int) -> int:
# ---- 10. 공휴일·주말 ---- # ---- 10. 공휴일·주말 ----
_SPECIAL_DAY_DEFS = [ _SPECIAL_DAY_DEFS = [
('weekend_1', '주말 출근 1회', '토/일 출근 1회', 1, ('weekend_1', tr('achieve.weekend_1.name'), tr('achieve.weekend_1.desc'), 1,
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'), _make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
('weekend_5', '주말 워커', '주말 출근 5회 (경고)', 5, ('weekend_5', tr('achieve.weekend_5.name'), tr('achieve.weekend_5.desc'), 5,
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'), _make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
('weekend_20', '진짜 워크홀릭', '주말 출근 20회 (강한 자조)', 20, ('weekend_20', tr('achieve.weekend_20.name'), tr('achieve.weekend_20.desc'), 20,
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'), _make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
('holiday_1', '공휴일 출근', '한국 공휴일 출근 1회', 1, ('holiday_1', tr('achieve.holiday_1.name'), tr('achieve.holiday_1.desc'), 1,
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'), _make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
('holiday_5', '공휴일 워커홀릭', '한국 공휴일 출근 5회 (경고)', 5, ('holiday_5', tr('achieve.holiday_5.name'), tr('achieve.holiday_5.desc'), 5,
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'), _make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
('day_christmas', '크리스마스 출근', '12/25 출근 (자조)', 1, ('day_christmas', tr('achieve.day_christmas.name'), tr('achieve.day_christmas.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'), _bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
('day_newyear', '신정 출근', '1/1 출근 (자조)', 1, ('day_newyear', tr('achieve.day_newyear.name'), tr('achieve.day_newyear.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'), _bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
('day_liberation', '광복절 출근', '8/15 출근', 1, ('day_liberation', tr('achieve.day_liberation.name'), tr('achieve.day_liberation.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'), _bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
('day_children', '어린이날 출근', '5/5 출근 (자조)', 1, ('day_children', tr('achieve.day_children.name'), tr('achieve.day_children.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'), _bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
('day_hangul', '한글날 출근', '10/9 출근', 1, ('day_hangul', tr('achieve.day_hangul.name'), tr('achieve.day_hangul.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'), _bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
('day_valentine', '발렌타인데이 출근', '2/14 출근', 1, ('day_valentine', tr('achieve.day_valentine.name'), tr('achieve.day_valentine.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'), _bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
('day_white', '화이트데이 출근', '3/14 출근', 1, ('day_white', tr('achieve.day_white.name'), tr('achieve.day_white.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'), _bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
('day_pepero', '빼빼로데이', '11/11 출근', 1, ('day_pepero', tr('achieve.day_pepero.name'), tr('achieve.day_pepero.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'), _bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
('day_halloween', '핼러윈 출근', '10/31 출근', 1, ('day_halloween', tr('achieve.day_halloween.name'), tr('achieve.day_halloween.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'), _bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
('day_aprilfools', '만우절 출근', '4/1 출근', 1, ('day_aprilfools', tr('achieve.day_aprilfools.name'), tr('achieve.day_aprilfools.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'), _bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
('day_77', '칠월칠석', '7/7 출근', 1, ('day_77', tr('achieve.day_77.name'), tr('achieve.day_77.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'), _bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
('day_dongji', '동지 출근', '12/22 출근', 1, ('day_dongji', tr('achieve.day_dongji.name'), tr('achieve.day_dongji.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'), _bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
('day_parents', '어버이날 정시 퇴근', '5/8 정시 퇴근', 1, ('day_parents', tr('achieve.day_parents.name'), tr('achieve.day_parents.desc'), 1,
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')), _bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
TIER_SILVER, '🪅'), TIER_SILVER, '🪅'),
('day_teacher', '스승의 날 정시 퇴근', '5/15 정시 퇴근', 1, ('day_teacher', tr('achieve.day_teacher.name'), tr('achieve.day_teacher.desc'), 1,
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')), _bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
TIER_BRONZE, '🎂'), TIER_BRONZE, '🎂'),
('day_xmas_eve', '크리스마스이브 정시 퇴근', '12/24 정시 퇴근', 1, ('day_xmas_eve', tr('achieve.day_xmas_eve.name'), tr('achieve.day_xmas_eve.desc'), 1,
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')), _bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
TIER_SILVER, '🎁'), TIER_SILVER, '🎁'),
('day_earth', '지구의 날', '4/22 출근 (시크릿)', 1, ('day_earth', tr('achieve.day_earth.name'), tr('achieve.day_earth.desc'), 1,
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'), _bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
] ]
@ -760,79 +761,79 @@ def _make_month_first_eval(month: int):
_SEASON_DEFS = [ _SEASON_DEFS = [
('season_jan', '1월 정착', '1월 한 달 출근', 1, ('season_jan', tr('achieve.season_jan.name'), tr('achieve.season_jan.desc'), 1,
_make_month_first_eval(1), TIER_BRONZE, ''), _make_month_first_eval(1), TIER_BRONZE, ''),
('season_feb', '2월 정착', '2월 영업일 모두 출근', 1, ('season_feb', tr('achieve.season_feb.name'), tr('achieve.season_feb.desc'), 1,
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'), _make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
('season_mar', '봄을 맞이', '3월 첫 출근', 1, ('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1,
_make_month_first_eval(3), TIER_BRONZE, '🌸'), _make_month_first_eval(3), TIER_BRONZE, '🌸'),
('season_apr', '4월 정착', '4월 한 달 출근', 1, ('season_apr', tr('achieve.season_apr.name'), tr('achieve.season_apr.desc'), 1,
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'), _make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
('season_may', '5월 정착', '5월 영업일 모두 출근', 1, ('season_may', tr('achieve.season_may.name'), tr('achieve.season_may.desc'), 1,
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'), _make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
('season_jun', '여름의 시작', '6월 첫 출근', 1, ('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1,
_make_month_first_eval(6), TIER_BRONZE, '☀️'), _make_month_first_eval(6), TIER_BRONZE, '☀️'),
('season_jul', '7월 정착', '7월 한 달 출근', 1, ('season_jul', tr('achieve.season_jul.name'), tr('achieve.season_jul.desc'), 1,
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'), _make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
('season_aug', '8월 정착', '8월 영업일 모두 출근', 1, ('season_aug', tr('achieve.season_aug.name'), tr('achieve.season_aug.desc'), 1,
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'), _make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
('season_sep', '가을의 시작', '9월 첫 출근', 1, ('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1,
_make_month_first_eval(9), TIER_BRONZE, '🍂'), _make_month_first_eval(9), TIER_BRONZE, '🍂'),
('season_oct', '10월 정착', '10월 한 달 출근', 1, ('season_oct', tr('achieve.season_oct.name'), tr('achieve.season_oct.desc'), 1,
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'), _make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
('season_nov', '11월 단풍', '11월 영업일 모두 출근', 1, ('season_nov', tr('achieve.season_nov.name'), tr('achieve.season_nov.desc'), 1,
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'), _make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
('season_dec', '겨울의 시작', '12월 첫 출근', 1, ('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1,
_make_month_first_eval(12), TIER_BRONZE, '❄️'), _make_month_first_eval(12), TIER_BRONZE, '❄️'),
] ]
# ---- 12. 앱 사용 마일스톤 ---- # ---- 12. 앱 사용 마일스톤 ----
_MILESTONE_DEFS = [ _MILESTONE_DEFS = [
('mile_first', 'Hello, World!', '앱 첫 실행', 1, ('mile_first', tr('achieve.mile_first.name'), tr('achieve.mile_first.desc'), 1,
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0), _bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
TIER_BRONZE, '👋'), TIER_BRONZE, '👋'),
('mile_7days', '일주일 사용', '7일 사용', 7, ('mile_7days', tr('achieve.mile_7days.name'), tr('achieve.mile_7days.desc'), 7,
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'), _make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
('mile_30days', '한 달 사용', '30일 사용', 30, ('mile_30days', tr('achieve.mile_30days.name'), tr('achieve.mile_30days.desc'), 30,
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'), _make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
('mile_365days', '1주년', '365일 사용', 365, ('mile_365days', tr('achieve.mile_365days.name'), tr('achieve.mile_365days.desc'), 365,
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'), _make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
('mile_730days', '2주년', '730일 사용', 730, ('mile_730days', tr('achieve.mile_730days.name'), tr('achieve.mile_730days.desc'), 730,
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'), _make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
('mile_1095days', '3주년', '3년 사용', 1095, ('mile_1095days', tr('achieve.mile_1095days.name'), tr('achieve.mile_1095days.desc'), 1095,
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'), _make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
('mile_5years', '5년 사용자', '5년 사용', 1825, ('mile_5years', tr('achieve.mile_5years.name'), tr('achieve.mile_5years.desc'), 1825,
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'), _make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
('mile_10years', '10년 사용자', '10년 사용', 3650, ('mile_10years', tr('achieve.mile_10years.name'), tr('achieve.mile_10years.desc'), 3650,
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'), _make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
] ]
# ---- 13. 통계·분석 (view counter 기반) ---- # ---- 13. 통계·분석 (view counter 기반) ----
_STATS_DEFS = [ _STATS_DEFS = [
('stat_weekly_10', '주간 통계러', '주간 탭 10회 조회', 10, ('stat_weekly_10', tr('achieve.stat_weekly_10.name'), tr('achieve.stat_weekly_10.desc'), 10,
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10), _make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
TIER_BRONZE, '📊'), TIER_BRONZE, '📊'),
('stat_monthly_10', '월간 통계러', '월간 탭 10회', 10, ('stat_monthly_10', tr('achieve.stat_monthly_10.name'), tr('achieve.stat_monthly_10.desc'), 10,
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10), _make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
TIER_BRONZE, '📈'), TIER_BRONZE, '📈'),
('stat_pattern_10', '패턴 분석가', '패턴 탭 10회', 10, ('stat_pattern_10', tr('achieve.stat_pattern_10.name'), tr('achieve.stat_pattern_10.desc'), 10,
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10), _make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
TIER_SILVER, '🔍'), TIER_SILVER, '🔍'),
('stat_calendar_30', '캘린더 챔프', '캘린더 30회 조회', 30, ('stat_calendar_30', tr('achieve.stat_calendar_30.name'), tr('achieve.stat_calendar_30.desc'), 30,
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30), _make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
TIER_SILVER, '📅'), TIER_SILVER, '📅'),
('stat_report_first', '일일 보고서 첫 생성', '일일 보고 1회', 1, ('stat_report_first', tr('achieve.stat_report_first.name'), tr('achieve.stat_report_first.desc'), 1,
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1), _make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
TIER_BRONZE, '📋'), TIER_BRONZE, '📋'),
('stat_report_30', '보고서 챔프', '일일 보고 30회', 30, ('stat_report_30', tr('achieve.stat_report_30.name'), tr('achieve.stat_report_30.desc'), 30,
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30), _make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
TIER_SILVER, '📰'), TIER_SILVER, '📰'),
('stat_chart_hover', '차트 호버 발견', '차트 hover 첫 발견', 1, ('stat_chart_hover', tr('achieve.stat_chart_hover.name'), tr('achieve.stat_chart_hover.desc'), 1,
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'), _bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
TIER_BRONZE, '🎨'), TIER_BRONZE, '🎨'),
('stat_achievements_open', '도전과제 박물관', '도전과제 뷰 50회', 50, ('stat_achievements_open', tr('achieve.stat_achievements_open.name'), tr('achieve.stat_achievements_open.desc'), 50,
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50), _make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
TIER_BRONZE, '🦄'), TIER_BRONZE, '🦄'),
] ]
@ -957,23 +958,23 @@ def _has_500_anniv_clockin(db) -> bool:
_SECRET_DEFS = [ _SECRET_DEFS = [
('secret_palindrome', '회문 시각', '출근 시각이 회문', 1, ('secret_palindrome', tr('achieve.secret_palindrome.name'), tr('achieve.secret_palindrome.desc'), 1,
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'), _bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
('secret_jackpot', '잭팟 시각', '출근 시각 모든 자릿수 동일', 1, ('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1,
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'), _bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
('secret_fri13', '13일 금요일', '13일 금요일 출근', 1, ('secret_fri13', tr('achieve.secret_fri13.name'), tr('achieve.secret_fri13.desc'), 1,
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'), _bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
('secret_777', '7-7-7', '7월 7일 7시 7분 출근', 1, ('secret_777', tr('achieve.secret_777.name'), tr('achieve.secret_777.desc'), 1,
_bool_eval(_has_777), TIER_LEGEND, '🔮'), _bool_eval(_has_777), TIER_LEGEND, '🔮'),
('secret_exact_8h', '정확 8시간', '정확히 8h 0m 근무', 1, ('secret_exact_8h', tr('achieve.secret_exact_8h.name'), tr('achieve.secret_exact_8h.desc'), 1,
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'), _bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
('secret_pi_day', '파이 데이', '3/14 01:59 출근', 1, ('secret_pi_day', tr('achieve.secret_pi_day.name'), tr('achieve.secret_pi_day.desc'), 1,
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'), _bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
('secret_fibonacci', '피보나치', '출근 분이 피보나치 수', 1, ('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1,
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'), _bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
('secret_double_six', '더블 식스', '6/6 18:06 출근', 1, ('secret_double_six', tr('achieve.secret_double_six.name'), tr('achieve.secret_double_six.desc'), 1,
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'), _bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
('secret_anniversary', '마법사', '가입 후 정확히 365일 후 출근', 1, ('secret_anniversary', tr('achieve.secret_anniversary.name'), tr('achieve.secret_anniversary.desc'), 1,
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'), _bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
] ]
@ -984,30 +985,30 @@ def _setting_changed_from_default(db, key: str, default_value: str) -> bool:
_SETTINGS_DEFS = [ _SETTINGS_DEFS = [
('set_dark', '다크 사이드', '다크 테마 1회 사용', 1, ('set_dark', tr('achieve.set_dark.name'), tr('achieve.set_dark.desc'), 1,
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')), _bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
TIER_BRONZE, '🌗'), TIER_BRONZE, '🌗'),
('set_lang', '이중언어', '언어 변경 (en 사용)', 1, ('set_lang', tr('achieve.set_lang.name'), tr('achieve.set_lang.desc'), 1,
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'), _bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
TIER_BRONZE, '🌐'), TIER_BRONZE, '🌐'),
('set_a11y', '접근성 활용', '글꼴 크기≠100% 또는 고대비 ON', 1, ('set_a11y', tr('achieve.set_a11y.name'), tr('achieve.set_a11y.desc'), 1,
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0' _bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
or db.get_setting('high_contrast', 'false').lower() == 'true'), or db.get_setting('high_contrast', 'false').lower() == 'true'),
TIER_BRONZE, ''), TIER_BRONZE, ''),
('set_overtime_unit', '단위 변경', 'overtime_unit 변경', 1, ('set_overtime_unit', tr('achieve.set_overtime_unit.name'), tr('achieve.set_overtime_unit.desc'), 1,
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'), _bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
TIER_BRONZE, '⏱️'), TIER_BRONZE, '⏱️'),
('set_goal_full', '목표 마스터', '월 연장+일평균 둘 다 설정', 1, ('set_goal_full', tr('achieve.set_goal_full.name'), tr('achieve.set_goal_full.desc'), 1,
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0 _bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0), and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
TIER_SILVER, '🎯'), TIER_SILVER, '🎯'),
('set_discord_full', '풀 셋업', 'Discord URL + 모든 알림 ON', 1, ('set_discord_full', tr('achieve.set_discord_full.name'), tr('achieve.set_discord_full.desc'), 1,
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '') _bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
and all(db.get_setting(k, 'true').lower() == 'true' for k in and all(db.get_setting(k, 'true').lower() == 'true' for k in
('notification_clock_out', 'notification_lunch', ('notification_clock_out', 'notification_lunch',
'notification_overtime', 'notification_health'))), 'notification_overtime', 'notification_health'))),
TIER_SILVER, '🔔'), TIER_SILVER, '🔔'),
('set_cloud', '클라우드 동기화', 'DB 경로 변경', 1, ('set_cloud', tr('achieve.set_cloud.name'), tr('achieve.set_cloud.desc'), 1,
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')), _bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
TIER_SILVER, '☁️'), TIER_SILVER, '☁️'),
] ]
@ -1032,21 +1033,21 @@ def _earned_secret_count(db) -> int:
_META_DEFS = [ _META_DEFS = [
('meta_first', '첫 도전과제', '첫 도전과제 획득', 1, ('meta_first', tr('achieve.meta_first.name'), tr('achieve.meta_first.desc'), 1,
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'), _make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
('meta_10', '10개 달성', '10개 보유', 10, ('meta_10', tr('achieve.meta_10.name'), tr('achieve.meta_10.desc'), 10,
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'), _make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
('meta_25', '25개 달성', '25개 보유', 25, ('meta_25', tr('achieve.meta_25.name'), tr('achieve.meta_25.desc'), 25,
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'), _make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
('meta_50', '50개 달성', '50개 보유', 50, ('meta_50', tr('achieve.meta_50.name'), tr('achieve.meta_50.desc'), 50,
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'), _make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
('meta_75', '75개 달성', '75개 보유', 75, ('meta_75', tr('achieve.meta_75.name'), tr('achieve.meta_75.desc'), 75,
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'), _make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
('meta_100', '100개 달성', '100개 보유', 100, ('meta_100', tr('achieve.meta_100.name'), tr('achieve.meta_100.desc'), 100,
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'), _make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
('meta_secret_1', '시크릿 발견', '첫 시크릿 발견', 1, ('meta_secret_1', tr('achieve.meta_secret_1.name'), tr('achieve.meta_secret_1.desc'), 1,
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'), _make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
('meta_secret_5', '시크릿 헌터', '시크릿 5개 발견', 5, ('meta_secret_5', tr('achieve.meta_secret_5.name'), tr('achieve.meta_secret_5.desc'), 5,
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'), _make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
] ]

View File

@ -12,6 +12,7 @@ from core.settings_keys import (
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS, WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
) )
from utils.debug_log import dlog
class Database: class Database:
@ -53,8 +54,8 @@ class Database:
finally: finally:
try: try:
conn.close() conn.close()
except Exception: except Exception as e:
pass dlog(f"connection close failed: {e}")
def _enable_concurrency(self): def _enable_concurrency(self):
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화.""" """WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
@ -89,10 +90,55 @@ class Database:
self.migrate_v271_work_records_indexes() self.migrate_v271_work_records_indexes()
self.migrate_v280_achievements_columns() self.migrate_v280_achievements_columns()
self.migrate_v280_hire_date() self.migrate_v280_hire_date()
self.migrate_v290_holidays_auto_sync()
# 기본 설정 초기화 # 기본 설정 초기화
self.init_default_settings() self.init_default_settings()
def migrate_v290_holidays_auto_sync(self) -> None:
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
값이 오늘과 같으면 스킵 같은 여러 켜도 호출 1.
매일 호출하므로 정부가 임시공휴일 발표하면 다음 자동 반영.
일일 한도 10000, 사용자 50 × 1 = 0.5% 소비.
실제 동기화는 백그라운드 스레드에서 부트스트랩이 네트워크에 묶이지 않음.
실패는 silent, 다음 실행 재시도.
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 비활성화.
"""
import os
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
return
from datetime import datetime as _dt
import threading
try:
today = _dt.now().date().isoformat()
sentinel = self.get_setting('holidays_synced_date', '')
if sentinel == today:
return
except Exception as e:
dlog(f"holiday sync precheck failed: {e}")
return
cur_year = _dt.now().year
def _worker():
try:
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
from core.database import Database
db = Database(self.db_path)
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
if added >= 0:
db.set_setting('holidays_synced_date', today)
except Exception as e:
dlog(f"holiday sync worker failed: {e}")
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
t.start()
def _create_tables(self, conn) -> None: def _create_tables(self, conn) -> None:
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리.""" """init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
cursor = conn.cursor() cursor = conn.cursor()
@ -726,7 +772,7 @@ class Database:
'dinner_duration_minutes': '60', 'dinner_duration_minutes': '60',
'auto_lunch': 'false', 'auto_lunch': 'false',
'auto_overtime': 'true', 'auto_overtime': 'true',
'theme': 'light', 'theme': 'dark',
'notification_before_minutes': '30', 'notification_before_minutes': '30',
'notification_clock_out': 'true', 'notification_clock_out': 'true',
'notification_lunch': 'true', 'notification_lunch': 'true',
@ -933,6 +979,19 @@ class Database:
''', (work_record_id, earned_minutes, date)) ''', (work_record_id, earned_minutes, date))
conn.commit() conn.commit()
def delete_overtime_earned(self, bank_id: int) -> bool:
"""연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소.
Returns:
bool: 실제로 삭제된 행이 있으면 True.
"""
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,))
deleted = cursor.rowcount
conn.commit()
return deleted > 0
def add_overtime_usage(self, work_record_id: int, used_minutes: int, def add_overtime_usage(self, work_record_id: int, used_minutes: int,
date: str, reason: str = None): date: str, reason: str = None):
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
@ -1013,7 +1072,8 @@ class Database:
target = _dt.strptime(date_str, '%Y-%m-%d').date() target = _dt.strptime(date_str, '%Y-%m-%d').date()
recs = self.get_recurring_leaves(active_on=date_str) recs = self.get_recurring_leaves(active_on=date_str)
recurring_days = sum(o.days for o in expand_for_date(recs, target)) recurring_days = sum(o.days for o in expand_for_date(recs, target))
except Exception: except Exception as e:
dlog(f"recurring leave expansion failed: {e}")
recurring_days = 0.0 recurring_days = 0.0
return concrete_days + recurring_days return concrete_days + recurring_days
@ -1299,17 +1359,21 @@ class Database:
for row in rows: for row in rows:
key = row['key'] key = row['key']
value = row['value'] value = row['value']
# 타입 변환 # 타입 변환 (bool 우선, 숫자, 문자열 순)
if value.lower() in ['true', 'false']: lower = (value or '').lower()
settings[key] = value.lower() == 'true' if lower in ('1', 'true', 'yes', 'on'):
else: settings[key] = True
continue
if lower in ('0', 'false', 'no', 'off', ''):
settings[key] = False
continue
try:
settings[key] = int(value)
except ValueError:
try: try:
settings[key] = int(value) settings[key] = float(value)
except ValueError: except ValueError:
try: settings[key] = value
settings[key] = float(value)
except ValueError:
settings[key] = value
return settings return settings
def save_settings(self, settings: Dict): def save_settings(self, settings: Dict):
@ -1331,7 +1395,8 @@ class Database:
pass pass
elif 'work_hours' in synced and 'work_minutes' not in synced: elif 'work_hours' in synced and 'work_minutes' not in synced:
try: try:
synced['work_minutes'] = int(round(float(synced['work_hours']) * 60)) # 은행 반올림 회피: 명시적 반올림
synced['work_minutes'] = int(float(synced['work_hours']) * 60 + 0.5)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@ -1617,7 +1682,8 @@ class Database:
''', (date, leave_type, days, memo)) ''', (date, leave_type, days, memo))
conn.commit() conn.commit()
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit) # 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
self.set_leave_balance(current_balance - days) # get_leave_balance()가 leave_records를 실시간으로 계산하므로
# 별도로 leave_balance 설정값을 갱신할 필요가 없음.
# ===== 공휴일 관련 메서드 ===== # ===== 공휴일 관련 메서드 =====
@ -1713,6 +1779,32 @@ class Database:
for date, name in fixed_holidays: for date, name in fixed_holidays:
self.add_holiday(date, name, is_recurring=True) self.add_holiday(date, name, is_recurring=True)
def add_korean_holidays_from_api(self, year: int) -> int:
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
임시공휴일 + 근로자의 holidays 패키지가 놓치는 항목까지 포함.
네트워크 실패 -1 반환 호출자 fallback.
Returns:
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 -1.
"""
try:
from utils.holiday_api import fetch_korean_holidays
except ImportError:
return -1
items = fetch_korean_holidays(year)
if items is None:
return -1
added = 0
for it in items:
if not it.get('is_holiday'):
continue
date_str = it['date']
if not self.is_holiday(date_str):
self.add_holiday(date_str, it['name'], is_recurring=False)
added += 1
return added
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int: def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
@ -1738,30 +1830,33 @@ class Database:
Returns: Returns:
추가된 공휴일 개수. 패키지 미설치 -1. 추가된 공휴일 개수. 패키지 미설치 -1.
""" """
try:
import holidays as _holidays
except ImportError:
return -1
years_to_add = [year] years_to_add = [year]
if include_next_year: if include_next_year:
years_to_add.append(year + 1) years_to_add.append(year + 1)
added = 0 added = 0
for y in years_to_add: for y in years_to_add:
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
api_count = self.add_korean_holidays_from_api(y)
if api_count >= 0:
added += api_count
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
continue
# 2차 fallback: holidays 패키지
try: try:
import holidays as _holidays
kr = _holidays.country_holidays('KR', years=y) kr = _holidays.country_holidays('KR', years=y)
except Exception: except Exception as e:
continue # 패키지 내부 오류는 해당 연도만 스킵 dlog(f"holidays package fallback failed for {y}: {e}")
continue # 둘 다 실패면 해당 연도만 스킵
for d, name in kr.items(): for d, name in kr.items():
date_str = d.isoformat() date_str = d.isoformat()
if not self.is_holiday(date_str): if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=False) self.add_holiday(date_str, name, is_recurring=False)
added += 1 added += 1
# holidays.KR이 누락하는 한국 노동자 휴일 보강. # holidays.KR이 누락하는 근로자의 날 명시적 보강
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
extra = [(f"{y}-05-01", "근로자의 날")] extra = [(f"{y}-05-01", "근로자의 날")]
for date_str, name in extra: for date_str, name in extra:
if not self.is_holiday(date_str): if not self.is_holiday(date_str):

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,8 @@ from dataclasses import dataclass
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import List, Dict, Optional from typing import List, Dict, Optional
from core.i18n import tr
_WEEKDAY_MAP = { _WEEKDAY_MAP = {
'mon': 0, 'monday': 0, 'mon': 0, 'monday': 0,
@ -25,6 +27,7 @@ _WEEKDAY_MAP = {
'sat': 5, 'saturday': 5, 'sat': 5, 'saturday': 5,
'sun': 6, 'sunday': 6, 'sun': 6, 'sunday': 6,
} }
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
@dataclass @dataclass
@ -135,19 +138,16 @@ def _parse_date(s: Optional[str]) -> Optional[date]:
return None return None
_KO_WEEKDAY_NAMES = ['', '', '', '', '', '', '']
def describe_pattern(pattern: str) -> str: def describe_pattern(pattern: str) -> str:
"""사용자에게 보여줄 패턴 설명. ko.""" """사용자에게 보여줄 패턴 설명."""
parsed = _parse_pattern(pattern) parsed = _parse_pattern(pattern)
if parsed is None: if parsed is None:
return pattern return pattern
kind, info = parsed kind, info = parsed
if kind in ('weekly', 'biweekly'): if kind in ('weekly', 'biweekly'):
names = [_KO_WEEKDAY_NAMES[w] for w in info] names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info]
prefix = '매주' if kind == 'weekly' else '격주' prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly')
return f"{prefix} {','.join(names)}요일" return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names))
if kind == 'monthly': if kind == 'monthly':
return f"매월 {info}" return tr('recurring.pattern_monthly', day=info)
return pattern return pattern

View File

@ -21,7 +21,8 @@ class TimeCalculator:
if work_minutes is not None: if work_minutes is not None:
self.work_minutes = int(work_minutes) self.work_minutes = int(work_minutes)
elif work_hours is not None: elif work_hours is not None:
self.work_minutes = int(round(float(work_hours) * 60)) # 은행 반올림(banker's rounding) 회피: 6.5시간 → 390분이 되도록 명시적 반올림
self.work_minutes = int(float(work_hours) * 60 + 0.5)
else: else:
self.work_minutes = 480 self.work_minutes = 480

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push. 릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 . CHANGELOG.md의 최상단 항목과 일치시킬 .
""" """
__version__ = '2.9.0' __version__ = '2.12.0'

BIN
font/NanumSquareB.otf Normal file

Binary file not shown.

BIN
font/NanumSquareB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareEB.otf Normal file

Binary file not shown.

BIN
font/NanumSquareEB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareL.otf Normal file

Binary file not shown.

BIN
font/NanumSquareL.ttf Normal file

Binary file not shown.

BIN
font/NanumSquareOTF_acB.otf Normal file

Binary file not shown.

Binary file not shown.

BIN
font/NanumSquareOTF_acL.otf Normal file

Binary file not shown.

BIN
font/NanumSquareOTF_acR.otf Normal file

Binary file not shown.

BIN
font/NanumSquareR.otf Normal file

Binary file not shown.

BIN
font/NanumSquareR.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acEB.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acL.ttf Normal file

Binary file not shown.

BIN
font/NanumSquare_acR.ttf Normal file

Binary file not shown.

View File

@ -96,8 +96,9 @@ def main():
) )
return 1 return 1
# 폰트 설정 # 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
app.setFont(QFont("Segoe UI", 9)) from utils.font_loader import apply_app_font
apply_app_font(app, 9)
# 필수 패키지 확인 # 필수 패키지 확인
if not check_requirements(): if not check_requirements():

View File

@ -14,20 +14,35 @@ if os.path.exists(_staged):
elif os.path.exists(_fallback): elif os.path.exists(_fallback):
_extra_datas.append((_fallback, '.')) _extra_datas.append((_fallback, '.'))
# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
_font_files = [
'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
]
_font_datas = [
(os.path.join('font', f), 'font')
for f in _font_files if os.path.exists(os.path.join('font', f))
]
a = Analysis( a = Analysis(
['main.py'], ['main.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[('3d-alarm.png', '.')] + _extra_datas, datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
hiddenimports=[ hiddenimports=[
'holidays', 'holidays.countries.south_korea', 'holidays', 'holidays.countries.south_korea',
'win32evtlog', 'win32evtlogutil', 'win32evtlog', 'win32evtlogutil',
'matplotlib.backends.backend_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
'matplotlib.backends.backend_qt5agg', 'matplotlib.backends.backend_qt5agg',
'PyQt5.QtSvg',
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
], ],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'], # numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
noarchive=False, noarchive=False,
optimize=0, optimize=0,
) )

View File

@ -86,12 +86,12 @@ OkMsg "All checks passed (Version: $Version)"
# ====== 1. Bump version.py ====== # ====== 1. Bump version.py ======
Step "1/7 Bump core/version.py" Step "1/7 Bump core/version.py"
$verFile = 'core/version.py' $verFile = 'core/version.py'
$verContent = Get-Content $verFile -Raw $verContent = [System.IO.File]::ReadAllText((Resolve-Path $verFile).Path, [System.Text.Encoding]::UTF8)
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'" $newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
if ($verContent -eq $newContent) { if ($verContent -eq $newContent) {
Info "Already at $VersionRaw (no change)" Info "Already at $VersionRaw (no change)"
} else { } else {
if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline } if (-not $DryRun) { [System.IO.File]::WriteAllText((Resolve-Path $verFile).Path, $newContent, [System.Text.Encoding]::UTF8) }
OkMsg "$verFile -> $VersionRaw" OkMsg "$verFile -> $VersionRaw"
} }

9
tests/conftest.py Normal file
View File

@ -0,0 +1,9 @@
"""
pytest 공통 설정.
모든 테스트는 백그라운드 휴일 동기화를 Database 생성 spawn되는
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
"""
import os
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'

View File

@ -4,13 +4,15 @@ utils.csv_importer 단위 테스트.
import os import os
import sys import sys
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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 from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records
from core.database import Database
class TestNormalizeTime: class TestNormalizeTime:
@ -125,3 +127,54 @@ class TestParseCsv:
assert '줄 3' in str(exc.value) assert '줄 3' in str(exc.value)
finally: finally:
os.remove(path) os.remove(path)
class TestImportRecords:
def _db(self):
p = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
p.close()
db = Database(p.name)
db.save_settings({
'work_minutes': '480',
'lunch_duration_minutes': '60',
'dinner_duration_minutes': '60',
})
return db, p.name
def test_overwrite_clears_overtime_usage(self):
"""CSV 덮어쓰기 시 overtime_usage도 삭제되어 잔액이 일관성을 유지해야 함."""
db, path = self._db()
try:
date_str = '2026-04-01'
# 기존 기록 + 연장근무 사용 기록 생성
wid = db.add_work_record(date_str, '09:00:00')
db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120)
db.add_overtime_earned(wid, 120, date_str)
db.add_overtime_usage(wid, 30, date_str, '테스트')
# 덮어쓰기 전 잔액
balance_before = db.get_total_overtime_balance()
assert balance_before == 90 # 120 적립 - 30 사용
rows = [{
'date': date_str,
'clock_in': '09:00:00',
'clock_out': '18:00:00',
'lunch_minutes': 60,
'dinner_minutes': 0,
'memo': '',
}]
import_records(db, rows, on_conflict='overwrite')
# 덮어쓰기 후 연장근무 사용 기록은 삭제되어야 함
with db._conn() as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,))
assert cur.fetchone()[0] == 0
balance_after = db.get_total_overtime_balance()
assert balance_after == 0 # 새 기록은 연장근무 없음
finally:
try:
os.remove(path)
except OSError:
pass

173
tests/test_holiday_api.py Normal file
View File

@ -0,0 +1,173 @@
"""
utils.holiday_api 단위 테스트.
실제 정부 API는 호출하지 않음 모두 urlopen mock.
"""
import json
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.holiday_api import (
fetch_korean_holidays, _parse_response, is_configured,
)
def _ok_response(items):
"""API 정상 응답 형식 빌드."""
return {
'response': {
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
'body': {
'items': {'item': items} if items else {'item': []},
'numOfRows': 100,
'pageNo': 1,
'totalCount': len(items) if isinstance(items, list) else 1,
},
}
}
class TestParseResponse:
def test_multiple_items(self):
items = [
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
'locdate': 20260501, 'seq': 1},
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
'locdate': 20260505, 'seq': 1},
]
out = _parse_response(_ok_response(items))
assert len(out) == 2
assert out[0]['date'] == '2026-05-01'
assert out[0]['name'] == '근로자의 날'
assert out[0]['is_holiday'] is True
assert out[1]['date'] == '2026-05-05'
def test_single_item_as_dict(self):
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': {'item': item}, 'totalCount': 1},
}
}
out = _parse_response(data)
assert len(out) == 1
assert out[0]['name'] == '근로자의 날'
def test_empty_year(self):
# totalCount=0 같은 정상 빈 응답
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': '', 'totalCount': 0},
}
}
assert _parse_response(data) == []
def test_error_result_code(self):
data = {
'response': {
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
'body': {},
}
}
assert _parse_response(data) is None
def test_isholiday_n_filtered_at_caller_level(self):
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
out = _parse_response(_ok_response(items))
assert len(out) == 1
assert out[0]['is_holiday'] is False
def test_locdate_str_form(self):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
out = _parse_response(_ok_response(items))
assert out[0]['date'] == '2026-05-01'
def test_invalid_locdate_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
]
out = _parse_response(_ok_response(items))
assert len(out) == 1 # 정상 1개만
assert out[0]['name'] == '근로자의 날'
def test_missing_required_fields_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
]
out = _parse_response(_ok_response(items))
assert len(out) == 1
def test_malformed_response_returns_none(self):
# response 구조 자체가 깨진 경우
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
# 위는 implementation-dependent — 둘 다 합리적
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
assert _parse_response({}) is None
class TestFetchNetwork:
@patch('utils.holiday_api.urllib.request.urlopen')
def test_success(self, mock_urlopen):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
resp = MagicMock()
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
out = fetch_korean_holidays(2026)
assert out is not None
assert len(out) == 1
assert out[0]['date'] == '2026-05-01'
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
req = mock_urlopen.call_args[0][0]
assert 'serviceKey=' in req.full_url
assert 'solYear=2026' in req.full_url
assert '_type=json' in req.full_url
@patch('utils.holiday_api.urllib.request.urlopen')
def test_network_error_returns_none(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_timeout_returns_none(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_invalid_json_returns_none(self, mock_urlopen):
resp = MagicMock()
resp.read.return_value = b'<html>error</html>'
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert fetch_korean_holidays(2026) is None
class TestConfigured:
def test_key_set(self, monkeypatch):
import utils.holiday_api as _ha
monkeypatch.setattr(_ha, '_SERVICE_KEY', 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93')
assert _ha.is_configured() is True
def test_key_empty(self, monkeypatch):
import utils.holiday_api as _ha
monkeypatch.setattr(_ha, '_SERVICE_KEY', '')
assert _ha.is_configured() is False

View File

@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
set_language('ko') set_language('ko')
label = QLabel() label = QLabel()
i18n.register(label, 'btn.save') i18n.register(label, 'btn.save')
assert label.text() == '💾 저장' assert label.text() == '저장'
def test_retranslate_after_language_change(qapp, i18n): def test_retranslate_after_language_change(qapp, i18n):
@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
set_language('ko') set_language('ko')
dlg = QDialog() dlg = QDialog()
i18n.register(dlg, 'window.settings', setter='setWindowTitle') i18n.register(dlg, 'window.settings', setter='setWindowTitle')
assert dlg.windowTitle() == '⚙️ 설정' assert dlg.windowTitle() == '설정'
i18n.set_language_and_retranslate('en') i18n.set_language_and_retranslate('en')
assert dlg.windowTitle() == '⚙️ Settings' assert dlg.windowTitle() == 'Settings'
def test_post_callback_applied(qapp, i18n): def test_post_callback_applied(qapp, i18n):
@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
set_language('ko') set_language('ko')
label = QLabel() label = QLabel()
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]") i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
assert label.text() == '[💾 저장]' assert label.text() == '[저장]'
i18n.set_language_and_retranslate('en') i18n.set_language_and_retranslate('en')
assert label.text() == '[💾 Save]' assert label.text() == '[Save]'
def test_dead_widget_pruned(qapp, i18n): def test_dead_widget_pruned(qapp, i18n):

View File

@ -0,0 +1,72 @@
"""연장근무 자동 적립 가드 테스트.
auto_overtime(자동 적립) OFF면, 자동 퇴근 경로(근무일 경계 롤오버 )에서도
은행 적립을 하지 않아야 한다 clock_out() 대화상자에서 '아니오' 고른 것과 동일한 의미.
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
"""
import os
import sys
from datetime import datetime, timedelta, time as dtime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from core.time_calculator import TimeCalculator
from ui.main_window import MainWindow
def _rollover_balance(db, monkeypatch):
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
from PyQt5.QtWidgets import QMessageBox
monkeypatch.setattr(QMessageBox, 'information',
staticmethod(lambda *a, **k: QMessageBox.Ok))
today = datetime.now().date()
y = today - timedelta(days=1)
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
w.db = db
w.time_calc = TimeCalculator(work_minutes=480)
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
w.is_clocked_in = True
w.midnight_rollover_handled = False
w.is_on_break = False
w.lunch_break_enabled = False
w.dinner_break_enabled = False
w.load_today_data = lambda: None # tail UI refresh stub
w.update_overtime_balance = lambda: None # tail UI refresh stub
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
return db.get_total_overtime_balance()
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
db = Database(str(tmp_path / 'off.db'))
db.set_setting('auto_overtime', 'false')
assert _rollover_balance(db, monkeypatch) == 0
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
db = Database(str(tmp_path / 'on.db'))
db.set_setting('auto_overtime', 'true')
assert _rollover_balance(db, monkeypatch) > 0
def test_delete_overtime_earned_reduces_balance(tmp_path):
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
from datetime import date
db = Database(str(tmp_path / 'del.db'))
today = date.today().isoformat()
db.add_overtime_earned(None, 90, today)
assert db.get_total_overtime_balance() == 90
bank_id = db.get_connection().execute(
'SELECT id FROM overtime_bank').fetchone()[0]
assert db.delete_overtime_earned(bank_id) is True
assert db.get_total_overtime_balance() == 0
# 없는 id 삭제는 False
assert db.delete_overtime_earned(999999) is False

View File

@ -16,7 +16,9 @@ from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont from PyQt5.QtGui import QFont
from core.achievements import get_all_with_status, get_stats from core.achievements import get_all_with_status, get_stats
from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조) # 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
@ -28,7 +30,6 @@ TIER_THEMES = {
'bg_bot': '#241810', 'bg_bot': '#241810',
'text': '#ffd9a8', 'text': '#ffd9a8',
'label': '🥉', 'label': '🥉',
'name': '브론즈',
}, },
'silver': { 'silver': {
'border': '#a8a8a8', 'border': '#a8a8a8',
@ -37,7 +38,6 @@ TIER_THEMES = {
'bg_bot': '#1c1c22', 'bg_bot': '#1c1c22',
'text': '#e8e8f0', 'text': '#e8e8f0',
'label': '🥈', 'label': '🥈',
'name': '실버',
}, },
'gold': { 'gold': {
'border': '#ffb700', 'border': '#ffb700',
@ -46,7 +46,6 @@ TIER_THEMES = {
'bg_bot': '#241c08', 'bg_bot': '#241c08',
'text': '#ffe9a0', 'text': '#ffe9a0',
'label': '🥇', 'label': '🥇',
'name': '골드',
}, },
'platinum': { 'platinum': {
'border': '#7fdbff', 'border': '#7fdbff',
@ -55,7 +54,6 @@ TIER_THEMES = {
'bg_bot': '#0e1f28', 'bg_bot': '#0e1f28',
'text': '#c5ecff', 'text': '#c5ecff',
'label': '💎', 'label': '💎',
'name': '플래티넘',
}, },
'legend': { 'legend': {
'border': '#ff6b9d', 'border': '#ff6b9d',
@ -64,20 +62,9 @@ TIER_THEMES = {
'bg_bot': '#26101a', 'bg_bot': '#26101a',
'text': '#ffc0d4', 'text': '#ffc0d4',
'label': '🌟', 'label': '🌟',
'name': '레전드',
}, },
} }
CATEGORY_LABELS = {
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
'meal': '식사', 'break_use': '외출', 'settings': '설정',
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
'ambition': '야망', 'meta': '메타',
}
class AchievementsView(QDialog): class AchievementsView(QDialog):
"""도전과제 다이얼로그 — 4탭 + 통계 헤더.""" """도전과제 다이얼로그 — 4탭 + 통계 헤더."""
@ -85,13 +72,13 @@ class AchievementsView(QDialog):
def __init__(self, db, parent=None): def __init__(self, db, parent=None):
super().__init__(parent) super().__init__(parent)
self.db = db self.db = db
self.setWindowTitle("🏆 도전과제") self.setWindowTitle(tr('achieve.title'))
self.setMinimumSize(960, 720) self.setMinimumSize(960, 720)
self.resize(1100, 800) self.resize(1100, 800)
self._increment_view_count() self._increment_view_count()
self.setStyleSheet("QDialog { background: #0e0e14; }") self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
self.init_ui() self.init_ui()
apply_dark_titlebar(self, dark=True) apply_dark_titlebar(self) # 현재 테마에 맞춰
def _increment_view_count(self) -> None: def _increment_view_count(self) -> None:
try: try:
@ -120,30 +107,23 @@ class AchievementsView(QDialog):
if a['earned_date'] is None and not a['is_secret']] if a['earned_date'] is None and not a['is_secret']]
secret_items = [a for a in all_items if a['is_secret']] secret_items = [a for a in all_items if a['is_secret']]
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}") self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
self.tabs.addTab(self._build_grid_tab(in_progress), self.tabs.addTab(self._build_grid_tab(in_progress),
f"⚡ 진행 중 · {len(in_progress)}") tr('achieve.tab_in_progress', count=len(in_progress)))
self.tabs.addTab(self._build_grid_tab(earned_items), self.tabs.addTab(self._build_grid_tab(earned_items),
f"✓ 완료 · {len(earned_items)}") tr('achieve.tab_completed', count=len(earned_items)))
self.tabs.addTab( self.tabs.addTab(
self._build_grid_tab(secret_items, secret_mode=True), self._build_grid_tab(secret_items, secret_mode=True),
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}" tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total'])
) )
layout.addWidget(self.tabs, 1) layout.addWidget(self.tabs, 1)
# === 닫기 버튼 === # === 닫기 버튼 ===
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
close_btn = QPushButton("닫기") close_btn = QPushButton(tr('btn.close'))
close_btn.setMinimumWidth(100) close_btn.setMinimumWidth(100)
close_btn.setStyleSheet(""" close_btn.setStyleSheet(button_qss('default'))
QPushButton {
background: #2a2a36; color: #e0e0e8;
border: 1px solid #44446a; border-radius: 6px;
padding: 8px 20px; font-size: 10pt;
}
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
""")
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn) btn_row.addWidget(close_btn)
layout.addLayout(btn_row) layout.addLayout(btn_row)
@ -153,14 +133,13 @@ class AchievementsView(QDialog):
# ----- 헤더 ----- # ----- 헤더 -----
def _build_header(self, stats: dict) -> QWidget: def _build_header(self, stats: dict) -> QWidget:
container = QFrame() container = QFrame()
container.setStyleSheet(""" container.setStyleSheet(f"""
QFrame { QFrame {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, background: {tc('panel')};
stop:0 #1a1a30, stop:1 #2a1a3a); border: 1px solid {tc('border')};
border: 1px solid #3a3a5a;
border-radius: 12px; border-radius: 12px;
} }}
QLabel { background: transparent; border: none; color: #e8e8f4; } QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
""") """)
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 16, 20, 16)
@ -172,22 +151,28 @@ class AchievementsView(QDialog):
num_row = QHBoxLayout() num_row = QHBoxLayout()
num_row.setSpacing(24) num_row.setSpacing(24)
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>" # 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>") if _is_dark():
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
else:
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
big.setTextFormat(Qt.RichText) big.setTextFormat(Qt.RichText)
num_row.addWidget(big) num_row.addWidget(big)
spacer = QFrame() spacer = QFrame()
spacer.setFrameShape(QFrame.VLine) spacer.setFrameShape(QFrame.VLine)
spacer.setStyleSheet("color: #3a3a5a;") spacer.setStyleSheet(f"color: {tc('border')};")
num_row.addWidget(spacer) num_row.addWidget(spacer)
secret_lbl = QLabel( secret_lbl = QLabel(
f"<div style='line-height: 1.3;'>" f"<div style='line-height: 1.3;'>"
f"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>" f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>" f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
f"{stats['secret_earned']}</span>" f"{stats['secret_earned']}</span>"
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>" f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
f"</div>" f"</div>"
) )
secret_lbl.setTextFormat(Qt.RichText) secret_lbl.setTextFormat(Qt.RichText)
@ -197,8 +182,8 @@ class AchievementsView(QDialog):
pct_lbl = QLabel( pct_lbl = QLabel(
f"<div style='text-align: right; line-height: 1.3;'>" f"<div style='text-align: right; line-height: 1.3;'>"
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>" f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>" f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
f"{pct:.1f}%</span></div>" f"{pct:.1f}%</span></div>"
) )
pct_lbl.setTextFormat(Qt.RichText) pct_lbl.setTextFormat(Qt.RichText)
@ -214,17 +199,17 @@ class AchievementsView(QDialog):
bar.setTextVisible(False) bar.setTextVisible(False)
bar.setMinimumHeight(8) bar.setMinimumHeight(8)
bar.setMaximumHeight(8) bar.setMaximumHeight(8)
bar.setStyleSheet(""" bar.setStyleSheet(f"""
QProgressBar { QProgressBar {{
background: #1a1a26; background: {tc('panel2')};
border: none; border: none;
border-radius: 4px; border-radius: 4px;
} }}
QProgressBar::chunk { QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8); stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
border-radius: 4px; border-radius: 4px;
} }}
""") """)
layout.addWidget(bar) layout.addWidget(bar)
@ -235,17 +220,7 @@ class AchievementsView(QDialog):
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget: def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
scroll.setStyleSheet(""" scroll.setStyleSheet(scroll_qss())
QScrollArea { background: transparent; border: none; }
QScrollBar:vertical {
background: #1a1a24; width: 10px; border-radius: 5px;
}
QScrollBar::handle:vertical {
background: #44446a; border-radius: 5px; min-height: 30px;
}
QScrollBar::handle:vertical:hover { background: #6b9eff; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
""")
container = QWidget() container = QWidget()
container.setStyleSheet("background: transparent;") container.setStyleSheet("background: transparent;")
grid = QGridLayout() grid = QGridLayout()
@ -253,10 +228,10 @@ class AchievementsView(QDialog):
grid.setContentsMargins(8, 8, 8, 8) grid.setContentsMargins(8, 8, 8, 8)
if not items: if not items:
empty = QLabel("(아직 없음)") empty = QLabel(tr('achieve.empty'))
empty.setAlignment(Qt.AlignCenter) empty.setAlignment(Qt.AlignCenter)
empty.setStyleSheet( empty.setStyleSheet(
"color: #666; padding: 60px; font-size: 12pt; background: transparent;" f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
) )
grid.addWidget(empty, 0, 0) grid.addWidget(empty, 0, 0)
else: else:
@ -279,11 +254,18 @@ class AchievementsView(QDialog):
tier = item['tier'] or 'bronze' tier = item['tier'] or 'bronze'
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze']) theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
# 시크릿 미발견은 회색 톤으로 # 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
light = not _is_dark()
if is_locked_secret: if is_locked_secret:
bg_top, bg_bot = '#1a1a26', '#0e0e16' if light:
border = '#3a3a4a' bg_top = bg_bot = tc('panel'); border = tc('border')
text_color = '#666' else:
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
text_color = tc('text_faint')
elif light:
bg_top = bg_bot = tc('panel')
border = theme['border_strong'] if is_earned else theme['border']
text_color = tc('text') if is_earned else tc('text_dim')
else: else:
bg_top = theme['bg_top'] bg_top = theme['bg_top']
bg_bot = theme['bg_bot'] bg_bot = theme['bg_bot']
@ -342,19 +324,19 @@ class AchievementsView(QDialog):
name = QLabel(name_text) name = QLabel(name_text)
name.setStyleSheet( name.setStyleSheet(
f"font-size: 12pt; font-weight: bold; " f"font-size: 12pt; font-weight: bold; "
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; " f"color: {tc('text') if is_earned else tc('text_dim')}; "
f"background: transparent; border: none;" f"background: transparent; border: none;"
) )
name.setWordWrap(True) name.setWordWrap(True)
name_box.addWidget(name) name_box.addWidget(name)
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '') cat_text = tr(f"achieve.cat_{item['category']}")
if not is_locked_secret: if not is_locked_secret:
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ") cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} · {cat_text} ")
cat_label.setStyleSheet( cat_label.setStyleSheet(
f"font-size: 8.5pt; " f"font-size: 8.5pt; "
f"color: {theme['border_strong']}; " f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
f"background: rgba(255,255,255,0.05); " f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
f"border: 1px solid {theme['border']}; " f"border: 1px solid {theme['border']}; "
f"border-radius: 8px; " f"border-radius: 8px; "
f"padding: 1px 4px;" f"padding: 1px 4px;"
@ -372,24 +354,24 @@ class AchievementsView(QDialog):
# 2행: 설명 # 2행: 설명
if is_locked_secret: if is_locked_secret:
desc_text = "🔒 달성하면 공개됩니다" desc_text = tr('achieve.secret_locked')
else: else:
desc_text = item['description'] or '' desc_text = item['description'] or ''
desc = QLabel(desc_text) desc = QLabel(desc_text)
desc.setWordWrap(True) desc.setWordWrap(True)
desc.setStyleSheet( desc.setStyleSheet(
f"color: #a0a0b8; font-size: 9.5pt; " f"color: {tc('text_dim')}; font-size: 9.5pt; "
f"background: transparent; border: none; padding: 0;" f"background: transparent; border: none; padding: 0;"
) )
outer.addWidget(desc) outer.addWidget(desc)
# 3행: 진행 게이지 또는 획득 일자 # 3행: 진행 게이지 또는 획득 일자
if is_earned: if is_earned:
earned = QLabel(f"{item['earned_date']} 달성 ") earned = QLabel(tr('achieve.earned_date', date=item['earned_date']))
earned.setStyleSheet( earned.setStyleSheet(
f"color: {theme['border_strong']}; " f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
f"font-weight: bold; font-size: 9.5pt; " f"font-weight: bold; font-size: 9.5pt; "
f"background: rgba(255,255,255,0.08); " f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
f"border: 1px solid {theme['border']}; " f"border: 1px solid {theme['border']}; "
f"border-radius: 6px; padding: 4px 8px;" f"border-radius: 6px; padding: 4px 8px;"
) )
@ -415,7 +397,7 @@ class AchievementsView(QDialog):
pb.setMaximumHeight(10) pb.setMaximumHeight(10)
pb.setStyleSheet(f""" pb.setStyleSheet(f"""
QProgressBar {{ QProgressBar {{
background: rgba(0,0,0,0.4); background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
border: none; border: none;
border-radius: 5px; border-radius: 5px;
}} }}
@ -429,7 +411,7 @@ class AchievementsView(QDialog):
num = QLabel(f"{progress} / {target}") num = QLabel(f"{progress} / {target}")
num.setStyleSheet( num.setStyleSheet(
f"color: {theme['border_strong']}; font-size: 9pt; " f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
f"font-weight: bold; background: transparent; border: none;" f"font-weight: bold; background: transparent; border: none;"
) )
num.setMinimumWidth(60) num.setMinimumWidth(60)
@ -453,32 +435,5 @@ class AchievementsView(QDialog):
# ----- 탭 QSS (다이얼로그 전용) ----- # ----- 탭 QSS (다이얼로그 전용) -----
def _tabs_qss(self) -> str: def _tabs_qss(self) -> str:
return """ # 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
QTabWidget::pane { return tabs_qss(ACCENT_GOLD)
background: #14141c;
border: 1px solid #2a2a3a;
border-radius: 10px;
top: -1px;
}
QTabBar::tab {
background: #1c1c28;
color: #a0a0b8;
padding: 9px 18px;
border: 1px solid #2a2a3a;
border-bottom: none;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-right: 3px;
font-size: 10pt;
}
QTabBar::tab:selected {
background: #14141c;
color: #ffd24a;
font-weight: bold;
border-bottom: 2px solid #ffd24a;
}
QTabBar::tab:hover:!selected {
background: #2a2a36;
color: #e0e0e8;
}
"""

View File

@ -12,6 +12,7 @@ import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database from core.database import Database
from core.i18n import tr
from ui.styles import ThemeColors, apply_dark_titlebar from ui.styles import ThemeColors, apply_dark_titlebar
@ -37,7 +38,7 @@ class CalendarView(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel("월간 근무 기록") title = QLabel(tr('cal.dialog_title'))
title.setObjectName("dialog_title") title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
@ -55,15 +56,16 @@ class CalendarView(QDialog):
# 범례 # 범례
legend_layout = QHBoxLayout() legend_layout = QHBoxLayout()
legend_layout.setSpacing(12) legend_layout.setSpacing(12)
legend_layout.addWidget(QLabel("🟢 정상")) for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')),
legend_layout.addWidget(QLabel("🔴 연장")) ('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]:
legend_layout.addWidget(QLabel("🟡 휴가")) _item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
legend_layout.addWidget(QLabel("⚪ 없음")) _item.setTextFormat(Qt.RichText)
legend_layout.addWidget(_item)
legend_layout.addStretch() legend_layout.addStretch()
layout.addLayout(legend_layout) layout.addLayout(legend_layout)
# 선택된 날짜 상세 정보 # 선택된 날짜 상세 정보
detail_group = QGroupBox("선택된 날짜 정보") detail_group = QGroupBox(tr('cal.detail_group_title'))
detail_layout = QVBoxLayout() detail_layout = QVBoxLayout()
detail_layout.setSpacing(6) detail_layout.setSpacing(6)
detail_layout.setContentsMargins(10, 20, 10, 8) detail_layout.setContentsMargins(10, 20, 10, 8)
@ -77,13 +79,13 @@ class CalendarView(QDialog):
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
button_layout.setSpacing(6) button_layout.setSpacing(6)
self.edit_time_button = QPushButton("✏️ 시간 수정") self.edit_time_button = QPushButton(tr('cal.edit_time'))
self.edit_time_button.setObjectName("btn_primary") self.edit_time_button.setObjectName("btn_primary")
self.edit_time_button.setEnabled(False) self.edit_time_button.setEnabled(False)
self.edit_time_button.clicked.connect(self.edit_work_time) self.edit_time_button.clicked.connect(self.edit_work_time)
button_layout.addWidget(self.edit_time_button) button_layout.addWidget(self.edit_time_button)
self.delete_record_button = QPushButton("🗑️ 기록 삭제") self.delete_record_button = QPushButton(tr('cal.delete_record'))
self.delete_record_button.setObjectName("btn_danger") self.delete_record_button.setObjectName("btn_danger")
self.delete_record_button.setEnabled(False) self.delete_record_button.setEnabled(False)
self.delete_record_button.clicked.connect(self.delete_selected_record) self.delete_record_button.clicked.connect(self.delete_selected_record)
@ -94,17 +96,17 @@ class CalendarView(QDialog):
layout.addWidget(detail_group) layout.addWidget(detail_group)
# 메모 그룹 # 메모 그룹
memo_group = QGroupBox("메모") memo_group = QGroupBox(tr('cal.memo_group'))
memo_layout = QVBoxLayout() memo_layout = QVBoxLayout()
memo_layout.setSpacing(6) memo_layout.setSpacing(6)
memo_layout.setContentsMargins(10, 20, 10, 8) memo_layout.setContentsMargins(10, 20, 10, 8)
self.memo_edit = QTextEdit() self.memo_edit = QTextEdit()
self.memo_edit.setMaximumHeight(70) self.memo_edit.setMaximumHeight(70)
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...") self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder'))
memo_layout.addWidget(self.memo_edit) memo_layout.addWidget(self.memo_edit)
self.save_memo_button = QPushButton("💾 메모 저장") self.save_memo_button = QPushButton(tr('cal.save_memo'))
self.save_memo_button.setObjectName("btn_primary") self.save_memo_button.setObjectName("btn_primary")
self.save_memo_button.setEnabled(False) self.save_memo_button.setEnabled(False)
self.save_memo_button.clicked.connect(self.save_memo) self.save_memo_button.clicked.connect(self.save_memo)
@ -163,21 +165,23 @@ class CalendarView(QDialog):
existing = self.db.get_work_record(date_str) existing = self.db.get_work_record(date_str)
menu = QMenu(self) menu = QMenu(self)
edit_action = delete_action = add_action = None
if existing: if existing:
edit_action = menu.addAction(f"✏️ {date_str} 편집") edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
delete_action = menu.addAction(f"🗑️ {date_str} 삭제") delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
else: else:
add_action = menu.addAction(f" {date_str} 기록 추가") add_action = menu.addAction(tr('cal.context_add', date=date_str))
action = menu.exec_(self.calendar.mapToGlobal(pos)) action = menu.exec_(self.calendar.mapToGlobal(pos))
if action is None: if action is None:
return return
if existing and action.text().startswith("✏️"): # 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
if action == edit_action:
self._open_edit_dialog(date_str) self._open_edit_dialog(date_str)
elif existing and action.text().startswith("🗑️"): elif action == delete_action:
self._delete_record(date_str) self._delete_record(date_str)
elif not existing and action.text().startswith(""): elif action == add_action:
self._add_past_record(date_str) self._add_past_record(date_str)
def _add_past_record(self, date_str: str): def _add_past_record(self, date_str: str):
@ -214,9 +218,9 @@ class CalendarView(QDialog):
if ot_earned > 0: if ot_earned > 0:
self.db.add_overtime_earned(wid, ot_earned, date_str) self.db.add_overtime_earned(wid, ot_earned, date_str)
self._refresh_calendar() self._refresh_calendar()
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.") QMessageBox.information(self, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str))
except Exception as e: except Exception as e:
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}") QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e))
def _open_edit_dialog(self, date_str: str): def _open_edit_dialog(self, date_str: str):
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음).""" """기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
@ -228,8 +232,8 @@ class CalendarView(QDialog):
def _delete_record(self, date_str: str): def _delete_record(self, date_str: str):
reply = QMessageBox.question( reply = QMessageBox.question(
self, "삭제 확인", tr('cal.delete_confirm_title'),
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)", tr('cal.delete_confirm_body', date=date_str),
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply != QMessageBox.Yes: if reply != QMessageBox.Yes:
@ -242,10 +246,10 @@ class CalendarView(QDialog):
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,)) cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
conn.commit() conn.commit()
self._refresh_calendar() self._refresh_calendar()
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨") QMessageBox.information(self, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str))
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
QMessageBox.critical(self, "오류", str(e)) QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
finally: finally:
conn.close() conn.close()
@ -267,33 +271,33 @@ class CalendarView(QDialog):
if record: if record:
# 상세 정보 표시 # 상세 정보 표시
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d')}\n\n" detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
detail += f"출근: {record['clock_in']}\n" detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
if record.get('clock_out'): if record.get('clock_out'):
detail += f"퇴근: {record['clock_out']}\n" detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n" detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
if record.get('lunch_break'): if record.get('lunch_break'):
detail += f"점심시간: 사용함\n" detail += tr('cal.detail_lunch_used') + '\n'
else: else:
detail += f"점심시간: 미사용\n" detail += tr('cal.detail_lunch_unused') + '\n'
if record.get('dinner_break'): if record.get('dinner_break'):
detail += f"저녁시간: 사용함\n" detail += tr('cal.detail_dinner_used') + '\n'
else: else:
detail += f"저녁시간: 미사용\n" detail += tr('cal.detail_dinner_unused') + '\n'
if record.get('overtime_earned', 0) > 0: if record.get('overtime_earned', 0) > 0:
earned_min = record['overtime_earned'] earned_min = record['overtime_earned']
earned_hours = earned_min // 60 earned_hours = earned_min // 60
earned_mins = earned_min % 60 earned_mins = earned_min % 60
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}\n" detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n'
else: else:
detail += f"퇴근: 미기록\n" detail += tr('cal.detail_clock_out_none') + '\n'
if record.get('memo'): if record.get('memo'):
detail += f"\n메모: {record['memo']}\n" detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n'
self.detail_text.setText(detail) self.detail_text.setText(detail)
self.edit_time_button.setEnabled(True) self.edit_time_button.setEnabled(True)
@ -303,7 +307,7 @@ class CalendarView(QDialog):
self.memo_edit.setPlainText(record.get('memo', '')) self.memo_edit.setPlainText(record.get('memo', ''))
self.save_memo_button.setEnabled(True) self.save_memo_button.setEnabled(True)
else: else:
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d')}\n\n기록이 없습니다.") self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
self.edit_time_button.setEnabled(False) self.edit_time_button.setEnabled(False)
self.delete_record_button.setEnabled(False) self.delete_record_button.setEnabled(False)
self.memo_edit.setPlainText('') self.memo_edit.setPlainText('')
@ -316,10 +320,8 @@ class CalendarView(QDialog):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"출근 기록 삭제", tr('cal.delete_selected_title'),
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n" tr('cal.delete_selected_body', date=self.selected_date_str),
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
f"※ 이 작업은 되돌릴 수 없습니다.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No QMessageBox.No
) )
@ -329,8 +331,8 @@ class CalendarView(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"삭제 완료", tr('cal.delete_done_title'),
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다." tr('cal.delete_done_body', date=self.selected_date_str)
) )
# 캘린더 새로고침 # 캘린더 새로고침
@ -350,8 +352,8 @@ class CalendarView(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"메모 저장", tr('cal.save_memo_title'),
f"{self.selected_date_str}의 메모가 저장되었습니다." tr('cal.save_memo_body', date=self.selected_date_str)
) )
# 상세 정보 새로고침 # 상세 정보 새로고침
@ -397,7 +399,7 @@ class EditWorkTimeDialog(QDialog):
from PyQt5.QtWidgets import QTimeEdit from PyQt5.QtWidgets import QTimeEdit
from PyQt5.QtCore import QTime from PyQt5.QtCore import QTime
self.setWindowTitle("출퇴근 시간 수정") self.setWindowTitle(tr('cal.edit_dialog_title'))
self.setModal(True) self.setModal(True)
self.setMinimumWidth(420) self.setMinimumWidth(420)
@ -406,19 +408,19 @@ class EditWorkTimeDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10) layout.setContentsMargins(12, 10, 12, 10)
# 제목 # 제목
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정") title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str))
title.setObjectName("dialog_subtitle") title.setObjectName("dialog_subtitle")
layout.addWidget(title) layout.addWidget(title)
# 출근 시간 # 출근 시간
clock_in_layout = QHBoxLayout() clock_in_layout = QHBoxLayout()
clock_in_layout.setSpacing(4) clock_in_layout.setSpacing(4)
clock_in_label = QLabel("출근:") clock_in_label = QLabel(tr('cal.label_clock_in'))
clock_in_label.setObjectName("field_label") clock_in_label.setObjectName("field_label")
clock_in_label.setFixedWidth(40) clock_in_label.setFixedWidth(40)
clock_in_layout.addWidget(clock_in_label) clock_in_layout.addWidget(clock_in_label)
clock_in_minus_btn = QPushButton("-30분") clock_in_minus_btn = QPushButton(tr('cal.btn_minus_30'))
clock_in_minus_btn.setFixedWidth(55) clock_in_minus_btn.setFixedWidth(55)
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30)) clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
clock_in_layout.addWidget(clock_in_minus_btn) clock_in_layout.addWidget(clock_in_minus_btn)
@ -429,7 +431,7 @@ class EditWorkTimeDialog(QDialog):
self.clock_in_edit.setTime(clock_in_time) self.clock_in_edit.setTime(clock_in_time)
clock_in_layout.addWidget(self.clock_in_edit) clock_in_layout.addWidget(self.clock_in_edit)
clock_in_plus_btn = QPushButton("+30분") clock_in_plus_btn = QPushButton(tr('cal.btn_plus_30'))
clock_in_plus_btn.setFixedWidth(55) clock_in_plus_btn.setFixedWidth(55)
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30)) clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
clock_in_layout.addWidget(clock_in_plus_btn) clock_in_layout.addWidget(clock_in_plus_btn)
@ -438,12 +440,12 @@ class EditWorkTimeDialog(QDialog):
# 퇴근 시간 # 퇴근 시간
clock_out_layout = QHBoxLayout() clock_out_layout = QHBoxLayout()
clock_out_layout.setSpacing(4) clock_out_layout.setSpacing(4)
clock_out_label = QLabel("퇴근:") clock_out_label = QLabel(tr('cal.label_clock_out'))
clock_out_label.setObjectName("field_label") clock_out_label.setObjectName("field_label")
clock_out_label.setFixedWidth(40) clock_out_label.setFixedWidth(40)
clock_out_layout.addWidget(clock_out_label) clock_out_layout.addWidget(clock_out_label)
clock_out_minus_btn = QPushButton("-30분") clock_out_minus_btn = QPushButton(tr('cal.btn_minus_30'))
clock_out_minus_btn.setFixedWidth(55) clock_out_minus_btn.setFixedWidth(55)
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30)) clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
clock_out_layout.addWidget(clock_out_minus_btn) clock_out_layout.addWidget(clock_out_minus_btn)
@ -455,7 +457,7 @@ class EditWorkTimeDialog(QDialog):
self.clock_out_edit.setTime(clock_out_time) self.clock_out_edit.setTime(clock_out_time)
clock_out_layout.addWidget(self.clock_out_edit) clock_out_layout.addWidget(self.clock_out_edit)
clock_out_plus_btn = QPushButton("+30분") clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30'))
clock_out_plus_btn.setFixedWidth(55) clock_out_plus_btn.setFixedWidth(55)
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30)) clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
clock_out_layout.addWidget(clock_out_plus_btn) clock_out_layout.addWidget(clock_out_plus_btn)
@ -464,27 +466,27 @@ class EditWorkTimeDialog(QDialog):
# 점심/저녁 체크박스 - 한 줄에 # 점심/저녁 체크박스 - 한 줄에
from PyQt5.QtWidgets import QCheckBox from PyQt5.QtWidgets import QCheckBox
check_layout = QHBoxLayout() check_layout = QHBoxLayout()
self.lunch_check = QCheckBox("점심 (1시간)") self.lunch_check = QCheckBox(tr('cal.check_lunch_1h'))
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False))) self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
check_layout.addWidget(self.lunch_check) check_layout.addWidget(self.lunch_check)
self.dinner_check = QCheckBox("저녁 (1시간)") self.dinner_check = QCheckBox(tr('cal.check_dinner_1h'))
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False))) self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
check_layout.addWidget(self.dinner_check) check_layout.addWidget(self.dinner_check)
layout.addLayout(check_layout) layout.addLayout(check_layout)
# 안내 메시지 # 안내 메시지
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.") note = QLabel(tr('cal.edit_note'))
note.setObjectName("note_text") note.setObjectName("note_text")
layout.addWidget(note) layout.addWidget(note)
# 버튼 # 버튼
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_button = QPushButton("저장") save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_success") save_button.setObjectName("btn_success")
save_button.clicked.connect(self.save_changes) save_button.clicked.connect(self.save_changes)
cancel_button = QPushButton("취소") cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject) cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button) button_layout.addWidget(save_button)
@ -510,8 +512,8 @@ class EditWorkTimeDialog(QDialog):
if clock_out <= clock_in: if clock_out <= clock_in:
QMessageBox.warning( QMessageBox.warning(
self, self,
"시간 오류", tr('cal.time_error_title'),
"퇴근 시간은 출근 시간보다 늦어야 합니다." tr('cal.time_error_body')
) )
return return
@ -594,15 +596,12 @@ class EditWorkTimeDialog(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"수정 완료", tr('cal.edit_done_title'),
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n" tr('cal.edit_done_body',
f"출근: {clock_in}\n" date=self.date_str, clock_in=clock_in, clock_out=clock_out,
f"퇴근: {clock_out}\n" lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
f"점심시간: {'사용' if lunch_break else '미사용'}\n" dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
f"저녁시간: {'사용' if dinner_break else '미사용'}\n" break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
f"외출시간: {break_minutes}\n"
f"총 근무시간: {total_hours:.1f}시간\n"
f"연장근무: {overtime_earned}분 적립"
) )
self.accept() self.accept()
@ -610,8 +609,8 @@ class EditWorkTimeDialog(QDialog):
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, self,
"오류", tr('cal.edit_error_title'),
f"수정 중 오류가 발생했습니다:\n{str(e)}" tr('cal.edit_error_body', error=str(e))
) )
finally: finally:
if conn: if conn:

View File

@ -9,25 +9,52 @@ from typing import List, Tuple
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from core.i18n import tr
try: try:
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import matplotlib import matplotlib
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif'] from matplotlib.figure import Figure
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
try:
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
except Exception:
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
matplotlib.rcParams['axes.unicode_minus'] = False matplotlib.rcParams['axes.unicode_minus'] = False
_MPL = True _MPL = True
except ImportError: except Exception as _mpl_err:
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
_MPL = False _MPL = False
try:
from utils.debug_log import dlog
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
except Exception:
pass
# 다크 테마 색상 (dark_components 톤과 일치) # 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
_CHART_BG = '#14141c' # 막대/선은 데이터 구분용 고정 색.
_CHART_GRID = '#2a2a3a' _CHART_BG = '#25262B'
_CHART_TEXT = '#c0c0d0' _CHART_GRID = '#2C2E33'
_CHART_BAR_NORMAL = '#6b9eff' # blue _CHART_TEXT = '#909296'
_CHART_BAR_OVERTIME = '#ff90b8' # pink _CHART_BAR_NORMAL = '#4DABF7' # accent blue
_CHART_BAR_WEEKEND = '#fcd34d' # gold _CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
_CHART_AVG_LINE = '#4ade80' # green _CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
_CHART_AVG_LINE = '#51CF66' # green
def _refresh_chart_colors() -> None:
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
global _CHART_BG, _CHART_GRID, _CHART_TEXT
try:
from ui.styles import ThemeColors
_CHART_BG = ThemeColors.get('bg_secondary')
_CHART_GRID = ThemeColors.get('border_subtle')
_CHART_TEXT = ThemeColors.get('text_secondary')
except Exception:
pass
def _apply_dark_axes(ax) -> None: def _apply_dark_axes(ax) -> None:
@ -43,7 +70,8 @@ def _apply_dark_axes(ax) -> None:
def _apply_dark_figure(fig) -> None: def _apply_dark_figure(fig) -> None:
"""figure 배경을 다크 톤으로.""" """figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
_refresh_chart_colors()
fig.patch.set_facecolor(_CHART_BG) fig.patch.set_facecolor(_CHART_BG)
@ -55,7 +83,7 @@ class _Fallback(QWidget):
label = QLabel(message) label = QLabel(message)
label.setAlignment(Qt.AlignCenter) label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True) label.setWordWrap(True)
label.setStyleSheet("color: #888; padding: 20px;") label.setStyleSheet("color: #909296; padding: 20px;")
layout.addWidget(label) layout.addWidget(label)
self.setLayout(layout) self.setLayout(layout)
@ -63,7 +91,8 @@ class _Fallback(QWidget):
def make_chart_widget(parent=None) -> QWidget: def make_chart_widget(parent=None) -> QWidget:
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback.""" """차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
if not _MPL: if not _MPL:
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib") return _Fallback(tr('chart.need_matplotlib'))
_refresh_chart_colors()
widget = QWidget(parent) widget = QWidget(parent)
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;") widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
layout = QVBoxLayout() layout = QVBoxLayout()
@ -88,7 +117,7 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
if not records: if not records:
ax = fig.add_subplot(111) ax = fig.add_subplot(111)
_apply_dark_axes(ax) _apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw() widget._canvas.draw()
return return
@ -100,10 +129,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
base = [max(h - o, 0) for h, o in zip(hours, overtimes)] base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
ax = fig.add_subplot(111) ax = fig.add_subplot(111)
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL) bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'),
color=_CHART_BAR_OVERTIME) color=_CHART_BAR_OVERTIME)
ax.set_ylabel('시간') ax.set_ylabel(tr('chart.ylabel_hours'))
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG, legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT) edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
ax.tick_params(axis='x', labelrotation=45, labelsize=8) ax.tick_params(axis='x', labelrotation=45, labelsize=8)
@ -130,9 +159,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
for i, bar in enumerate(bars): for i, bar in enumerate(bars):
if bar.contains(event)[0]: if bar.contains(event)[0]:
h = hours[i]; ot = overtimes[i] h = hours[i]; ot = overtimes[i]
text = f"{full_dates[i]}\n근무 {h:.1f}h" text = tr('chart.hover_text',
date=full_dates[i], hours=f"{h:.1f}")
if ot > 0: if ot > 0:
text += f"\n연장 +{ot:.1f}h" text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}")
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y()) annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
annot.set_text(text) annot.set_text(text)
annot.set_visible(True) annot.set_visible(True)
@ -164,7 +194,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
if not records: if not records:
ax = fig.add_subplot(111) ax = fig.add_subplot(111)
_apply_dark_axes(ax) _apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw() widget._canvas.draw()
return return
@ -184,7 +214,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
if not minutes_list: if not minutes_list:
ax = fig.add_subplot(111) ax = fig.add_subplot(111)
_apply_dark_axes(ax) _apply_dark_axes(ax)
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11) transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
widget._canvas.draw() widget._canvas.draw()
return return
@ -198,12 +228,13 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL, ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
edgecolor=_CHART_BG, linewidth=1) edgecolor=_CHART_BG, linewidth=1)
avg = sum(minutes_list) / len(minutes_list) avg = sum(minutes_list) / len(minutes_list)
avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}"
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2, ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}') label=tr('chart.avg_line', time=avg_time))
ax.set_xticks([m for m in bins if m % 60 == 0]) ax.set_xticks([m for m in bins if m % 60 == 0])
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0], ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
rotation=45, fontsize=8) rotation=45, fontsize=8)
ax.set_ylabel('일수') ax.set_ylabel(tr('chart.ylabel_days'))
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG, legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT) edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
_apply_dark_axes(ax) _apply_dark_axes(ax)
@ -230,11 +261,13 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
weekday_counts[d.weekday()] += 1 weekday_counts[d.weekday()] += 1
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)] avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
labels = ['', '', '', '', '', '', ''] labels = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'),
tr('label.weekday_sun')]
ax = fig.add_subplot(111) ax = fig.add_subplot(111)
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조 colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
ax.bar(labels, avg, color=colors) ax.bar(labels, avg, color=colors)
ax.set_ylabel('평균 시간') ax.set_ylabel(tr('chart.ylabel_avg_hours'))
_apply_dark_axes(ax) _apply_dark_axes(ax)
widget._canvas.draw() widget._canvas.draw()

View File

@ -134,8 +134,8 @@ if __name__ == "__main__":
dialog = ClockInDialog() dialog = ClockInDialog()
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
selected_time = dialog.get_time() selected_time = dialog.get_time()
print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}") print(tr('clock_in_dialog.selected', time=selected_time.strftime('%H:%M:%S')))
else: else:
print("취소됨") print(tr('clock_in_dialog.cancelled'))
sys.exit() sys.exit()

View File

@ -8,6 +8,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
import sqlite3
class LockMonitor: class LockMonitor:
@ -61,14 +62,23 @@ class LockMonitor:
clock_in_str = when.strftime("%H:%M:%S") clock_in_str = when.strftime("%H:%M:%S")
existing = self.db.get_today_record() existing = self.db.get_today_record()
if existing: if existing:
conn = self.db.get_connection() with self.db._conn() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?", "UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
(clock_in_str, today), (clock_in_str, today),
) )
conn.commit() conn.commit()
conn.close()
else: else:
self.db.add_work_record(today, clock_in_str) try:
self.db.add_work_record(today, clock_in_str)
except sqlite3.IntegrityError:
# get_today_record()와 add_work_record() 사이 경쟁 조건
with self.db._conn() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
(clock_in_str, today),
)
conn.commit()
w.update_display() w.update_display()

View File

@ -10,6 +10,7 @@ from datetime import datetime
from core.settings_keys import ( from core.settings_keys import (
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS, HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
) )
from core.i18n import tr
from utils.debug_log import dlog from utils.debug_log import dlog
@ -54,12 +55,12 @@ class NotificationOrchestrator:
longest = max(closed, key=lambda r: r.get('total_hours') or 0) longest = max(closed, key=lambda r: r.get('total_hours') or 0)
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)" longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
title = "📊 지난주 요약" title = tr('notif.weekly_report.title')
body = (f"기간: {last_mon} ~ {last_sun}\n" body = tr('notif.weekly_report.body',
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n" start=last_mon, end=last_sun,
f"일 평균: {avg_h:.1f}시간\n" total_h=total_h, days=len(closed),
f"연장근무: {ot_h}시간 {ot_m}\n" avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
f"가장 긴 날: {longest_str}") longest=longest_str)
self.notifier.notification_signal.emit(title, body) self.notifier.notification_signal.emit(title, body)
self.db.log_notification('system', 'weekly_report') self.db.log_notification('system', 'weekly_report')
@ -70,13 +71,17 @@ class NotificationOrchestrator:
try: try:
from utils.discord_webhook import send, COLOR_BLUE from utils.discord_webhook import send, COLOR_BLUE
fields = [ fields = [
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True}, {"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True},
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True}, {"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True},
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}", "inline": True}, {"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True},
{"name": "가장 긴 날", "value": longest_str, "inline": False}, {"name": tr('field.longest_day'), "value": longest_str, "inline": False},
] ]
ok = send(url, "📊 지난주 요약", ok = send(url, tr('notif.weekly_report.title'),
f"기간: {last_mon} ~ {last_sun}", tr('notif.weekly_report.body',
start=last_mon, end=last_sun,
total_h=total_h, days=len(closed),
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
longest=longest_str),
color=COLOR_BLUE, fields=fields) color=COLOR_BLUE, fields=fields)
self.db.log_notification('discord', 'weekly_report', success=ok) self.db.log_notification('discord', 'weekly_report', success=ok)
except Exception as e: except Exception as e:
@ -147,8 +152,8 @@ class NotificationOrchestrator:
for a in unlocked: for a in unlocked:
self.db.log_notification('system', f'achievement:{a.code}') self.db.log_notification('system', f'achievement:{a.code}')
if notif_on: if notif_on:
title = f"{a.badge_icon} 도전과제 달성!" title = tr('notif.achievement.title', icon=a.badge_icon)
body = f"{a.name}\n{a.description}" body = tr('notif.achievement.body', name=a.name, description=a.description)
self.notifier.notification_signal.emit(title, body) self.notifier.notification_signal.emit(title, body)
# Discord 통합 push (여러 개면 묶어서) # Discord 통합 push (여러 개면 묶어서)
self._discord_achievements(unlocked) self._discord_achievements(unlocked)
@ -167,8 +172,8 @@ class NotificationOrchestrator:
extra = (f"\n... 외 {len(unlocked) - 10}" if len(unlocked) > 10 else '') extra = (f"\n... 외 {len(unlocked) - 10}" if len(unlocked) > 10 else '')
ok = discord_webhook.send( ok = discord_webhook.send(
url, url,
f"🏆 도전과제 {len(unlocked)}개 달성!", tr('discord.achievement.title', count=len(unlocked)),
f"새로 잠금 해제된 도전과제 입니다.{extra}", tr('discord.achievement.body', extra=extra),
color=discord_webhook.COLOR_YELLOW, color=discord_webhook.COLOR_YELLOW,
fields=fields, fields=fields,
) )

View File

@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt
# ── 색상 팔레트 ──────────────────────────────────────────────── # ── 색상 팔레트 ────────────────────────────────────────────────
DARK_BG = '#0e0e14' # 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
DARK_PANEL = '#14141c' DARK_BG = '#1A1B1E'
DARK_PANEL_2 = '#1c1c28' DARK_PANEL = '#25262B'
DARK_BORDER = '#2a2a3a' DARK_PANEL_2 = '#2C2E33'
DARK_BORDER_STRONG = '#44446a' DARK_BORDER = '#2C2E33'
DARK_TEXT = '#e8e8f4' DARK_BORDER_STRONG = '#373A40'
DARK_TEXT_DIM = '#a0a0b8' DARK_TEXT = '#E9ECEF'
DARK_TEXT_FAINT = '#666680' DARK_TEXT_DIM = '#909296'
DARK_TEXT_FAINT = '#6C6E73'
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
ACCENT_GOLD = '#ffd24a' ACCENT_GOLD = '#ffd24a'
ACCENT_BLUE = '#6b9eff' ACCENT_BLUE = '#4DABF7'
ACCENT_CYAN = '#4adef0' ACCENT_CYAN = '#4adef0'
ACCENT_PINK = '#ff90b8' ACCENT_PINK = '#ff90b8'
ACCENT_GREEN = '#4ade80' ACCENT_GREEN = '#51CF66'
ACCENT_ORANGE = '#fcd34d' ACCENT_ORANGE = '#fcd34d'
ACCENT_RED = '#fb7185' ACCENT_RED = '#FA5252'
# 카드 테마 (등급/상태별) # 카드 테마 (등급/상태별)
CARD_THEMES = { CARD_THEMES = {
@ -76,26 +78,59 @@ CARD_THEMES = {
} }
# ── 테마 연동 ──────────────────────────────────────────────────
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
def _pal() -> dict:
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
from ui.styles import ThemeColors
g = ThemeColors.get
return {
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
'border': g('border_subtle'), 'border_strong': g('border_default'),
'text': g('text_primary'), 'text_dim': g('text_secondary'),
'text_faint': g('text_tertiary'),
'blue': g('accent_primary'), 'green': g('accent_success'),
'red': g('accent_danger'),
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
}
def _is_dark() -> bool:
from ui.styles import ThemeColors, DARK_COLORS
return ThemeColors.current is DARK_COLORS
def tc(role: str) -> str:
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
return _pal().get(role, '#FF00FF')
# ── QSS 헬퍼 ─────────────────────────────────────────────────── # ── QSS 헬퍼 ───────────────────────────────────────────────────
def dialog_qss() -> str: def dialog_qss() -> str:
"""다이얼로그 전체 배경.""" """다이얼로그 전체 배경 (현재 테마)."""
return f"QDialog {{ background: {DARK_BG}; }}" return f"QDialog {{ background: {_pal()['bg']}; }}"
def tabs_qss(accent: str = ACCENT_GOLD) -> str: def tabs_qss(accent: str = None) -> str:
p = _pal()
if accent is None:
accent = p['blue']
return f""" return f"""
QTabWidget::pane {{ QTabWidget::pane {{
background: {DARK_PANEL}; background: {p['panel']};
border: 1px solid {DARK_BORDER}; border: 1px solid {p['border']};
border-radius: 10px; border-radius: 10px;
top: -1px; top: -1px;
}} }}
QTabBar::tab {{ QTabBar::tab {{
background: {DARK_PANEL_2}; background: {p['panel2']};
color: {DARK_TEXT_DIM}; color: {p['text_dim']};
padding: 9px 18px; padding: 9px 18px;
border: 1px solid {DARK_BORDER}; border: 1px solid {p['border']};
border-bottom: none; border-bottom: none;
border-top-left-radius: 8px; border-top-left-radius: 8px;
border-top-right-radius: 8px; border-top-right-radius: 8px;
@ -103,88 +138,90 @@ def tabs_qss(accent: str = ACCENT_GOLD) -> str:
font-size: 10pt; font-size: 10pt;
}} }}
QTabBar::tab:selected {{ QTabBar::tab:selected {{
background: {DARK_PANEL}; background: {p['panel']};
color: {accent}; color: {accent};
font-weight: bold; font-weight: bold;
border-bottom: 2px solid {accent}; border-bottom: 2px solid {accent};
}} }}
QTabBar::tab:hover:!selected {{ QTabBar::tab:hover:!selected {{
background: #2a2a36; background: {p['border_strong']};
color: {DARK_TEXT}; color: {p['text']};
}} }}
""" """
def scroll_qss() -> str: def scroll_qss() -> str:
p = _pal()
return f""" return f"""
QScrollArea {{ background: transparent; border: none; }} QScrollArea {{ background: transparent; border: none; }}
QScrollBar:vertical {{ QScrollBar:vertical {{
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px; background: {p['panel2']}; width: 10px; border-radius: 5px;
}} }}
QScrollBar::handle:vertical {{ QScrollBar::handle:vertical {{
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px; background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
}} }}
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }} QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
QScrollBar:horizontal {{ QScrollBar:horizontal {{
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px; background: {p['panel2']}; height: 10px; border-radius: 5px;
}} }}
QScrollBar::handle:horizontal {{ QScrollBar::handle:horizontal {{
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px; background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
}} }}
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }} QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
""" """
def button_qss(variant: str = 'default') -> str: def button_qss(variant: str = 'default') -> str:
""" variant: default | primary | success | danger | ghost """ """ variant: default | primary | success | danger | ghost (현재 테마) """
p = _pal()
if variant == 'primary': if variant == 'primary':
return f""" return f"""
QPushButton {{ QPushButton {{
background: {ACCENT_BLUE}; color: white; background: {p['blue']}; color: white;
border: none; border-radius: 6px; border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt; padding: 8px 18px; font-weight: bold; font-size: 10pt;
}} }}
QPushButton:hover {{ background: #82b0ff; }} QPushButton:hover {{ background: {p['blue_hover']}; }}
QPushButton:pressed {{ background: #5a8eee; }} QPushButton:pressed {{ background: {p['blue_pressed']}; }}
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }} QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
""" """
if variant == 'success': if variant == 'success':
return f""" return f"""
QPushButton {{ QPushButton {{
background: {ACCENT_GREEN}; color: #0e2a1a; background: {p['green']}; color: white;
border: none; border-radius: 6px; border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt; padding: 8px 18px; font-weight: bold; font-size: 10pt;
}} }}
QPushButton:hover {{ background: #6ae899; }} QPushButton:hover {{ background: {p['green_hover']}; }}
""" """
if variant == 'danger': if variant == 'danger':
return f""" return f"""
QPushButton {{ QPushButton {{
background: {ACCENT_RED}; color: white; background: {p['red']}; color: white;
border: none; border-radius: 6px; border: none; border-radius: 8px;
padding: 8px 18px; font-weight: bold; font-size: 10pt; padding: 8px 18px; font-weight: bold; font-size: 10pt;
}} }}
QPushButton:hover {{ background: #fc8896; }} QPushButton:hover {{ background: {p['red_hover']}; }}
""" """
if variant == 'ghost': if variant == 'ghost':
return f""" return f"""
QPushButton {{ QPushButton {{
background: transparent; color: {DARK_TEXT_DIM}; background: transparent; color: {p['text_dim']};
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px; border: 1px solid {p['border_strong']}; border-radius: 8px;
padding: 6px 14px; font-size: 9.5pt; padding: 6px 14px; font-size: 9.5pt;
}} }}
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT}; QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
border-color: {ACCENT_BLUE}; }} border-color: {p['blue']}; }}
""" """
# default # default
return f""" return f"""
QPushButton {{ QPushButton {{
background: {DARK_PANEL_2}; color: {DARK_TEXT}; background: {p['panel2']}; color: {p['text']};
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px; border: 1px solid {p['border_strong']}; border-radius: 8px;
padding: 8px 18px; font-size: 10pt; padding: 8px 18px; font-size: 10pt;
}} }}
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }} QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
""" """
@ -202,15 +239,15 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
big_color: 숫자 big_color: 숫자
extra_widgets: 우측에 배치할 위젯 (: 추가 통계, 토글) extra_widgets: 우측에 배치할 위젯 (: 추가 통계, 토글)
""" """
p = _pal()
container = QFrame() container = QFrame()
container.setStyleSheet(f""" container.setStyleSheet(f"""
QFrame {{ QFrame {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, background: {p['panel']};
stop:0 #1a1a30, stop:1 #2a1a3a); border: 1px solid {p['border']};
border: 1px solid #3a3a5a; border-radius: 8px;
border-radius: 12px;
}} }}
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }} QLabel {{ background: transparent; border: none; color: {p['text']}; }}
""") """)
layout = QHBoxLayout() layout = QHBoxLayout()
layout.setContentsMargins(20, 14, 20, 14) layout.setContentsMargins(20, 14, 20, 14)
@ -222,13 +259,13 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
if title: if title:
t = QLabel(title) t = QLabel(title)
t.setStyleSheet( t.setStyleSheet(
f"font-size: 9pt; color: {DARK_TEXT_DIM}; " f"font-size: 9pt; color: {p['text_dim']}; "
f"background: transparent; border: none;" f"background: transparent; border: none;"
) )
left.addWidget(t) left.addWidget(t)
big = QLabel( big = QLabel(
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>" f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>" f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
f" {subtitle}</span>" if subtitle else '') f" {subtitle}</span>" if subtitle else '')
) )
big.setTextFormat(Qt.RichText) big.setTextFormat(Qt.RichText)
@ -252,29 +289,49 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
theme: str = 'blue', icon: str = '') -> QFrame: theme: str = 'blue', icon: str = '') -> QFrame:
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지.""" """단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
t = CARD_THEMES.get(theme, CARD_THEMES['blue']) t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
p = _pal()
dark = _is_dark()
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
if dark:
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
card_border = t['border']
label_color = t['text']
value_color = t['border_strong']
else:
card_bg = p['panel']
card_border = p['border']
label_color = p['text']
value_color = p['text']
card = QFrame() card = QFrame()
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
card.setStyleSheet(f""" card.setStyleSheet(f"""
QFrame {{ QFrame {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, background: {card_bg};
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']}); border: 1px solid {card_border};
border: 1px solid {t['border']};
border-radius: 10px; border-radius: 10px;
}} }}
QLabel {{ background: transparent; border: none; color: {t['text']}; }} QLabel {{ background: transparent; border: none; color: {label_color}; }}
""") """)
outer = QHBoxLayout() outer = QHBoxLayout()
outer.setContentsMargins(16, 12, 16, 12) outer.setContentsMargins(16, 12, 16, 12)
outer.setSpacing(12) outer.setSpacing(12)
if icon: if icon:
icon_lbl = QLabel(icon) icon_lbl = QLabel()
icon_lbl.setStyleSheet(
f"font-size: 28pt; background: transparent; border: none; "
f"color: {t['border_strong']};"
)
icon_lbl.setMinimumWidth(48) icon_lbl.setMinimumWidth(48)
icon_lbl.setAlignment(Qt.AlignCenter) icon_lbl.setAlignment(Qt.AlignCenter)
from ui.icons import get_icon, _PATHS
if icon in _PATHS:
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
else:
# 이모지/텍스트 폴백 (구버전 호환)
icon_lbl.setText(icon)
icon_lbl.setStyleSheet(
f"font-size: 28pt; background: transparent; border: none; "
f"color: {t['border_strong']};"
)
outer.addWidget(icon_lbl) outer.addWidget(icon_lbl)
text_box = QVBoxLayout() text_box = QVBoxLayout()
@ -282,13 +339,13 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
title_lbl = QLabel(title) title_lbl = QLabel(title)
title_lbl.setStyleSheet( title_lbl.setStyleSheet(
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; " f"font-size: 9.5pt; color: {p['text_dim']}; "
f"background: transparent; border: none;" f"background: transparent; border: none;"
) )
text_box.addWidget(title_lbl) text_box.addWidget(title_lbl)
val_lbl = QLabel( val_lbl = QLabel(
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>" f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
f"{value}</span>" f"{value}</span>"
) )
val_lbl.setTextFormat(Qt.RichText) val_lbl.setTextFormat(Qt.RichText)
@ -298,7 +355,7 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
if subtitle: if subtitle:
sub_lbl = QLabel(subtitle) sub_lbl = QLabel(subtitle)
sub_lbl.setStyleSheet( sub_lbl.setStyleSheet(
f"font-size: 9pt; color: {DARK_TEXT_DIM}; " f"font-size: 9pt; color: {p['text_dim']}; "
f"background: transparent; border: none;" f"background: transparent; border: none;"
) )
sub_lbl.setWordWrap(True) sub_lbl.setWordWrap(True)
@ -313,16 +370,25 @@ def build_section_card(title: str, content: QWidget,
theme: str = 'gray', icon: str = '') -> QFrame: theme: str = 'gray', icon: str = '') -> QFrame:
"""제목 + 내용 큰 카드 (세로 레이아웃).""" """제목 + 내용 큰 카드 (세로 레이아웃)."""
t = CARD_THEMES.get(theme, CARD_THEMES['gray']) t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
p = _pal()
if _is_dark():
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
card_border = t['border']
label_color = t['text']
else:
card_bg = p['panel']
card_border = p['border']
label_color = p['text']
card = QFrame() card = QFrame()
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
card.setStyleSheet(f""" card.setStyleSheet(f"""
QFrame {{ QFrame {{
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, background: {card_bg};
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']}); border: 1px solid {card_border};
border: 1px solid {t['border']};
border-radius: 10px; border-radius: 10px;
}} }}
QLabel {{ background: transparent; border: none; color: {t['text']}; }} QLabel {{ background: transparent; border: none; color: {label_color}; }}
""") """)
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 14) layout.setContentsMargins(16, 12, 16, 14)
@ -330,15 +396,20 @@ def build_section_card(title: str, content: QWidget,
head = QHBoxLayout() head = QHBoxLayout()
if icon: if icon:
i = QLabel(icon) i = QLabel()
i.setStyleSheet( from ui.icons import get_icon, _PATHS
f"font-size: 16pt; color: {t['border_strong']}; " if icon in _PATHS:
f"background: transparent; border: none;" i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
) else:
i.setText(icon)
i.setStyleSheet(
f"font-size: 16pt; color: {t['border_strong']}; "
f"background: transparent; border: none;"
)
head.addWidget(i) head.addWidget(i)
title_lbl = QLabel(title) title_lbl = QLabel(title)
title_lbl.setStyleSheet( title_lbl.setStyleSheet(
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; " f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
f"background: transparent; border: none;" f"background: transparent; border: none;"
) )
head.addWidget(title_lbl) head.addWidget(title_lbl)
@ -372,9 +443,11 @@ def style_progressbar(pb: QProgressBar, theme: str = 'blue',
def transparent_label(text: str, size: int = 10, weight: str = 'normal', def transparent_label(text: str, size: int = 10, weight: str = 'normal',
color: str = DARK_TEXT) -> QLabel: color: str = None) -> QLabel:
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음).""" """글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
lbl = QLabel(text) lbl = QLabel(text)
if color is None:
color = _pal()['text']
weight_str = 'bold' if weight == 'bold' else 'normal' weight_str = 'bold' if weight == 'bold' else 'normal'
lbl.setStyleSheet( lbl.setStyleSheet(
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; " f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "

View File

@ -9,6 +9,8 @@ from datetime import datetime, date
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from core.i18n import tr
class GoalWidget(QWidget): class GoalWidget(QWidget):
"""월간 목표 진행률 표시.""" """월간 목표 진행률 표시."""
@ -20,13 +22,13 @@ class GoalWidget(QWidget):
layout.setContentsMargins(8, 6, 8, 6) layout.setContentsMargins(8, 6, 8, 6)
layout.setSpacing(4) layout.setSpacing(4)
title = QLabel("🎯 이번 달 목표") title = QLabel(tr('goal.title'))
title.setStyleSheet("font-weight: bold;") title.setStyleSheet("font-weight: bold;")
layout.addWidget(title) layout.addWidget(title)
# 연장근무 상한 # 연장근무 상한
ot_row = QHBoxLayout() ot_row = QHBoxLayout()
self.ot_label = QLabel("연장근무:") self.ot_label = QLabel(tr('goal.overtime'))
self.ot_label.setFixedWidth(100) self.ot_label.setFixedWidth(100)
self.ot_bar = QProgressBar() self.ot_bar = QProgressBar()
self.ot_bar.setTextVisible(True) self.ot_bar.setTextVisible(True)
@ -37,7 +39,7 @@ class GoalWidget(QWidget):
# 일평균 # 일평균
avg_row = QHBoxLayout() avg_row = QHBoxLayout()
self.avg_label = QLabel("일평균:") self.avg_label = QLabel(tr('goal.avg_daily'))
self.avg_label.setFixedWidth(100) self.avg_label.setFixedWidth(100)
self.avg_bar = QProgressBar() self.avg_bar = QProgressBar()
self.avg_bar.setTextVisible(True) self.avg_bar.setTextVisible(True)
@ -78,7 +80,7 @@ class GoalWidget(QWidget):
ot_h, ot_m = ot_total // 60, ot_total % 60 ot_h, ot_m = ot_total // 60, ot_total % 60
tg_h, tg_m = ot_target // 60, ot_target % 60 tg_h, tg_m = ot_target // 60, ot_target % 60
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m") self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336') color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}") self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else: else:
self.ot_label.setVisible(False) self.ot_label.setVisible(False)
@ -93,7 +95,7 @@ class GoalWidget(QWidget):
self.avg_bar.setValue(int(min(avg, avg_target) * 100)) self.avg_bar.setValue(int(min(avg, avg_target) * 100))
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h") self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
ratio = avg / avg_target if avg_target else 0 ratio = avg / avg_target if avg_target else 0
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336') color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}") self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
else: else:
self.avg_label.setVisible(False) self.avg_label.setVisible(False)

View File

@ -10,10 +10,7 @@ from PyQt5.QtCore import Qt
from core.i18n import tr, tr_html from core.i18n import tr, tr_html
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
from ui.dark_components import ( from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
dialog_qss, tabs_qss, button_qss,
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
)
class HelpView(QDialog): class HelpView(QDialog):
@ -37,7 +34,7 @@ class HelpView(QDialog):
self.resize(820, 760) self.resize(820, 760)
self.setStyleSheet(dialog_qss()) self.setStyleSheet(dialog_qss())
self.init_ui() self.init_ui()
apply_dark_titlebar(self, dark=True) apply_dark_titlebar(self) # 현재 테마에 맞춰
def init_ui(self): def init_ui(self):
main_layout = QVBoxLayout() main_layout = QVBoxLayout()
@ -45,15 +42,14 @@ class HelpView(QDialog):
main_layout.setSpacing(10) main_layout.setSpacing(10)
# 다크 타이틀 # 다크 타이틀
title = QLabel(f"📖 {tr('window.help')}") title = QLabel(tr('window.help'))
title.setStyleSheet( title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; " f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;" f"background: transparent; border: none; padding: 4px 0;"
) )
main_layout.addWidget(title) main_layout.addWidget(title)
tabs = QTabWidget() tabs = QTabWidget()
tabs.setDocumentMode(True)
tabs.setStyleSheet(tabs_qss()) tabs.setStyleSheet(tabs_qss())
for html_key, tab_label_key in self._TABS: for html_key, tab_label_key in self._TABS:
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key)) tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
@ -63,7 +59,7 @@ class HelpView(QDialog):
button_layout.setContentsMargins(0, 6, 0, 0) button_layout.setContentsMargins(0, 6, 0, 0)
# 온보딩 다시 보기 (왼쪽, ghost 스타일) # 온보딩 다시 보기 (왼쪽, ghost 스타일)
onboarding_button = QPushButton("🚀 온보딩 다시 보기") onboarding_button = QPushButton(tr('help.onboarding_button'))
onboarding_button.setMinimumHeight(36) onboarding_button.setMinimumHeight(36)
onboarding_button.setStyleSheet(button_qss('ghost')) onboarding_button.setStyleSheet(button_qss('ghost'))
onboarding_button.clicked.connect(self._reopen_onboarding) onboarding_button.clicked.connect(self._reopen_onboarding)
@ -89,7 +85,7 @@ class HelpView(QDialog):
def _make_tab(self, html: str) -> QWidget: def _make_tab(self, html: str) -> QWidget:
container = QWidget() container = QWidget()
container.setStyleSheet(f"background: {DARK_PANEL};") container.setStyleSheet(f"background: {tc('panel')};")
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@ -100,21 +96,21 @@ class HelpView(QDialog):
browser.setHtml(styled_html) browser.setHtml(styled_html)
browser.setStyleSheet(f""" browser.setStyleSheet(f"""
QTextBrowser {{ QTextBrowser {{
background: {DARK_PANEL}; background: {tc('panel')};
color: {DARK_TEXT}; color: {tc('text')};
border: none; border: none;
padding: 16px 20px; padding: 16px 20px;
font-size: 10.5pt; font-size: 10.5pt;
selection-background-color: {ACCENT_GOLD}; selection-background-color: {tc('blue')};
selection-color: #1a1a26; selection-color: #ffffff;
}} }}
QScrollBar:vertical {{ QScrollBar:vertical {{
background: {DARK_PANEL}; width: 10px; border-radius: 5px; background: {tc('panel')}; width: 10px; border-radius: 5px;
}} }}
QScrollBar::handle:vertical {{ QScrollBar::handle:vertical {{
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px; background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
}} }}
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }} QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
""") """)
layout.addWidget(browser) layout.addWidget(browser)
@ -123,61 +119,67 @@ class HelpView(QDialog):
def _inject_dark_styles(self, html: str) -> str: def _inject_dark_styles(self, html: str) -> str:
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블).""" """HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
text = tc('text')
dim = tc('text_dim')
blue = tc('blue')
green = tc('green')
panel2 = tc('panel2')
border = tc('border')
css = f""" css = f"""
<style> <style>
body, p, li {{ body, p, li {{
color: #e8e8f4; color: {text};
font-size: 14px; font-size: 14px;
line-height: 1.65; line-height: 1.65;
}} }}
h1, h2, h3, h4 {{ h1, h2, h3, h4 {{
color: #ffd24a; color: {blue};
margin-top: 1.2em; margin-top: 1.2em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
}} }}
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }} h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
h3 {{ font-size: 13pt; color: #6b9eff; }} h3 {{ font-size: 13pt; color: {blue}; }}
h4 {{ font-size: 11pt; color: #4ade80; }} h4 {{ font-size: 11pt; color: {green}; }}
b, strong {{ color: #ff90b8; }} b, strong {{ color: {text}; }}
code {{ code {{
background: #1c1c28; background: {panel2};
color: #ffd24a; color: {blue};
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-family: Consolas, monospace; font-family: Consolas, monospace;
font-size: 12px; font-size: 12px;
}} }}
pre {{ pre {{
background: #1c1c28; background: {panel2};
border: 1px solid #2a2a3a; border: 1px solid {border};
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
color: #e8e8f4; color: {text};
}} }}
ul, ol {{ margin-left: 0; padding-left: 24px; }} ul, ol {{ margin-left: 0; padding-left: 24px; }}
li {{ margin-bottom: 4px; }} li {{ margin-bottom: 4px; }}
a {{ color: #4adef0; text-decoration: none; }} a {{ color: {blue}; text-decoration: none; }}
a:hover {{ text-decoration: underline; }} a:hover {{ text-decoration: underline; }}
table {{ border-collapse: collapse; margin: 10px 0; }} table {{ border-collapse: collapse; margin: 10px 0; }}
th {{ th {{
background: #2a2a3a; background: {panel2};
color: #ffd24a; color: {text};
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #44446a; border: 1px solid {border};
text-align: left; text-align: left;
}} }}
td {{ td {{
padding: 6px 12px; padding: 6px 12px;
border: 1px solid #2a2a3a; border: 1px solid {border};
color: #e8e8f4; color: {text};
}} }}
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }} hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
blockquote {{ blockquote {{
border-left: 3px solid #6b9eff; border-left: 3px solid {blue};
margin-left: 0; margin-left: 0;
padding: 4px 16px; padding: 4px 16px;
color: #a0a0b8; color: {dim};
background: rgba(107, 158, 255, 0.05);
}} }}
</style> </style>
""" """

82
ui/icons.py Normal file
View File

@ -0,0 +1,82 @@
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환
재호출하면 자동으로 재틴팅된다.
사용:
from ui.icons import get_icon
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
"""
from __future__ import annotations
from PyQt5.QtCore import QByteArray, QRectF, Qt
from PyQt5.QtGui import QIcon, QPixmap, QPainter
from PyQt5.QtSvg import QSvgRenderer
from ui.styles import ThemeColors
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
_PATHS = {
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
}
_SVG_TMPL = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
'fill="none" stroke="{color}" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
)
_cache: dict = {}
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
if color is None:
color = ThemeColors.get('text_secondary')
key = (name, color, size)
cached = _cache.get(key)
if cached is not None:
return cached
paths = _PATHS.get(name)
if paths is None:
return QIcon()
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
renderer = QSvgRenderer(QByteArray(svg))
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
pm = QPixmap(size * dpr, size * dpr)
pm.fill(Qt.transparent)
painter = QPainter(pm)
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
painter.end()
pm.setDevicePixelRatio(dpr)
icon = QIcon(pm)
_cache[key] = icon
return icon
def clear_cache() -> None:
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
_cache.clear()

View File

@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
from PyQt5.QtCore import Qt, QDate from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
@ -22,7 +23,7 @@ class LeaveCalendarView(QDialog):
def __init__(self, parent=None, db=None): def __init__(self, parent=None, db=None):
super().__init__(parent) super().__init__(parent)
self.db = db self.db = db
self.setWindowTitle("📅 연차 캘린더") self.setWindowTitle(tr('leave_cal.title'))
self.setModal(True) self.setModal(True)
self.setMinimumSize(540, 480) self.setMinimumSize(540, 480)
self._build_ui() self._build_ui()
@ -37,7 +38,7 @@ class LeaveCalendarView(QDialog):
balance = float(self.db.get_setting('leave_balance', '0') or 0) balance = float(self.db.get_setting('leave_balance', '0') or 0)
total = float(self.db.get_setting('annual_leave_total', '15') or 15) total = float(self.db.get_setting('annual_leave_total', '15') or 15)
used = total - balance used = total - balance
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)") title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
title.setStyleSheet("font-weight: bold; font-size: 13px;") title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title) header.addWidget(title)
header.addStretch() header.addStretch()
@ -45,10 +46,11 @@ class LeaveCalendarView(QDialog):
# 범례 (사용 완료 + 예정 분리) # 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout() legend = QHBoxLayout()
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)", for _color, _txt in [('#51CF66', tr('leave_cal.legend_full')), ('#FAB005', tr('leave_cal.legend_half')),
"🔵 예정", "🔘 종일+예정"]: ('#B197FC', tr('leave_cal.legend_quarter')), ('#4DABF7', tr('leave_cal.legend_planned')),
l = QLabel(label) ('#748FFC', tr('leave_cal.legend_full_planned'))]:
l.setStyleSheet(f"padding: 2px 6px;") l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
l.setStyleSheet("padding: 2px 6px;")
legend.addWidget(l) legend.addWidget(l)
legend.addStretch() legend.addStretch()
layout.addLayout(legend) layout.addLayout(legend)
@ -61,13 +63,13 @@ class LeaveCalendarView(QDialog):
# 선택 일자 정보 # 선택 일자 정보
self.detail_label = QLabel("") self.detail_label = QLabel("")
self.detail_label.setStyleSheet("padding: 6px; color: #888;") self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
layout.addWidget(self.detail_label) layout.addWidget(self.detail_label)
# 닫기 버튼 # 닫기 버튼
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
close_btn = QPushButton("닫기") close_btn = QPushButton(tr('btn.close'))
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
btn_row.addWidget(close_btn) btn_row.addWidget(close_btn)
layout.addLayout(btn_row) layout.addLayout(btn_row)
@ -108,12 +110,12 @@ class LeaveCalendarView(QDialog):
records = self.db.get_all_leave_records(limit=365) records = self.db.get_all_leave_records(limit=365)
match = [r for r in records if r['date'] == date_str] match = [r for r in records if r['date'] == date_str]
if not match: if not match:
self.detail_label.setText(f"{date_str} — 연차 사용 없음") self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
return return
parts = [] parts = []
for r in match: for r in match:
t = r.get('leave_type', 'annual') t = r.get('leave_type', 'annual')
d = float(r.get('days') or 0) d = float(r.get('days') or 0)
memo = r.get('memo') or '' memo = r.get('memo') or ''
parts.append(f"{t} {d}" + (f" ({memo})" if memo else "")) parts.append(tr('leave_cal.detail_memo', type=t, days=d, memo=memo) if memo else tr('leave_cal.detail_label', type=t, days=d))
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts)) self.detail_label.setText(f"{date_str}: " + ", ".join(parts))

View File

@ -90,8 +90,8 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar) cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button) button_layout.addWidget(cal_button)
schedule_button = QPushButton("🗓️ 스케줄") schedule_button = QPushButton(tr('view.leave.btn_schedule'))
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기") schedule_button.setToolTip(tr('view.leave.schedule_tooltip'))
schedule_button.clicked.connect(self._show_schedule) schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_button) button_layout.addWidget(schedule_button)
@ -137,16 +137,16 @@ class LeaveView(QDialog):
days = record['days'] days = record['days']
hours = days * 8 hours = days * 8
if days == 1.0: if days == 1.0:
days_str = "1일" days_str = tr('view.leave.used_1day')
elif days == 0.5: elif days == 0.5:
days_str = "0.5일 (4시간)" days_str = tr('view.leave.used_half_day')
elif hours < 8: elif hours < 8:
days_str = f"{days}일 ({hours}시간)" days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours)
else: else:
days_str = f"{days}" days_str = tr('view.leave.used_days_fmt', days=days)
days_item = QTableWidgetItem(days_str) days_item = QTableWidgetItem(days_str)
days_item.setTextAlignment(Qt.AlignCenter) days_item.setTextAlignment(Qt.AlignCenter)
days_item.setForeground(QColor(231, 76, 60)) # 빨간색 days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
memo_item = QTableWidgetItem(record['memo'] or "") memo_item = QTableWidgetItem(record['memo'] or "")
@ -365,17 +365,17 @@ class AddLeaveDialog(QDialog):
if date_dt.weekday() in (5, 6): # 토/일 if date_dt.weekday() in (5, 6): # 토/일
QMessageBox.warning( QMessageBox.warning(
self, self,
"주말 등록 불가", tr('view.leave.weekend_register_forbidden_title'),
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)" tr('view.leave.weekend_register_forbidden_body')
) )
return return
if self.db.is_holiday(date): if self.db.is_holiday(date):
holiday = self.db.get_holiday(date) holiday = self.db.get_holiday(date)
name = (holiday or {}).get('name', '공휴일') name = (holiday or {}).get('name', tr('label.holiday_default'))
QMessageBox.warning( QMessageBox.warning(
self, self,
"공휴일 등록 불가", tr('view.leave.holiday_register_forbidden_title'),
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다." tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
) )
return return
@ -385,9 +385,8 @@ class AddLeaveDialog(QDialog):
if existing_days + days > 1.0001: # 부동소수점 여유 if existing_days + days > 1.0001: # 부동소수점 여유
QMessageBox.warning( QMessageBox.warning(
self, self,
"중복 등록 초과", tr('view.leave.duplicate_register_title'),
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n" tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days)
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
) )
return return

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QMessageBox) QPushButton, QTimeEdit, QMessageBox)
from PyQt5.QtCore import QTime from PyQt5.QtCore import QTime
from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
@ -35,8 +36,8 @@ class MealTimeDialog(QDialog):
self.meal_type = meal_type self.meal_type = meal_type
self._clock_in = clock_in_time self._clock_in = clock_in_time
self._clock_out = clock_out_time self._clock_out = clock_out_time
title_kr = '점심' if meal_type == 'lunch' else '저녁' meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short')
self.setWindowTitle(f"{title_kr} 시간 입력") self.setWindowTitle(tr('meal.dialog_title', meal=meal_label))
self.setModal(True) self.setModal(True)
self.setFixedSize(380, 260) self.setFixedSize(380, 260)
@ -44,13 +45,12 @@ class MealTimeDialog(QDialog):
layout.setSpacing(10) layout.setSpacing(10)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 16, 20, 16)
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n" info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
if clock_in_time is not None: if clock_in_time is not None:
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능." info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M'))
info = QLabel(info_text) info = QLabel(info_text)
info.setWordWrap(True) info.setWordWrap(True)
info.setStyleSheet("color: #888; padding-bottom: 6px;") info.setStyleSheet("color: #909296; padding-bottom: 6px;")
layout.addWidget(info) layout.addWidget(info)
# 합리적 기본값: 출근 이후로 보정 # 합리적 기본값: 출근 이후로 보정
@ -63,7 +63,7 @@ class MealTimeDialog(QDialog):
# 시작 # 시작
start_row = QHBoxLayout() start_row = QHBoxLayout()
start_row.addWidget(QLabel("시작:")) start_row.addWidget(QLabel(tr('meal.label_start')))
self.start_edit = QTimeEdit() self.start_edit = QTimeEdit()
self.start_edit.setDisplayFormat("HH:mm") self.start_edit.setDisplayFormat("HH:mm")
self.start_edit.setTime(QTime(default_start_h, 0)) self.start_edit.setTime(QTime(default_start_h, 0))
@ -73,7 +73,7 @@ class MealTimeDialog(QDialog):
# 종료 # 종료
end_row = QHBoxLayout() end_row = QHBoxLayout()
end_row.addWidget(QLabel("종료:")) end_row.addWidget(QLabel(tr('meal.label_end')))
self.end_edit = QTimeEdit() self.end_edit = QTimeEdit()
self.end_edit.setDisplayFormat("HH:mm") self.end_edit.setDisplayFormat("HH:mm")
self.end_edit.setTime(QTime(default_end_h, 0)) self.end_edit.setTime(QTime(default_end_h, 0))
@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
# 미리보기 라벨 # 미리보기 라벨
self.preview = QLabel("") self.preview = QLabel("")
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;") self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
layout.addWidget(self.preview) layout.addWidget(self.preview)
self._update_preview() self._update_preview()
self.start_edit.timeChanged.connect(self._update_preview) self.start_edit.timeChanged.connect(self._update_preview)
@ -92,10 +92,10 @@ class MealTimeDialog(QDialog):
# 버튼 # 버튼
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
ok_btn = QPushButton("저장") ok_btn = QPushButton(tr('btn.save'))
ok_btn.setObjectName("btn_primary") ok_btn.setObjectName("btn_primary")
ok_btn.clicked.connect(self.accept) ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("취소") cancel_btn = QPushButton(tr('btn.cancel'))
cancel_btn.clicked.connect(self.reject) cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(ok_btn) btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn) btn_row.addWidget(cancel_btn)
@ -137,24 +137,24 @@ class MealTimeDialog(QDialog):
start_dt, end_dt, minutes = self._resolve_meal_window() start_dt, end_dt, minutes = self._resolve_meal_window()
ok, reason = self._validate_window(start_dt, end_dt, minutes) ok, reason = self._validate_window(start_dt, end_dt, minutes)
if not ok: if not ok:
self.preview.setText(f"⚠️ {reason}") self.preview.setText(reason)
self.preview.setStyleSheet("color: #f44336;") self.preview.setStyleSheet("color: #FA5252;")
else: else:
self.preview.setText(f"{minutes}") self.preview.setText(tr('meal.preview_total', minutes=minutes))
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;") self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
def _validate_window(self, start_dt: datetime, end_dt: datetime, def _validate_window(self, start_dt: datetime, end_dt: datetime,
minutes: int) -> tuple[bool, str]: minutes: int) -> tuple[bool, str]:
"""식사 시각이 출/퇴근 범위와 정합인지 검증.""" """식사 시각이 출/퇴근 범위와 정합인지 검증."""
if minutes <= 0: if minutes <= 0:
return False, "시작이 종료보다 늦습니다" return False, tr('meal.error_start_after_end')
if minutes > 8 * 60: if minutes > 8 * 60:
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음 # 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
return False, "식사 시간이 8시간을 초과합니다" return False, tr('meal.error_too_long')
if self._clock_in is not None and start_dt < self._clock_in: if self._clock_in is not None and start_dt < self._clock_in:
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다" return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M'))
if self._clock_out is not None and end_dt > self._clock_out: if self._clock_out is not None and end_dt > self._clock_out:
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다" return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M'))
return True, "" return True, ""
def accept(self): def accept(self):
@ -162,7 +162,7 @@ class MealTimeDialog(QDialog):
start_dt, end_dt, minutes = self._resolve_meal_window() start_dt, end_dt, minutes = self._resolve_meal_window()
ok, reason = self._validate_window(start_dt, end_dt, minutes) ok, reason = self._validate_window(start_dt, end_dt, minutes)
if not ok: if not ok:
QMessageBox.warning(self, "입력 오류", reason) QMessageBox.warning(self, tr('meal.input_error_title'), reason)
return return
super().accept() super().accept()

View File

@ -41,7 +41,7 @@ class MiniWidget(QWidget):
self.title_label = QLabel(tr('label.remaining')) self.title_label = QLabel(tr('label.remaining'))
self.title_label.setAlignment(Qt.AlignCenter) self.title_label.setAlignment(Qt.AlignCenter)
self.title_label.setStyleSheet("color: #888; font-size: 11px;") self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
self.time_label = QLabel("--:--:--") self.time_label = QLabel("--:--:--")
self.time_label.setAlignment(Qt.AlignCenter) self.time_label.setAlignment(Qt.AlignCenter)
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
layout.addWidget(self.time_label) layout.addWidget(self.time_label)
self.setLayout(layout) self.setLayout(layout)
# 기본 스타일 (테마 무관 가독성 유지) # 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
self.setStyleSheet(""" self.setStyleSheet("""
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; } QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
QLabel { color: #fff; } QLabel { color: #E9ECEF; background: transparent; }
""") """)
apply_dark_titlebar(self) apply_dark_titlebar(self)
@ -63,11 +63,12 @@ class MiniWidget(QWidget):
"""메인 윈도우에서 호출 — 남은 시간 동기화.""" """메인 윈도우에서 호출 — 남은 시간 동기화."""
self.time_label.setText(remaining_str) self.time_label.setText(remaining_str)
if remaining_str.startswith('+'): if remaining_str.startswith('+'):
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
self.title_label.setText(tr('label.overtime_progress')) self.title_label.setText(tr('label.overtime_progress'))
self.time_label.setStyleSheet("color: #ff6b6b;") self.time_label.setStyleSheet("color: #51CF66;")
else: else:
self.title_label.setText(tr('label.remaining')) self.title_label.setText(tr('label.remaining'))
self.time_label.setStyleSheet("color: #fff;") self.time_label.setStyleSheet("color: #E9ECEF;")
# 드래그 이동 # 드래그 이동
def mousePressEvent(self, event: QMouseEvent): def mousePressEvent(self, event: QMouseEvent):
@ -90,8 +91,19 @@ class MiniWidget(QWidget):
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
from PyQt5.QtWidgets import QMenu from PyQt5.QtWidgets import QMenu
menu = QMenu(self) menu = QMenu(self)
open_main = menu.addAction("메인 창 열기") # 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
close_mini = menu.addAction("미니 위젯 닫기") # 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
qss = self.parent_window.styleSheet() if self.parent_window else ''
if not qss:
try:
from ui.styles import get_theme
qss = get_theme('dark')
except Exception:
qss = ''
if qss:
menu.setStyleSheet(qss)
open_main = menu.addAction(tr('mini.open_main'))
close_mini = menu.addAction(tr('mini.close'))
action = menu.exec_(event.globalPos()) action = menu.exec_(event.globalPos())
if action == open_main and self.parent_window: if action == open_main and self.parent_window:
self.parent_window.show() self.parent_window.show()

View File

@ -32,17 +32,10 @@ WORK_PRESETS = [
class WelcomePage(QWizardPage): class WelcomePage(QWizardPage):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setTitle("👋 환영합니다!") self.setTitle(tr('onboarding.welcome_title'))
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.") self.setSubTitle(tr('onboarding.welcome_subtitle'))
layout = QVBoxLayout() layout = QVBoxLayout()
intro = QLabel( intro = QLabel(tr('onboarding.welcome_intro'))
"이 앱은:\n"
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
"• 30분 단위 연장근무 적립\n"
"• 연차·반차·외출 시간 추적\n"
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
"[다음] 버튼을 눌러 시작하세요."
)
intro.setWordWrap(True) intro.setWordWrap(True)
layout.addWidget(intro) layout.addWidget(intro)
self.setLayout(layout) self.setLayout(layout)
@ -51,8 +44,8 @@ class WelcomePage(QWizardPage):
class WorkPatternPage(QWizardPage): class WorkPatternPage(QWizardPage):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setTitle("🕘 근무 패턴") self.setTitle(tr('onboarding.work_pattern_title'))
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.") self.setSubTitle(tr('onboarding.work_pattern_subtitle'))
layout = QVBoxLayout() layout = QVBoxLayout()
self.button_group = QButtonGroup(self) self.button_group = QButtonGroup(self)
@ -127,22 +120,20 @@ class WorkPatternPage(QWizardPage):
class ClockInDetectionPage(QWizardPage): class ClockInDetectionPage(QWizardPage):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setTitle("⏰ 출근 시간 감지 방식") self.setTitle(tr('onboarding.detection_title'))
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.") self.setSubTitle(tr('onboarding.detection_subtitle'))
layout = QVBoxLayout() layout = QVBoxLayout()
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)") self.option_boot = QRadioButton(tr('onboarding.detection_boot'))
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)") self.option_unlock = QRadioButton(tr('onboarding.detection_unlock'))
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)") self.option_manual = QRadioButton(tr('onboarding.detection_manual'))
self.option_boot.setChecked(True) self.option_boot.setChecked(True)
for opt in (self.option_boot, self.option_unlock, self.option_manual): for opt in (self.option_boot, self.option_unlock, self.option_manual):
layout.addWidget(opt) layout.addWidget(opt)
info = QLabel( info = QLabel(tr('onboarding.detection_info'))
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
)
info.setWordWrap(True) info.setWordWrap(True)
info.setStyleSheet("color: #888; padding: 8px;") info.setStyleSheet("color: #909296; padding: 8px;")
layout.addWidget(info) layout.addWidget(info)
layout.addStretch() layout.addStretch()
@ -159,35 +150,35 @@ class ClockInDetectionPage(QWizardPage):
class LeaveSalaryPage(QWizardPage): class LeaveSalaryPage(QWizardPage):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setTitle("🌴 연차 + 💰 급여 (옵션)") self.setTitle(tr('onboarding.leave_salary_title'))
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.") self.setSubTitle(tr('onboarding.leave_salary_subtitle'))
layout = QVBoxLayout() layout = QVBoxLayout()
# 연차 # 연차
leave_box = QGroupBox("연간 연차") leave_box = QGroupBox(tr('onboarding.leave_group'))
leave_layout = QHBoxLayout() leave_layout = QHBoxLayout()
self.leave_spin = QSpinBox() self.leave_spin = QSpinBox()
self.leave_spin.setRange(0, 30) self.leave_spin.setRange(0, 30)
self.leave_spin.setValue(15) self.leave_spin.setValue(15)
self.leave_spin.setSuffix("") self.leave_spin.setSuffix(tr('label.unit_day'))
leave_layout.addWidget(QLabel("내 연차:")) leave_layout.addWidget(QLabel(tr('onboarding.my_leave')))
leave_layout.addWidget(self.leave_spin) leave_layout.addWidget(self.leave_spin)
leave_layout.addStretch() leave_layout.addStretch()
leave_box.setLayout(leave_layout) leave_box.setLayout(leave_layout)
layout.addWidget(leave_box) layout.addWidget(leave_box)
# 급여 (옵션) # 급여 (옵션)
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)") salary_box = QGroupBox(tr('onboarding.salary_group'))
salary_layout = QVBoxLayout() salary_layout = QVBoxLayout()
self.salary_enabled = QCheckBox("급여 추정 활성화") self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled'))
salary_layout.addWidget(self.salary_enabled) salary_layout.addWidget(self.salary_enabled)
wage_row = QHBoxLayout() wage_row = QHBoxLayout()
wage_row.addWidget(QLabel("시급:")) wage_row.addWidget(QLabel(tr('onboarding.hourly_wage')))
self.wage_spin = QSpinBox() self.wage_spin = QSpinBox()
self.wage_spin.setRange(0, 1000000) self.wage_spin.setRange(0, 1000000)
self.wage_spin.setSingleStep(1000) self.wage_spin.setSingleStep(1000)
self.wage_spin.setSuffix(" 원/시간") self.wage_spin.setSuffix(tr('onboarding.wage_suffix'))
self.wage_spin.setValue(0) self.wage_spin.setValue(0)
self.wage_spin.setEnabled(False) self.wage_spin.setEnabled(False)
wage_row.addWidget(self.wage_spin) wage_row.addWidget(self.wage_spin)
@ -195,11 +186,11 @@ class LeaveSalaryPage(QWizardPage):
salary_layout.addLayout(wage_row) salary_layout.addLayout(wage_row)
rate_row = QHBoxLayout() rate_row = QHBoxLayout()
rate_row.addWidget(QLabel("연장수당 가산률:")) rate_row.addWidget(QLabel(tr('onboarding.overtime_rate')))
self.rate_combo = QComboBox() self.rate_combo = QComboBox()
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0) self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0)
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5) self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5)
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0) self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0)
self.rate_combo.setCurrentIndex(1) self.rate_combo.setCurrentIndex(1)
self.rate_combo.setEnabled(False) self.rate_combo.setEnabled(False)
rate_row.addWidget(self.rate_combo) rate_row.addWidget(self.rate_combo)
@ -218,30 +209,25 @@ class LeaveSalaryPage(QWizardPage):
class DiscordPage(QWizardPage): class DiscordPage(QWizardPage):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setTitle("💬 Discord 알림 (선택)") self.setTitle(tr('onboarding.discord_title'))
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)") self.setSubTitle(tr('onboarding.discord_subtitle'))
layout = QVBoxLayout() layout = QVBoxLayout()
self.enable_check = QCheckBox("Discord 웹훅 알림 사용") self.enable_check = QCheckBox(tr('onboarding.discord_enable'))
layout.addWidget(self.enable_check) layout.addWidget(self.enable_check)
self.url_edit = QLineEdit() self.url_edit = QLineEdit()
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...") self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder'))
self.url_edit.setEnabled(False) self.url_edit.setEnabled(False)
layout.addWidget(self.url_edit) layout.addWidget(self.url_edit)
guide = QLabel( guide = QLabel(tr('onboarding.discord_guide'))
"셋업 방법:\n" guide.setStyleSheet("color: #909296; padding: 6px;")
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
"2. 새 웹훅 만들기 → URL 복사\n"
"3. 위 입력란에 붙여넣기"
)
guide.setStyleSheet("color: #888; padding: 6px;")
guide.setWordWrap(True) guide.setWordWrap(True)
layout.addWidget(guide) layout.addWidget(guide)
test_row = QHBoxLayout() test_row = QHBoxLayout()
self.test_btn = QPushButton("테스트 메시지 보내기") self.test_btn = QPushButton(tr('onboarding.discord_test'))
self.test_btn.setEnabled(False) self.test_btn.setEnabled(False)
self.test_btn.clicked.connect(self._test_webhook) self.test_btn.clicked.connect(self._test_webhook)
test_row.addWidget(self.test_btn) test_row.addWidget(self.test_btn)
@ -257,39 +243,30 @@ class DiscordPage(QWizardPage):
def _test_webhook(self): def _test_webhook(self):
url = self.url_edit.text().strip() url = self.url_edit.text().strip()
if not url: if not url:
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.") QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body'))
return return
from utils import discord_webhook from utils import discord_webhook
if not discord_webhook.is_valid_webhook_url(url): if not discord_webhook.is_valid_webhook_url(url):
QMessageBox.warning( QMessageBox.warning(
self, "URL 형식 오류", self, tr('onboarding.discord_url_invalid_title'),
"Discord 웹훅 URL 형식이 아닙니다.\n" tr('onboarding.discord_url_invalid_body')
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
) )
return return
ok = discord_webhook.send_test(url) ok = discord_webhook.send_test(url)
if ok: if ok:
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.") QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body'))
else: else:
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.") QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body'))
class FinishPage(QWizardPage): class FinishPage(QWizardPage):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setTitle("🎉 준비 완료!") self.setTitle(tr('onboarding.finish_title'))
self.setSubTitle("이제 출근부터 자동 추적됩니다.") self.setSubTitle(tr('onboarding.finish_subtitle'))
layout = QVBoxLayout() layout = QVBoxLayout()
msg = QLabel( msg = QLabel(tr('onboarding.finish_msg'))
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
"🕐 단축키:\n"
" • Ctrl+O — 출퇴근 토글\n"
" • F1 — 도움말\n"
" • F5 — 업데이트 확인\n"
" • Ctrl+, — 설정"
)
msg.setWordWrap(True) msg.setWordWrap(True)
layout.addWidget(msg) layout.addWidget(msg)
self.setLayout(layout) self.setLayout(layout)
@ -301,7 +278,7 @@ class OnboardingWizard(QWizard):
def __init__(self, db, parent=None): def __init__(self, db, parent=None):
super().__init__(parent) super().__init__(parent)
self.db = db self.db = db
self.setWindowTitle("Clock-out Calculator — 시작 설정") self.setWindowTitle(tr('onboarding.window_title'))
self.setMinimumSize(600, 500) self.setMinimumSize(600, 500)
self.setWizardStyle(QWizard.ModernStyle) self.setWizardStyle(QWizard.ModernStyle)
self.setOption(QWizard.NoBackButtonOnStartPage, True) self.setOption(QWizard.NoBackButtonOnStartPage, True)
@ -323,7 +300,7 @@ class OnboardingWizard(QWizard):
# 1. 근무 패턴 # 1. 근무 패턴
wm, lm, dm = self.work_page.selected_minutes() wm, lm, dm = self.work_page.selected_minutes()
if wm < 30: if wm < 30:
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.") QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small'))
return return
settings = { settings = {

View File

@ -66,6 +66,8 @@ class OvertimeView(QDialog):
self.earned_table.setAlternatingRowColors(True) self.earned_table.setAlternatingRowColors(True)
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers) self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
earned_layout.addWidget(self.earned_table) earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned')) add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
@ -126,7 +128,7 @@ class OvertimeView(QDialog):
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
FROM overtime_bank ob FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC ORDER BY ob.date DESC
@ -138,6 +140,7 @@ class OvertimeView(QDialog):
for i, record in enumerate(earned_records): for i, record in enumerate(earned_records):
date_item = QTableWidgetItem(record[0]) date_item = QTableWidgetItem(record[0])
date_item.setTextAlignment(Qt.AlignCenter) date_item.setTextAlignment(Qt.AlignCenter)
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
minutes = record[1] minutes = record[1]
hours = minutes // 60 hours = minutes // 60
@ -148,7 +151,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins) time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str) time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter) time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색 time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo # work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "") memo_text = manual_label if record[2] is None else (record[3] or "")
@ -183,7 +186,7 @@ class OvertimeView(QDialog):
time_str = tr('view.break.duration_min_only', m=mins) time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str) time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter) time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색 time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
reason_item = QTableWidgetItem(record[3] or "") reason_item = QTableWidgetItem(record[3] or "")
@ -249,6 +252,46 @@ class OvertimeView(QDialog):
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance() self.parent().update_overtime_balance()
def show_earned_context_menu(self, position):
"""적립 내역 우클릭 메뉴 (삭제)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
menu = QMenu(self)
delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_earned_record)
menu.addAction(delete_action)
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
def delete_earned_record(self):
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
row = selected_rows[0].row()
date_item = self.earned_table.item(row, 0)
time_item = self.earned_table.item(row, 1)
# 행에 저장된 overtime_bank.id
bank_id = date_item.data(Qt.UserRole)
if bank_id is None:
return
reply = QMessageBox.question(
self,
tr('msg.confirm_delete.title'),
tr('view.overtime.delete_earned_confirm_body',
date=date_item.text(), time=time_item.text()),
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_overtime_earned(bank_id)
self.load_data()
# 부모 윈도우 잔액 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def add_earned_record(self): def add_earned_record(self):
"""수동 적립 추가""" """수동 적립 추가"""
dialog = AddOvertimeEarnedDialog(self, self.db) dialog = AddOvertimeEarnedDialog(self, self.db)

View File

@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QMessageBox) QMessageBox)
from PyQt5.QtCore import QTime, Qt from PyQt5.QtCore import QTime, Qt
from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
@ -18,7 +19,7 @@ class PastRecordDialog(QDialog):
def __init__(self, parent=None, date_str: str = ''): def __init__(self, parent=None, date_str: str = ''):
super().__init__(parent) super().__init__(parent)
self.date_str = date_str self.date_str = date_str
self.setWindowTitle(f"기록 추가 — {date_str}") self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
self.setModal(True) self.setModal(True)
self.setFixedSize(380, 320) self.setFixedSize(380, 320)
@ -26,13 +27,13 @@ class PastRecordDialog(QDialog):
layout.setSpacing(8) layout.setSpacing(8)
layout.setContentsMargins(20, 16, 20, 16) layout.setContentsMargins(20, 16, 20, 16)
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.") info = QLabel(tr('past_record.info', date=date_str))
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;") info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
layout.addWidget(info) layout.addWidget(info)
# 출근 # 출근
ci_row = QHBoxLayout() ci_row = QHBoxLayout()
ci_row.addWidget(QLabel("출근:")) ci_row.addWidget(QLabel(tr('past_record.label_clock_in')))
self.clock_in_edit = QTimeEdit() self.clock_in_edit = QTimeEdit()
self.clock_in_edit.setDisplayFormat("HH:mm") self.clock_in_edit.setDisplayFormat("HH:mm")
self.clock_in_edit.setTime(QTime(9, 0)) self.clock_in_edit.setTime(QTime(9, 0))
@ -42,8 +43,8 @@ class PastRecordDialog(QDialog):
# 퇴근 # 퇴근
co_row = QHBoxLayout() co_row = QHBoxLayout()
co_row.addWidget(QLabel("퇴근:")) co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
self.clock_out_check = QCheckBox("입력") self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
self.clock_out_check.setChecked(True) self.clock_out_check.setChecked(True)
self.clock_out_edit = QTimeEdit() self.clock_out_edit = QTimeEdit()
self.clock_out_edit.setDisplayFormat("HH:mm") self.clock_out_edit.setDisplayFormat("HH:mm")
@ -56,18 +57,18 @@ class PastRecordDialog(QDialog):
# 점심/저녁 # 점심/저녁
meal_row = QHBoxLayout() meal_row = QHBoxLayout()
self.lunch_check = QCheckBox("🍱 점심시간 포함") self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
self.lunch_check.setChecked(True) self.lunch_check.setChecked(True)
self.dinner_check = QCheckBox("🍽 저녁시간 포함") self.dinner_check = QCheckBox(tr('past_record.check_dinner'))
meal_row.addWidget(self.lunch_check) meal_row.addWidget(self.lunch_check)
meal_row.addWidget(self.dinner_check) meal_row.addWidget(self.dinner_check)
meal_row.addStretch() meal_row.addStretch()
layout.addLayout(meal_row) layout.addLayout(meal_row)
# 메모 # 메모
layout.addWidget(QLabel("메모 (선택):")) layout.addWidget(QLabel(tr('past_record.label_memo')))
self.memo_edit = QLineEdit() self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가") self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
layout.addWidget(self.memo_edit) layout.addWidget(self.memo_edit)
layout.addStretch() layout.addStretch()
@ -75,10 +76,10 @@ class PastRecordDialog(QDialog):
# 버튼 # 버튼
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
ok_btn = QPushButton("저장") ok_btn = QPushButton(tr('btn.save'))
ok_btn.setObjectName("btn_primary") ok_btn.setObjectName("btn_primary")
ok_btn.clicked.connect(self._validate_and_accept) ok_btn.clicked.connect(self._validate_and_accept)
cancel_btn = QPushButton("취소") cancel_btn = QPushButton(tr('btn.cancel'))
cancel_btn.clicked.connect(self.reject) cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(ok_btn) btn_row.addWidget(ok_btn)
btn_row.addWidget(cancel_btn) btn_row.addWidget(cancel_btn)
@ -92,8 +93,8 @@ class PastRecordDialog(QDialog):
ci = self.clock_in_edit.time() ci = self.clock_in_edit.time()
co = self.clock_out_edit.time() co = self.clock_out_edit.time()
if co <= ci: if co <= ci:
QMessageBox.warning(self, "입력 오류", QMessageBox.warning(self, tr('past_record.input_error_title'),
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.") tr('past_record.input_error_body'))
return return
self.accept() self.accept()

View File

@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
from PyQt5.QtCore import QDate, Qt from PyQt5.QtCore import QDate, Qt
from core.recurring_leaves import describe_pattern from core.recurring_leaves import describe_pattern
from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
@ -27,7 +28,7 @@ class RecurringLeaveDialog(QDialog):
def __init__(self, parent=None, db=None): def __init__(self, parent=None, db=None):
super().__init__(parent) super().__init__(parent)
self.db = db self.db = db
self.setWindowTitle("🔁 반복 연차 관리") self.setWindowTitle(tr('recurring.title'))
self.setMinimumSize(540, 480) self.setMinimumSize(540, 480)
self._build_ui() self._build_ui()
self._reload_list() self._reload_list()
@ -37,29 +38,29 @@ class RecurringLeaveDialog(QDialog):
layout = QVBoxLayout() layout = QVBoxLayout()
# 기존 패턴 목록 # 기존 패턴 목록
list_group = QGroupBox("등록된 반복 패턴") list_group = QGroupBox(tr('recurring.list_group'))
lg = QVBoxLayout() lg = QVBoxLayout()
self.list_widget = QListWidget() self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(160) self.list_widget.setMinimumHeight(160)
lg.addWidget(self.list_widget) lg.addWidget(self.list_widget)
del_btn = QPushButton("선택 삭제") del_btn = QPushButton(tr('recurring.btn_delete_selected'))
del_btn.clicked.connect(self._delete_selected) del_btn.clicked.connect(self._delete_selected)
lg.addWidget(del_btn) lg.addWidget(del_btn)
list_group.setLayout(lg) list_group.setLayout(lg)
layout.addWidget(list_group) layout.addWidget(list_group)
# 신규 등록 # 신규 등록
add_group = QGroupBox("신규 패턴 추가") add_group = QGroupBox(tr('recurring.add_group'))
ag = QVBoxLayout() ag = QVBoxLayout()
# 패턴 종류 # 패턴 종류
kind_row = QHBoxLayout() kind_row = QHBoxLayout()
kind_row.addWidget(QLabel("주기:")) kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
self.kind_group = QButtonGroup(self) self.kind_group = QButtonGroup(self)
self.rb_weekly = QRadioButton("매주") self.rb_weekly = QRadioButton(tr('recurring.weekly'))
self.rb_weekly.setChecked(True) self.rb_weekly.setChecked(True)
self.rb_biweekly = QRadioButton("격주") self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
self.rb_monthly = QRadioButton("매월 N일") self.rb_monthly = QRadioButton(tr('recurring.monthly'))
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly): for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
self.kind_group.addButton(rb) self.kind_group.addButton(rb)
kind_row.addWidget(rb) kind_row.addWidget(rb)
@ -68,10 +69,10 @@ class RecurringLeaveDialog(QDialog):
# 요일 체크박스 (weekly/biweekly) # 요일 체크박스 (weekly/biweekly)
wd_row = QHBoxLayout() wd_row = QHBoxLayout()
wd_row.addWidget(QLabel("요일:")) wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
self.weekday_checks = [] self.weekday_checks = []
for ko, en in _KO_WEEKDAYS: for ko, en in _KO_WEEKDAYS:
cb = QCheckBox(ko) cb = QCheckBox(tr(f'label.weekday_{en}'))
self.weekday_checks.append((cb, en)) self.weekday_checks.append((cb, en))
wd_row.addWidget(cb) wd_row.addWidget(cb)
wd_row.addStretch() wd_row.addStretch()
@ -79,40 +80,40 @@ class RecurringLeaveDialog(QDialog):
# 매월 N일 # 매월 N일
month_row = QHBoxLayout() month_row = QHBoxLayout()
month_row.addWidget(QLabel("매월:")) month_row.addWidget(QLabel(tr('recurring.label_monthly_day')))
self.day_of_month = QSpinBox() self.day_of_month = QSpinBox()
self.day_of_month.setRange(1, 31) self.day_of_month.setRange(1, 31)
self.day_of_month.setValue(15) self.day_of_month.setValue(15)
self.day_of_month.setSuffix("") self.day_of_month.setSuffix(tr('recurring.day_suffix'))
month_row.addWidget(self.day_of_month) month_row.addWidget(self.day_of_month)
month_row.addStretch() month_row.addStretch()
ag.addLayout(month_row) ag.addLayout(month_row)
# 차감 일수 # 차감 일수
days_row = QHBoxLayout() days_row = QHBoxLayout()
days_row.addWidget(QLabel("차감:")) days_row.addWidget(QLabel(tr('recurring.label_deduction')))
self.days_combo = QComboBox() self.days_combo = QComboBox()
self.days_combo.addItem("1.0일 (종일)", 1.0) self.days_combo.addItem(tr('recurring.deduction_full'), 1.0)
self.days_combo.addItem("0.5일 (반차)", 0.5) self.days_combo.addItem(tr('recurring.deduction_half'), 0.5)
self.days_combo.addItem("0.25일 (반반차)", 0.25) self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25)
days_row.addWidget(self.days_combo) days_row.addWidget(self.days_combo)
days_row.addStretch() days_row.addStretch()
ag.addLayout(days_row) ag.addLayout(days_row)
# 시작/종료 날짜 # 시작/종료 날짜
date_row = QHBoxLayout() date_row = QHBoxLayout()
date_row.addWidget(QLabel("시작:")) date_row.addWidget(QLabel(tr('recurring.label_start')))
self.start_edit = QDateEdit() self.start_edit = QDateEdit()
self.start_edit.setDate(QDate.currentDate()) self.start_edit.setDate(QDate.currentDate())
self.start_edit.setCalendarPopup(True) self.start_edit.setCalendarPopup(True)
date_row.addWidget(self.start_edit) date_row.addWidget(self.start_edit)
date_row.addWidget(QLabel("종료:")) date_row.addWidget(QLabel(tr('recurring.label_end')))
self.end_edit = QDateEdit() self.end_edit = QDateEdit()
self.end_edit.setDate(QDate.currentDate().addMonths(6)) self.end_edit.setDate(QDate.currentDate().addMonths(6))
self.end_edit.setCalendarPopup(True) self.end_edit.setCalendarPopup(True)
date_row.addWidget(self.end_edit) date_row.addWidget(self.end_edit)
self.no_end_check = QCheckBox("종료 없음 (무기한)") self.no_end_check = QCheckBox(tr('recurring.no_end'))
self.no_end_check.toggled.connect( self.no_end_check.toggled.connect(
lambda v: self.end_edit.setEnabled(not v) lambda v: self.end_edit.setEnabled(not v)
) )
@ -122,14 +123,14 @@ class RecurringLeaveDialog(QDialog):
# 메모 # 메모
memo_row = QHBoxLayout() memo_row = QHBoxLayout()
memo_row.addWidget(QLabel("메모:")) memo_row.addWidget(QLabel(tr('recurring.label_memo')))
self.memo_edit = QLineEdit() self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 육아 단축근무") self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
memo_row.addWidget(self.memo_edit) memo_row.addWidget(self.memo_edit)
ag.addLayout(memo_row) ag.addLayout(memo_row)
# 추가 버튼 # 추가 버튼
add_btn = QPushButton(" 추가") add_btn = QPushButton(tr('recurring.btn_add'))
add_btn.setObjectName("btn_primary") add_btn.setObjectName("btn_primary")
add_btn.clicked.connect(self._save) add_btn.clicked.connect(self._save)
ag.addWidget(add_btn) ag.addWidget(add_btn)
@ -138,7 +139,7 @@ class RecurringLeaveDialog(QDialog):
layout.addWidget(add_group) layout.addWidget(add_group)
# 닫기 # 닫기
close_btn = QPushButton("닫기") close_btn = QPushButton(tr('btn.close'))
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
layout.addWidget(close_btn) layout.addWidget(close_btn)
@ -148,7 +149,7 @@ class RecurringLeaveDialog(QDialog):
self.list_widget.clear() self.list_widget.clear()
for r in self.db.get_recurring_leaves(): for r in self.db.get_recurring_leaves():
desc = describe_pattern(r['pattern']) desc = describe_pattern(r['pattern'])
end = r.get('end_date') or '무기한' end = r.get('end_date') or tr('recurring.no_end')
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) " text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
f"· {r['start_date']} ~ {end}") f"· {r['start_date']} ~ {end}")
if r.get('memo'): if r.get('memo'):
@ -163,8 +164,8 @@ class RecurringLeaveDialog(QDialog):
return return
rec_id = item.data(Qt.UserRole) rec_id = item.data(Qt.UserRole)
reply = QMessageBox.question( reply = QMessageBox.question(
self, "삭제 확인", self, tr('recurring.delete_confirm_title'),
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}", tr('recurring.delete_confirm_body', item=item.text()),
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
@ -184,7 +185,7 @@ class RecurringLeaveDialog(QDialog):
def _save(self): def _save(self):
pattern = self._build_pattern() pattern = self._build_pattern()
if not pattern: if not pattern:
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.") QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
return return
days = self.days_combo.currentData() days = self.days_combo.currentData()
leave_type = self.days_combo.currentText().split(' ')[1].strip('()') leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
@ -193,7 +194,7 @@ class RecurringLeaveDialog(QDialog):
memo = self.memo_edit.text().strip() memo = self.memo_edit.text().strip()
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo) self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
QMessageBox.information(self, "추가 완료", QMessageBox.information(self, tr('recurring.add_done_title'),
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}") tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
self.memo_edit.clear() self.memo_edit.clear()
self._reload_list() self._reload_list()

View File

@ -18,6 +18,7 @@ from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from core.recurring_leaves import expand_for_range, describe_pattern from core.recurring_leaves import expand_for_range, describe_pattern
from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
@ -38,7 +39,7 @@ class ScheduleView(QDialog):
def __init__(self, parent=None, db=None): def __init__(self, parent=None, db=None):
super().__init__(parent) super().__init__(parent)
self.db = db self.db = db
self.setWindowTitle("🗓️ 스케줄") self.setWindowTitle(tr('schedule.title'))
self.setMinimumSize(820, 560) self.setMinimumSize(820, 560)
self._build_ui() self._build_ui()
self._reload() self._reload()
@ -49,16 +50,16 @@ class ScheduleView(QDialog):
# 상단 툴바 # 상단 툴바
bar = QHBoxLayout() bar = QHBoxLayout()
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴") title = QLabel(tr('schedule.header'))
title.setStyleSheet("font-weight: bold; font-size: 13px;") title.setStyleSheet("font-weight: bold; font-size: 13px;")
bar.addWidget(title) bar.addWidget(title)
bar.addStretch() bar.addStretch()
rec_btn = QPushButton("🔁 반복 패턴 관리") rec_btn = QPushButton(tr('schedule.btn_recurring'))
rec_btn.clicked.connect(self._open_recurring_dialog) rec_btn.clicked.connect(self._open_recurring_dialog)
bar.addWidget(rec_btn) bar.addWidget(rec_btn)
add_btn = QPushButton(" 연차 등록") add_btn = QPushButton(tr('schedule.btn_add_leave'))
add_btn.clicked.connect(self._open_add_leave_dialog) add_btn.clicked.connect(self._open_add_leave_dialog)
bar.addWidget(add_btn) bar.addWidget(add_btn)
@ -66,11 +67,11 @@ class ScheduleView(QDialog):
# 범례 # 범례
legend = QHBoxLayout() legend = QHBoxLayout()
for label, color in [("공휴일", _C_HOLIDAY), for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
("연차 사용", _C_LEAVE_FULL_PAST), (tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
("연차 예정", _C_LEAVE_FULL_PLAN), (tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
("반차/반반차", _C_LEAVE_HALF_PAST), (tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
("반복 패턴", _C_RECURRING)]: (tr('schedule.legend_recurring'), _C_RECURRING)]:
sw = QLabel(f" {label} ") sw = QLabel(f" {label} ")
sw.setStyleSheet( sw.setStyleSheet(
f"background-color: {color.name()}; color: white; " f"background-color: {color.name()}; color: white; "
@ -94,7 +95,7 @@ class ScheduleView(QDialog):
right = QWidget() right = QWidget()
right_layout = QVBoxLayout() right_layout = QVBoxLayout()
self.detail_title = QLabel("날짜를 선택하세요") self.detail_title = QLabel(tr('schedule.detail_placeholder'))
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;") self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
right_layout.addWidget(self.detail_title) right_layout.addWidget(self.detail_title)
@ -109,7 +110,7 @@ class ScheduleView(QDialog):
layout.addWidget(splitter, 1) layout.addWidget(splitter, 1)
close_btn = QPushButton("닫기") close_btn = QPushButton(tr('btn.close'))
close_btn.clicked.connect(self.close) close_btn.clicked.connect(self.close)
layout.addWidget(close_btn) layout.addWidget(close_btn)
@ -189,18 +190,19 @@ class ScheduleView(QDialog):
def _on_date_click(self, qd: QDate): def _on_date_click(self, qd: QDate):
d = date(qd.year(), qd.month(), qd.day()) d = date(qd.year(), qd.month(), qd.day())
date_str = d.isoformat() date_str = d.isoformat()
weekday_kr = ['', '', '', '', '', '', ''] weekday_kr = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)") tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), tr('label.weekday_sun')]
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
self.detail_list.clear() self.detail_list.clear()
# 휴일 # 휴일
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
if holiday: if holiday:
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}") item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
item.setForeground(QBrush(QColor("#e53935"))) item.setForeground(QBrush(QColor("#e53935")))
self.detail_list.addItem(item) self.detail_list.addItem(item)
elif d.weekday() in (5, 6): elif d.weekday() in (5, 6):
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)") item = QListWidgetItem(tr('schedule.weekend', weekday=weekday_kr[d.weekday()]))
self.detail_list.addItem(item) self.detail_list.addItem(item)
# 연차 (구체) # 연차 (구체)
@ -208,7 +210,7 @@ class ScheduleView(QDialog):
days = float(r.get('days') or 0) days = float(r.get('days') or 0)
t = r.get('leave_type', '연차') t = r.get('leave_type', '연차')
memo = r.get('memo') or '' memo = r.get('memo') or ''
label = f"📌 {t} {days}" label = tr('schedule.leave_label', type=t, days=days)
if memo: if memo:
label += f"{memo}" label += f"{memo}"
label += f" [id={r['id']}]" label += f" [id={r['id']}]"
@ -221,13 +223,14 @@ class ScheduleView(QDialog):
from core.recurring_leaves import expand_for_date from core.recurring_leaves import expand_for_date
for occ in expand_for_date(recurring, d): for occ in expand_for_date(recurring, d):
item = QListWidgetItem( item = QListWidgetItem(
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})" tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
days=occ.days, type=occ.leave_type)
) )
item.setData(Qt.UserRole, ('recurring', occ.recurring_id)) item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
self.detail_list.addItem(item) self.detail_list.addItem(item)
if self.detail_list.count() == 0: if self.detail_list.count() == 0:
self.detail_list.addItem("일정 없음") self.detail_list.addItem(tr('schedule.no_events'))
def _on_page_change(self, year: int, month: int): def _on_page_change(self, year: int, month: int):
self._reload() self._reload()
@ -241,7 +244,7 @@ class ScheduleView(QDialog):
return return
kind, _id = data kind, _id = data
menu = QMenu(self) menu = QMenu(self)
del_act = menu.addAction("삭제") del_act = menu.addAction(tr('schedule.delete'))
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos)) chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
if chosen == del_act: if chosen == del_act:
self._delete_record(kind, _id) self._delete_record(kind, _id)
@ -249,8 +252,8 @@ class ScheduleView(QDialog):
def _delete_record(self, kind: str, _id: int): def _delete_record(self, kind: str, _id: int):
if kind == 'concrete': if kind == 'concrete':
reply = QMessageBox.question( reply = QMessageBox.question(
self, "삭제 확인", self, tr('schedule.delete_leave_confirm_title'),
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)", tr('schedule.delete_leave_confirm_body'),
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
@ -261,8 +264,8 @@ class ScheduleView(QDialog):
self._on_date_click(d) self._on_date_click(d)
elif kind == 'recurring': elif kind == 'recurring':
reply = QMessageBox.question( reply = QMessageBox.question(
self, "삭제 확인", self, tr('schedule.delete_recurring_confirm_title'),
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)", tr('schedule.delete_recurring_confirm_body'),
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:

View File

@ -58,7 +58,7 @@ class SettingsView(QDialog):
main_layout.setSpacing(0) main_layout.setSpacing(0)
# 제목 # 제목
title = QLabel("설정") title = QLabel(tr('settings.title'))
title.setObjectName("dialog_title") title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title) main_layout.addWidget(title)
@ -137,16 +137,16 @@ class SettingsView(QDialog):
# 근무 패턴 프리셋 # 근무 패턴 프리셋
preset_layout = QHBoxLayout() preset_layout = QHBoxLayout()
preset_label = QLabel("근무 패턴:") preset_label = QLabel(tr('settings.work_pattern'))
preset_label.setFixedWidth(130) preset_label.setFixedWidth(130)
self.work_preset_combo = QComboBox() self.work_preset_combo = QComboBox()
# (label, work_minutes, lunch_minutes) # (label, work_minutes, lunch_minutes)
self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60)) self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60))
self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30)) self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30))
self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60)) self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60))
self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30)) self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30))
self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0)) self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0))
self.work_preset_combo.addItem("사용자 정의", None) self.work_preset_combo.addItem(tr('settings.preset.custom'), None)
self.work_preset_combo.setFixedWidth(260) self.work_preset_combo.setFixedWidth(260)
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed) self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
preset_layout.addWidget(preset_label) preset_layout.addWidget(preset_label)
@ -156,18 +156,18 @@ class SettingsView(QDialog):
# 하루 기본 근무 시간 (시 + 분 분리 입력) # 하루 기본 근무 시간 (시 + 분 분리 입력)
work_hours_layout = QHBoxLayout() work_hours_layout = QHBoxLayout()
work_hours_label = QLabel("하루 기본 근무:") work_hours_label = QLabel(tr('settings.daily_work'))
work_hours_label.setFixedWidth(130) work_hours_label.setFixedWidth(130)
self.work_hours_spin = QSpinBox() self.work_hours_spin = QSpinBox()
self.work_hours_spin.setRange(0, 12) self.work_hours_spin.setRange(0, 12)
self.work_hours_spin.setValue(8) self.work_hours_spin.setValue(8)
self.work_hours_spin.setSuffix(" 시간") self.work_hours_spin.setSuffix(tr('settings.suffix_hour'))
self.work_hours_spin.setFixedWidth(100) self.work_hours_spin.setFixedWidth(100)
self.work_minutes_spin = QSpinBox() self.work_minutes_spin = QSpinBox()
self.work_minutes_spin.setRange(0, 59) self.work_minutes_spin.setRange(0, 59)
self.work_minutes_spin.setValue(0) self.work_minutes_spin.setValue(0)
self.work_minutes_spin.setSingleStep(15) self.work_minutes_spin.setSingleStep(15)
self.work_minutes_spin.setSuffix("") self.work_minutes_spin.setSuffix(tr('settings.suffix_minute'))
self.work_minutes_spin.setFixedWidth(100) self.work_minutes_spin.setFixedWidth(100)
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로 # 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit) self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
@ -180,34 +180,34 @@ class SettingsView(QDialog):
# 점심시간 기본값 # 점심시간 기본값
lunch_layout = QHBoxLayout() lunch_layout = QHBoxLayout()
lunch_label = QLabel("점심시간 기본:") lunch_label = QLabel(tr('settings.lunch_default'))
lunch_label.setFixedWidth(130) lunch_label.setFixedWidth(130)
self.lunch_spin = QSpinBox() self.lunch_spin = QSpinBox()
self.lunch_spin.setRange(0, 120) self.lunch_spin.setRange(0, 120)
self.lunch_spin.setValue(60) self.lunch_spin.setValue(60)
self.lunch_spin.setSingleStep(5) self.lunch_spin.setSingleStep(5)
self.lunch_spin.setSuffix("") self.lunch_spin.setSuffix(tr('settings.suffix_minute'))
self.lunch_spin.setFixedWidth(110) self.lunch_spin.setFixedWidth(110)
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit) self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
lunch_layout.addWidget(lunch_label) lunch_layout.addWidget(lunch_label)
lunch_layout.addWidget(self.lunch_spin) lunch_layout.addWidget(self.lunch_spin)
# 점심시간 자동 적용 # 점심시간 자동 적용
self.auto_lunch_check = QCheckBox("자동 적용") self.auto_lunch_check = QCheckBox(tr('settings.auto_apply'))
self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용") self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip'))
lunch_layout.addWidget(self.auto_lunch_check) lunch_layout.addWidget(self.auto_lunch_check)
lunch_layout.addStretch() lunch_layout.addStretch()
layout.addLayout(lunch_layout) layout.addLayout(lunch_layout)
# 저녁시간 기본값 # 저녁시간 기본값
dinner_layout = QHBoxLayout() dinner_layout = QHBoxLayout()
dinner_label = QLabel("저녁시간 기본:") dinner_label = QLabel(tr('settings.dinner_default'))
dinner_label.setFixedWidth(130) dinner_label.setFixedWidth(130)
self.dinner_spin = QSpinBox() self.dinner_spin = QSpinBox()
self.dinner_spin.setRange(0, 120) self.dinner_spin.setRange(0, 120)
self.dinner_spin.setValue(60) self.dinner_spin.setValue(60)
self.dinner_spin.setSingleStep(5) self.dinner_spin.setSingleStep(5)
self.dinner_spin.setSuffix("") self.dinner_spin.setSuffix(tr('settings.suffix_minute'))
self.dinner_spin.setFixedWidth(110) self.dinner_spin.setFixedWidth(110)
dinner_layout.addWidget(dinner_label) dinner_layout.addWidget(dinner_label)
dinner_layout.addWidget(self.dinner_spin) dinner_layout.addWidget(self.dinner_spin)
@ -306,30 +306,30 @@ class SettingsView(QDialog):
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개) # 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
check_row1 = QHBoxLayout() check_row1 = QHBoxLayout()
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림") self.clock_out_notification_check = QCheckBox(tr('settings.notif_clock_out'))
self.clock_out_notification_check.setChecked(True) self.clock_out_notification_check.setChecked(True)
self.lunch_notification_check = QCheckBox("점심시간 등록 알림") self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch'))
self.lunch_notification_check.setChecked(True) self.lunch_notification_check.setChecked(True)
check_row1.addWidget(self.clock_out_notification_check) check_row1.addWidget(self.clock_out_notification_check)
check_row1.addWidget(self.lunch_notification_check) check_row1.addWidget(self.lunch_notification_check)
layout.addLayout(check_row1) layout.addLayout(check_row1)
check_row2 = QHBoxLayout() check_row2 = QHBoxLayout()
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림") self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner'))
self.dinner_notification_check.setChecked(True) self.dinner_notification_check.setChecked(True)
self.overtime_notification_check = QCheckBox("연장근무 적립 알림") self.overtime_notification_check = QCheckBox(tr('settings.notif_overtime'))
self.overtime_notification_check.setChecked(True) self.overtime_notification_check.setChecked(True)
check_row2.addWidget(self.dinner_notification_check) check_row2.addWidget(self.dinner_notification_check)
check_row2.addWidget(self.overtime_notification_check) check_row2.addWidget(self.overtime_notification_check)
layout.addLayout(check_row2) layout.addLayout(check_row2)
check_row3 = QHBoxLayout() check_row3 = QHBoxLayout()
self.health_notification_check = QCheckBox("건강 경고 알림") self.health_notification_check = QCheckBox(tr('settings.notif_health'))
self.health_notification_check.setChecked(True) self.health_notification_check.setChecked(True)
self.health_break_notification_check = QCheckBox("휴식 권고 알림") self.health_break_notification_check = QCheckBox(tr('settings.notif_break'))
self.health_break_notification_check.setChecked(True) self.health_break_notification_check.setChecked(True)
self.health_break_notification_check.setToolTip( self.health_break_notification_check.setToolTip(
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)" tr('settings.notif_break_tooltip')
) )
check_row3.addWidget(self.health_notification_check) check_row3.addWidget(self.health_notification_check)
check_row3.addWidget(self.health_break_notification_check) check_row3.addWidget(self.health_break_notification_check)
@ -337,75 +337,75 @@ class SettingsView(QDialog):
# 퇴근 N분 전 알림 시점 설정 # 퇴근 N분 전 알림 시점 설정
before_row = QHBoxLayout() before_row = QHBoxLayout()
before_label = QLabel("퇴근 알림 시점:") before_label = QLabel(tr('settings.notif_before'))
before_label.setFixedWidth(110) before_label.setFixedWidth(110)
self.notif_before_spin = QSpinBox() self.notif_before_spin = QSpinBox()
self.notif_before_spin.setRange(1, 120) self.notif_before_spin.setRange(1, 120)
self.notif_before_spin.setSingleStep(5) self.notif_before_spin.setSingleStep(5)
self.notif_before_spin.setValue(30) self.notif_before_spin.setValue(30)
self.notif_before_spin.setSuffix(" 분 전") self.notif_before_spin.setSuffix(' ' + tr('settings.notif_before_spin_suffix'))
self.notif_before_spin.setFixedWidth(110) self.notif_before_spin.setFixedWidth(110)
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)") self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip'))
before_row.addWidget(before_label) before_row.addWidget(before_label)
before_row.addWidget(self.notif_before_spin) before_row.addWidget(self.notif_before_spin)
before_row.addStretch() before_row.addStretch()
layout.addLayout(before_row) layout.addLayout(before_row)
# === 고급 임계값 (접이식 그룹박스) === # === 고급 임계값 (접이식 그룹박스) ===
adv_box = QGroupBox("고급 임계값") adv_box = QGroupBox(tr('settings.advanced_thresholds'))
adv_box.setCheckable(True) adv_box.setCheckable(True)
adv_box.setChecked(False) # 기본 접힘 adv_box.setChecked(False) # 기본 접힘
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정") adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip'))
adv_layout = QVBoxLayout() adv_layout = QVBoxLayout()
adv_layout.setSpacing(4) adv_layout.setSpacing(4)
# 점심 알림 임계 (출근 후 N시간) # 점심 알림 임계 (출근 후 N시간)
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간") self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림") self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip'))
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin)) adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin))
# 저녁 알림 임계 (출근 후 N시간) # 저녁 알림 임계 (출근 후 N시간)
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간") self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour'))
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림") self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip'))
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin)) adv_layout.addLayout(self._labeled_row(tr('settings.dinner_alert_after'), self.dinner_reminder_spin))
# 연장근무 누적 임계 # 연장근무 누적 임계
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간") self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour'))
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림") self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip'))
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin)) adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin))
# 주 X시간 임계 # 주 X시간 임계
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간") self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour'))
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)") self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip'))
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin)) adv_layout.addLayout(self._labeled_row(tr('settings.weekly_limit'), self.weekly_hours_spin))
# 연속 연장근무 일수 # 연속 연장근무 일수
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, "") self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day'))
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고") self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback=''))
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin)) adv_layout.addLayout(self._labeled_row(tr('settings.consecutive_ot'), self.health_consecutive_spin))
# 휴식 권고 (연속 근무 시간) # 휴식 권고 (연속 근무 시간)
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간") self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유") self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip'))
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin)) adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin))
adv_box.setLayout(adv_layout) adv_box.setLayout(adv_layout)
layout.addWidget(adv_box) layout.addWidget(adv_box)
# 시간 형식 + 테마 한 줄에 # 시간 형식 + 테마 한 줄에
format_row = QHBoxLayout() format_row = QHBoxLayout()
time_format_label = QLabel("시간 형식:") time_format_label = QLabel(tr('settings.time_format'))
time_format_label.setFixedWidth(70) time_format_label.setFixedWidth(70)
self.time_format_combo = QComboBox() self.time_format_combo = QComboBox()
self.time_format_combo.addItem("24시간 (17:30)", "24") self.time_format_combo.addItem(tr('settings.time_format_24'), "24")
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12") self.time_format_combo.addItem(tr('settings.time_format_12'), "12")
self.time_format_combo.setFixedWidth(180) self.time_format_combo.setFixedWidth(180)
theme_label = QLabel("테마:") theme_label = QLabel(tr('settings.theme'))
theme_label.setFixedWidth(40) theme_label.setFixedWidth(40)
self.theme_combo = QComboBox() self.theme_combo = QComboBox()
self.theme_combo.addItem("라이트", "light") self.theme_combo.addItem(tr('settings.theme_light'), "light")
self.theme_combo.addItem("다크", "dark") self.theme_combo.addItem(tr('settings.theme_dark'), "dark")
self.theme_combo.setFixedWidth(90) self.theme_combo.setFixedWidth(90)
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed) self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
@ -419,7 +419,7 @@ class SettingsView(QDialog):
# 접근성: 글꼴 크기 + 고대비 # 접근성: 글꼴 크기 + 고대비
a11y_row = QHBoxLayout() a11y_row = QHBoxLayout()
a11y_row.addWidget(QLabel("글꼴 크기:")) a11y_row.addWidget(QLabel(tr('settings.font_scale')))
self.font_scale_combo = QComboBox() self.font_scale_combo = QComboBox()
self.font_scale_combo.addItem("100%", "1.0") self.font_scale_combo.addItem("100%", "1.0")
self.font_scale_combo.addItem("125%", "1.25") self.font_scale_combo.addItem("125%", "1.25")
@ -427,8 +427,8 @@ class SettingsView(QDialog):
self.font_scale_combo.setFixedWidth(90) self.font_scale_combo.setFixedWidth(90)
a11y_row.addWidget(self.font_scale_combo) a11y_row.addWidget(self.font_scale_combo)
a11y_row.addSpacing(16) a11y_row.addSpacing(16)
self.high_contrast_check = QCheckBox("고대비 모드") self.high_contrast_check = QCheckBox(tr('settings.high_contrast'))
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)") self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip'))
a11y_row.addWidget(self.high_contrast_check) a11y_row.addWidget(self.high_contrast_check)
a11y_row.addStretch() a11y_row.addStretch()
layout.addLayout(a11y_row) layout.addLayout(a11y_row)
@ -436,13 +436,13 @@ class SettingsView(QDialog):
# 언어 선택 # 언어 선택
from core.i18n import available_languages, language_label from core.i18n import available_languages, language_label
lang_row = QHBoxLayout() lang_row = QHBoxLayout()
lang_label = QLabel("언어 / Language:") lang_label = QLabel(tr('label.language'))
lang_label.setFixedWidth(120) lang_label.setFixedWidth(120)
self.language_combo = QComboBox() self.language_combo = QComboBox()
for code in available_languages(): for code in available_languages():
self.language_combo.addItem(language_label(code), code) self.language_combo.addItem(language_label(code), code)
self.language_combo.setFixedWidth(140) self.language_combo.setFixedWidth(140)
self.language_combo.setToolTip("언어 변경은 재시작 후 완전히 적용됩니다.") self.language_combo.setToolTip(tr('group.language_restart_tooltip', fallback=''))
lang_row.addWidget(lang_label) lang_row.addWidget(lang_label)
lang_row.addWidget(self.language_combo) lang_row.addWidget(self.language_combo)
lang_row.addStretch() lang_row.addStretch()
@ -459,16 +459,16 @@ class SettingsView(QDialog):
# 잔액 + 계산 단위 한 줄 # 잔액 + 계산 단위 한 줄
top_row = QHBoxLayout() top_row = QHBoxLayout()
self.current_overtime_label = QLabel("현재 잔액: 계산 중...") self.current_overtime_label = QLabel(tr('settings.current_balance'))
self.current_overtime_label.setObjectName("badge_success") self.current_overtime_label.setObjectName("badge_success")
top_row.addWidget(self.current_overtime_label) top_row.addWidget(self.current_overtime_label)
top_row.addStretch() top_row.addStretch()
unit_label = QLabel("계산 단위:") unit_label = QLabel(tr('settings.calc_unit'))
self.overtime_unit_combo = QComboBox() self.overtime_unit_combo = QComboBox()
self.overtime_unit_combo.addItem("30분", 30) self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30)
self.overtime_unit_combo.addItem("1시간", 60) self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60)
self.overtime_unit_combo.addItem("15분", 15) self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15)
self.overtime_unit_combo.setFixedWidth(100) self.overtime_unit_combo.setFixedWidth(100)
top_row.addWidget(unit_label) top_row.addWidget(unit_label)
top_row.addWidget(self.overtime_unit_combo) top_row.addWidget(self.overtime_unit_combo)
@ -476,28 +476,28 @@ class SettingsView(QDialog):
# 초기 연장근무 설정 # 초기 연장근무 설정
initial_overtime_layout = QHBoxLayout() initial_overtime_layout = QHBoxLayout()
initial_overtime_label = QLabel("기존 연장근무:") initial_overtime_label = QLabel(tr('settings.initial_overtime'))
initial_overtime_label.setFixedWidth(100) initial_overtime_label.setFixedWidth(100)
self.initial_overtime_hours = QSpinBox() self.initial_overtime_hours = QSpinBox()
self.initial_overtime_hours.setRange(0, 200) self.initial_overtime_hours.setRange(0, 200)
self.initial_overtime_hours.setValue(0) self.initial_overtime_hours.setValue(0)
self.initial_overtime_hours.setSuffix(" 시간") self.initial_overtime_hours.setSuffix(tr('settings.suffix_hour'))
self.initial_overtime_hours.setFixedWidth(110) self.initial_overtime_hours.setFixedWidth(110)
self.initial_overtime_mins = QSpinBox() self.initial_overtime_mins = QSpinBox()
self.initial_overtime_mins.setRange(0, 59) self.initial_overtime_mins.setRange(0, 59)
self.initial_overtime_mins.setValue(0) self.initial_overtime_mins.setValue(0)
self.initial_overtime_mins.setSuffix("") self.initial_overtime_mins.setSuffix(tr('settings.suffix_minute'))
self.initial_overtime_mins.setFixedWidth(100) self.initial_overtime_mins.setFixedWidth(100)
apply_overtime_btn = QPushButton("적용") apply_overtime_btn = QPushButton(tr('btn.apply'))
apply_overtime_btn.setObjectName("btn_small") apply_overtime_btn.setObjectName("btn_small")
apply_overtime_btn.setFixedWidth(50) apply_overtime_btn.setFixedWidth(50)
apply_overtime_btn.clicked.connect(self.apply_initial_overtime) apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
self.auto_overtime_check = QCheckBox("자동 적립") self.auto_overtime_check = QCheckBox(tr('settings.auto_bank'))
self.auto_overtime_check.setChecked(True) self.auto_overtime_check.setChecked(True)
self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립") self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip'))
initial_overtime_layout.addWidget(initial_overtime_label) initial_overtime_layout.addWidget(initial_overtime_label)
initial_overtime_layout.addWidget(self.initial_overtime_hours) initial_overtime_layout.addWidget(self.initial_overtime_hours)
@ -507,7 +507,7 @@ class SettingsView(QDialog):
initial_overtime_layout.addWidget(self.auto_overtime_check) initial_overtime_layout.addWidget(self.auto_overtime_check)
layout.addLayout(initial_overtime_layout) layout.addLayout(initial_overtime_layout)
initial_overtime_note = QLabel("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)") initial_overtime_note = QLabel(tr('settings.initial_overtime_note', fallback=''))
initial_overtime_note.setObjectName("note_text") initial_overtime_note.setObjectName("note_text")
layout.addWidget(initial_overtime_note) layout.addWidget(initial_overtime_note)
@ -516,22 +516,22 @@ class SettingsView(QDialog):
def create_goal_group(self) -> QGroupBox: def create_goal_group(self) -> QGroupBox:
"""월간 목표 설정 그룹 (0=비활성).""" """월간 목표 설정 그룹 (0=비활성)."""
group = QGroupBox("🎯 월간 목표 (0=비활성)") group = QGroupBox(tr('settings.goal_group'))
layout = QVBoxLayout() layout = QVBoxLayout()
layout.setSpacing(6) layout.setSpacing(6)
# 연장근무 상한 # 연장근무 상한
ot_row = QHBoxLayout() ot_row = QHBoxLayout()
ot_label = QLabel("월 연장근무 상한:") ot_label = QLabel(tr('settings.monthly_ot_cap'))
ot_label.setFixedWidth(150) ot_label.setFixedWidth(150)
self.goal_ot_h = QSpinBox() self.goal_ot_h = QSpinBox()
self.goal_ot_h.setRange(0, 100) self.goal_ot_h.setRange(0, 100)
self.goal_ot_h.setSuffix(" 시간") self.goal_ot_h.setSuffix(tr('settings.suffix_hour'))
self.goal_ot_h.setFixedWidth(100) self.goal_ot_h.setFixedWidth(100)
self.goal_ot_m = QSpinBox() self.goal_ot_m = QSpinBox()
self.goal_ot_m.setRange(0, 59) self.goal_ot_m.setRange(0, 59)
self.goal_ot_m.setSingleStep(30) self.goal_ot_m.setSingleStep(30)
self.goal_ot_m.setSuffix("") self.goal_ot_m.setSuffix(tr('settings.suffix_minute'))
self.goal_ot_m.setFixedWidth(90) self.goal_ot_m.setFixedWidth(90)
ot_row.addWidget(ot_label) ot_row.addWidget(ot_label)
ot_row.addWidget(self.goal_ot_h) ot_row.addWidget(self.goal_ot_h)
@ -541,18 +541,18 @@ class SettingsView(QDialog):
# 일평균 목표 # 일평균 목표
avg_row = QHBoxLayout() avg_row = QHBoxLayout()
avg_label = QLabel("일 평균 근무 목표:") avg_label = QLabel(tr('settings.daily_avg_goal'))
avg_label.setFixedWidth(150) avg_label.setFixedWidth(150)
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식 self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
self.goal_avg.setRange(0, 24) self.goal_avg.setRange(0, 24)
self.goal_avg.setSuffix(" 시간") self.goal_avg.setSuffix(tr('settings.suffix_hour'))
self.goal_avg.setFixedWidth(100) self.goal_avg.setFixedWidth(100)
avg_row.addWidget(avg_label) avg_row.addWidget(avg_label)
avg_row.addWidget(self.goal_avg) avg_row.addWidget(self.goal_avg)
avg_row.addStretch() avg_row.addStretch()
layout.addLayout(avg_row) layout.addLayout(avg_row)
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인") note = QLabel(tr('settings.goal_note', fallback=''))
note.setObjectName("note_text") note.setObjectName("note_text")
layout.addWidget(note) layout.addWidget(note)
@ -567,40 +567,40 @@ class SettingsView(QDialog):
# 연차 개수 + 남은 연차 한 줄 # 연차 개수 + 남은 연차 한 줄
top_row = QHBoxLayout() top_row = QHBoxLayout()
annual_leave_label = QLabel("연간 연차:") annual_leave_label = QLabel(tr('settings.annual_leave'))
annual_leave_label.setFixedWidth(70) annual_leave_label.setFixedWidth(70)
self.annual_leave_days = QSpinBox() self.annual_leave_days = QSpinBox()
self.annual_leave_days.setRange(0, 30) self.annual_leave_days.setRange(0, 30)
self.annual_leave_days.setValue(15) self.annual_leave_days.setValue(15)
self.annual_leave_days.setSuffix("") self.annual_leave_days.setSuffix(tr('label.unit_day'))
self.annual_leave_days.setFixedWidth(100) self.annual_leave_days.setFixedWidth(100)
top_row.addWidget(annual_leave_label) top_row.addWidget(annual_leave_label)
top_row.addWidget(self.annual_leave_days) top_row.addWidget(self.annual_leave_days)
top_row.addStretch() top_row.addStretch()
self.remaining_leave_label = QLabel("남은 연차: 계산 중...") self.remaining_leave_label = QLabel(tr('settings.remaining_leave'))
self.remaining_leave_label.setObjectName("badge_leave") self.remaining_leave_label.setObjectName("badge_leave")
top_row.addWidget(self.remaining_leave_label) top_row.addWidget(self.remaining_leave_label)
layout.addLayout(top_row) layout.addLayout(top_row)
# 기존 사용 연차 설정 # 기존 사용 연차 설정
used_leave_layout = QHBoxLayout() used_leave_layout = QHBoxLayout()
used_leave_label = QLabel("기존 사용:") used_leave_label = QLabel(tr('settings.used_leave'))
used_leave_label.setFixedWidth(70) used_leave_label.setFixedWidth(70)
self.used_leave_hours = QSpinBox() self.used_leave_hours = QSpinBox()
self.used_leave_hours.setRange(0, 200) self.used_leave_hours.setRange(0, 200)
self.used_leave_hours.setValue(0) self.used_leave_hours.setValue(0)
self.used_leave_hours.setSuffix(" 시간") self.used_leave_hours.setSuffix(tr('settings.suffix_hour'))
self.used_leave_hours.setFixedWidth(110) self.used_leave_hours.setFixedWidth(110)
self.used_leave_mins = QSpinBox() self.used_leave_mins = QSpinBox()
self.used_leave_mins.setRange(0, 59) self.used_leave_mins.setRange(0, 59)
self.used_leave_mins.setValue(0) self.used_leave_mins.setValue(0)
self.used_leave_mins.setSuffix("") self.used_leave_mins.setSuffix(tr('settings.suffix_minute'))
self.used_leave_mins.setSingleStep(30) self.used_leave_mins.setSingleStep(30)
self.used_leave_mins.setFixedWidth(100) self.used_leave_mins.setFixedWidth(100)
apply_used_leave_btn = QPushButton("적용") apply_used_leave_btn = QPushButton(tr('btn.apply'))
apply_used_leave_btn.setObjectName("btn_small") apply_used_leave_btn.setObjectName("btn_small")
apply_used_leave_btn.setFixedWidth(50) apply_used_leave_btn.setFixedWidth(50)
apply_used_leave_btn.clicked.connect(self.apply_used_leave) apply_used_leave_btn.clicked.connect(self.apply_used_leave)
@ -612,7 +612,7 @@ class SettingsView(QDialog):
used_leave_layout.addStretch() used_leave_layout.addStretch()
layout.addLayout(used_leave_layout) layout.addLayout(used_leave_layout)
used_leave_note = QLabel("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)") used_leave_note = QLabel(tr('settings.used_leave_note', fallback=''))
used_leave_note.setObjectName("note_text") used_leave_note.setObjectName("note_text")
layout.addWidget(used_leave_note) layout.addWidget(used_leave_note)
@ -627,33 +627,33 @@ class SettingsView(QDialog):
# 공휴일 목록 + 버튼 한 줄 # 공휴일 목록 + 버튼 한 줄
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
holiday_list_label = QLabel("등록:") holiday_list_label = QLabel(tr('settings.registered'))
button_layout.addWidget(holiday_list_label) button_layout.addWidget(holiday_list_label)
self.holiday_count_label = QLabel("0개") self.holiday_count_label = QLabel(tr('settings.holiday_count', count=0, year=datetime.now().year))
self.holiday_count_label.setObjectName("info_text") self.holiday_count_label.setObjectName("info_text")
button_layout.addWidget(self.holiday_count_label) button_layout.addWidget(self.holiday_count_label)
button_layout.addStretch() button_layout.addStretch()
add_korean_btn = QPushButton("한국 공휴일 (자동)") add_korean_btn = QPushButton(tr('settings.add_korean_holidays'))
add_korean_btn.setObjectName("btn_small") add_korean_btn.setObjectName("btn_small")
add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록") add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip'))
add_korean_btn.clicked.connect(self.add_korean_holidays_auto) add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
button_layout.addWidget(add_korean_btn) button_layout.addWidget(add_korean_btn)
add_custom_btn = QPushButton("추가") add_custom_btn = QPushButton(tr('btn.add'))
add_custom_btn.setObjectName("btn_small") add_custom_btn.setObjectName("btn_small")
add_custom_btn.clicked.connect(self.add_custom_holiday) add_custom_btn.clicked.connect(self.add_custom_holiday)
button_layout.addWidget(add_custom_btn) button_layout.addWidget(add_custom_btn)
view_holidays_btn = QPushButton("목록") view_holidays_btn = QPushButton(tr('settings.list'))
view_holidays_btn.setObjectName("btn_small") view_holidays_btn.setObjectName("btn_small")
view_holidays_btn.clicked.connect(self.view_holidays) view_holidays_btn.clicked.connect(self.view_holidays)
button_layout.addWidget(view_holidays_btn) button_layout.addWidget(view_holidays_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다") holiday_note = QLabel(tr('settings.holiday_note', fallback=''))
holiday_note.setObjectName("note_text") holiday_note.setObjectName("note_text")
layout.addWidget(holiday_note) layout.addWidget(holiday_note)
@ -668,7 +668,7 @@ class SettingsView(QDialog):
"""공휴일 개수 표시 업데이트""" """공휴일 개수 표시 업데이트"""
current_year = datetime.now().year current_year = datetime.now().year
holidays = self.db.get_holidays_by_year(current_year) holidays = self.db.get_holidays_by_year(current_year)
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)") self.holiday_count_label.setText(tr('settings.holiday_count', count=len(holidays), year=current_year))
def add_korean_holidays_auto(self): def add_korean_holidays_auto(self):
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가. """holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
@ -680,18 +680,13 @@ class SettingsView(QDialog):
current_year = now.year current_year = now.year
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응) # 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
include_next = now.month >= 11 include_next = now.month >= 11
target_label = (f"{current_year}년 + {current_year + 1}" target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1)
if include_next else f"{current_year}") if include_next else tr('settings.korean_holidays_years_label_single', year=current_year))
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"한국 공휴일 자동 추가", tr('settings.korean_holidays_title'),
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n" tr('settings.korean_holidays_body', years=target_label),
"포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
"• 정부 지정 대체·임시공휴일\n\n"
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
if reply != QMessageBox.Yes: if reply != QMessageBox.Yes:
@ -706,18 +701,16 @@ class SettingsView(QDialog):
self.update_holiday_count() self.update_holiday_count()
QMessageBox.warning( QMessageBox.warning(
self, self,
"패키지 미설치", tr('settings.package_not_installed'),
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n" tr('settings.package_fallback_body', hint=tr('settings.package_install_hint'))
"음력/임시공휴일 자동 등록을 원하시면:\n"
" pip install holidays"
) )
return return
self.update_holiday_count() self.update_holiday_count()
QMessageBox.information( QMessageBox.information(
self, self,
"추가 완료", tr('settings.add_done'),
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다." tr('settings.korean_holidays_added', year=current_year, count=added)
) )
def add_custom_holiday(self): def add_custom_holiday(self):
@ -728,8 +721,8 @@ class SettingsView(QDialog):
today = datetime.now().date().isoformat() today = datetime.now().date().isoformat()
date_str, ok = QInputDialog.getText( date_str, ok = QInputDialog.getText(
self, self,
"공휴일 추가", tr('settings.holiday_add_title'),
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):", tr('settings.holiday_date_prompt'),
QLineEdit.Normal, QLineEdit.Normal,
today today
) )
@ -743,16 +736,16 @@ class SettingsView(QDialog):
except ValueError: except ValueError:
QMessageBox.warning( QMessageBox.warning(
self, self,
"입력 오류", tr('msg.input_error.title'),
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)" tr('msg.input_error.date_format')
) )
return return
# 공휴일 이름 입력 # 공휴일 이름 입력
name, ok = QInputDialog.getText( name, ok = QInputDialog.getText(
self, self,
"공휴일 추가", tr('settings.holiday_add_title'),
"공휴일 이름을 입력하세요:", tr('settings.holiday_name_prompt'),
QLineEdit.Normal, QLineEdit.Normal,
"" ""
) )
@ -766,8 +759,8 @@ class SettingsView(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"추가 완료", tr('settings.add_done'),
f"공휴일이 추가되었습니다.\n{date_str}: {name}" tr('settings.holiday_added', date=date_str, name=name)
) )
def view_holidays(self): def view_holidays(self):
@ -778,26 +771,28 @@ class SettingsView(QDialog):
if not holidays: if not holidays:
QMessageBox.information( QMessageBox.information(
self, self,
"공휴일 목록", tr('settings.holiday_list_title'),
f"{current_year}년에 등록된 공휴일이 없습니다." tr('stats.no_data')
) )
return return
# 목록 생성 # 목록 생성
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n" holiday_list = tr('settings.holiday_list_header', year=current_year)
for h in holidays: for h in holidays:
date_obj = datetime.strptime(h['date'], "%Y-%m-%d") date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
weekday = ['', '', '', '', '', '', ''][date_obj.weekday()] weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}")
recurring = " (매년)" if h['is_recurring'] else "" recurring = tr('label.recurring_yearly') if h['is_recurring'] else ""
holiday_list += f"{h['date']} ({weekday}): {h['name']}{recurring}\n" holiday_list += tr('settings.holiday_list_item',
date=h['date'], weekday=weekday,
name=h['name'], recurring=recurring)
holiday_list += f"\n{len(holidays)}" holiday_list += '\n' + tr('settings.holiday_total', count=len(holidays))
# 삭제 옵션 제공 # 삭제 옵션 제공
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"공휴일 목록", tr('settings.holiday_list_title'),
holiday_list + "\n\n공휴일을 삭제하시겠습니까?", holiday_list + tr('settings.holiday_delete_confirm'),
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
) )
@ -818,8 +813,8 @@ class SettingsView(QDialog):
items = [f"{h['date']}: {h['name']}" for h in holidays] items = [f"{h['date']}: {h['name']}" for h in holidays]
item, ok = QInputDialog.getItem( item, ok = QInputDialog.getItem(
self, self,
"공휴일 삭제", tr('settings.holiday_delete_title'),
"삭제할 공휴일을 선택하세요:", tr('settings.holiday_delete_prompt'),
items, items,
0, 0,
False False
@ -829,7 +824,7 @@ class SettingsView(QDialog):
date_str = item.split(":")[0] date_str = item.split(":")[0]
self.db.delete_holiday_by_date(date_str) self.db.delete_holiday_by_date(date_str)
self.update_holiday_count() self.update_holiday_count()
QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.") QMessageBox.information(self, tr('settings.delete_done'), tr('settings.holiday_deleted', item=item))
def create_data_group(self) -> QGroupBox: def create_data_group(self) -> QGroupBox:
"""데이터 관리 그룹""" """데이터 관리 그룹"""
@ -840,22 +835,22 @@ class SettingsView(QDialog):
# CSV 내보내기 버튼들 한 줄 # CSV 내보내기 버튼들 한 줄
export_layout = QHBoxLayout() export_layout = QHBoxLayout()
export_work_btn = QPushButton("근무기록") export_work_btn = QPushButton(tr('settings.export_work'))
export_work_btn.setObjectName("btn_small") export_work_btn.setObjectName("btn_small")
export_work_btn.clicked.connect(self.export_work_records) export_work_btn.clicked.connect(self.export_work_records)
export_layout.addWidget(export_work_btn) export_layout.addWidget(export_work_btn)
export_overtime_btn = QPushButton("연장근무") export_overtime_btn = QPushButton(tr('settings.export_overtime'))
export_overtime_btn.setObjectName("btn_small") export_overtime_btn.setObjectName("btn_small")
export_overtime_btn.clicked.connect(self.export_overtime_summary) export_overtime_btn.clicked.connect(self.export_overtime_summary)
export_layout.addWidget(export_overtime_btn) export_layout.addWidget(export_overtime_btn)
monthly_btn = QPushButton("월간 요약") monthly_btn = QPushButton(tr('settings.export_monthly'))
monthly_btn.setObjectName("btn_small") monthly_btn.setObjectName("btn_small")
monthly_btn.clicked.connect(self.export_monthly_summary) monthly_btn.clicked.connect(self.export_monthly_summary)
export_layout.addWidget(monthly_btn) export_layout.addWidget(monthly_btn)
export_label = QLabel("CSV 내보내기") export_label = QLabel(tr('settings.export_csv'))
export_label.setObjectName("note_text") export_label.setObjectName("note_text")
export_layout.addWidget(export_label) export_layout.addWidget(export_label)
export_layout.addStretch() export_layout.addStretch()
@ -864,12 +859,12 @@ class SettingsView(QDialog):
# CSV 가져오기 # CSV 가져오기
import_layout = QHBoxLayout() import_layout = QHBoxLayout()
import_btn = QPushButton("📥 CSV 가져오기") import_btn = QPushButton(tr('settings.import_csv'))
import_btn.setObjectName("btn_small") import_btn.setObjectName("btn_small")
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷") import_btn.setToolTip(tr('settings.import_tooltip'))
import_btn.clicked.connect(self._import_csv) import_btn.clicked.connect(self._import_csv)
import_layout.addWidget(import_btn) import_layout.addWidget(import_btn)
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)") import_label = QLabel(tr('settings.import_format'))
import_label.setObjectName("note_text") import_label.setObjectName("note_text")
import_layout.addWidget(import_label) import_layout.addWidget(import_label)
import_layout.addStretch() import_layout.addStretch()
@ -877,14 +872,14 @@ class SettingsView(QDialog):
# DB 경로 설정 (클라우드 동기화 가능) # DB 경로 설정 (클라우드 동기화 가능)
db_path_layout = QHBoxLayout() db_path_layout = QHBoxLayout()
db_path_label = QLabel("DB 경로:") db_path_label = QLabel(tr('settings.db_path_label'))
db_path_label.setFixedWidth(60) db_path_label.setFixedWidth(60)
self.db_path_edit = QLineEdit() self.db_path_edit = QLineEdit()
self.db_path_edit.setReadOnly(True) self.db_path_edit.setReadOnly(True)
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db') self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
db_path_btn = QPushButton("변경...") db_path_btn = QPushButton(tr('settings.change'))
db_path_btn.setObjectName("btn_small") db_path_btn.setObjectName("btn_small")
db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.") db_path_btn.setToolTip(tr('settings.db_path_tooltip'))
db_path_btn.clicked.connect(self._change_db_path) db_path_btn.clicked.connect(self._change_db_path)
db_path_layout.addWidget(db_path_label) db_path_layout.addWidget(db_path_label)
db_path_layout.addWidget(self.db_path_edit, 1) db_path_layout.addWidget(self.db_path_edit, 1)
@ -892,39 +887,35 @@ class SettingsView(QDialog):
layout.addLayout(db_path_layout) layout.addLayout(db_path_layout)
# 자동 외출 (화면 잠금 시) # 자동 외출 (화면 잠금 시)
self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀") self.auto_break_check = QCheckBox(tr('settings.auto_break_lock'))
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.") self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback=''))
layout.addWidget(self.auto_break_check) layout.addWidget(self.auto_break_check)
# Gitea 피드백 토큰 (옵션, crash 자동 보고용) # Gitea 피드백 토큰 (옵션, crash 자동 보고용)
feedback_layout = QHBoxLayout() feedback_layout = QHBoxLayout()
feedback_label = QLabel("Gitea 피드백:") feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback=''))
feedback_label.setFixedWidth(80) feedback_label.setFixedWidth(80)
self.gitea_token_edit = QLineEdit() self.gitea_token_edit = QLineEdit()
self.gitea_token_edit.setEchoMode(QLineEdit.Password) self.gitea_token_edit.setEchoMode(QLineEdit.Password)
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)") self.gitea_token_edit.setPlaceholderText(tr('settings.gitea_token_placeholder', fallback=''))
feedback_layout.addWidget(feedback_label) feedback_layout.addWidget(feedback_label)
feedback_layout.addWidget(self.gitea_token_edit, 1) feedback_layout.addWidget(self.gitea_token_edit, 1)
layout.addLayout(feedback_layout) layout.addLayout(feedback_layout)
self.gitea_feedback_enabled_check = QCheckBox( self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback'))
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
)
layout.addWidget(self.gitea_feedback_enabled_check) layout.addWidget(self.gitea_feedback_enabled_check)
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용) # 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용") self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock'))
self.clock_in_unlock_check.setToolTip( self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback=''))
"PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다."
)
layout.addWidget(self.clock_in_unlock_check) layout.addWidget(self.clock_in_unlock_check)
# 업데이트 확인 # 업데이트 확인
update_layout = QHBoxLayout() update_layout = QHBoxLayout()
from core.version import __version__ from core.version import __version__
version_label = QLabel(f"버전: v{__version__}") version_label = QLabel(tr('settings.version', version=__version__))
version_label.setObjectName("note_text") version_label.setObjectName("note_text")
update_btn = QPushButton("업데이트 확인 (F5)") update_btn = QPushButton(tr('settings.check_update'))
update_btn.setObjectName("btn_small") update_btn.setObjectName("btn_small")
update_btn.clicked.connect(self._check_updates) update_btn.clicked.connect(self._check_updates)
update_layout.addWidget(version_label) update_layout.addWidget(version_label)
@ -940,7 +931,7 @@ class SettingsView(QDialog):
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db' current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
new_path, _ = QFileDialog.getSaveFileName( new_path, _ = QFileDialog.getSaveFileName(
self, self,
"데이터베이스 파일 선택", tr('settings.select_db'),
current, current,
"SQLite Database (*.db)" "SQLite Database (*.db)"
) )
@ -951,10 +942,8 @@ class SettingsView(QDialog):
self.db_path_edit.setText(new_path) self.db_path_edit.setText(new_path)
QMessageBox.information( QMessageBox.information(
self, self,
"DB 경로 변경", tr('settings.db_path_label')[:-1],
f"새 경로가 저장되었습니다:\n{new_path}\n\n" tr('settings.db_path_saved', path=new_path)
"기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n"
"프로그램을 재시작하세요."
) )
def load_settings(self): def load_settings(self):
@ -1067,7 +1056,7 @@ class SettingsView(QDialog):
self.time_format_combo.setCurrentIndex(index) self.time_format_combo.setCurrentIndex(index)
# 테마 # 테마
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1) self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
# 언어 선택 적용 # 언어 선택 적용
if hasattr(self, 'language_combo'): if hasattr(self, 'language_combo'):
@ -1104,7 +1093,7 @@ class SettingsView(QDialog):
def _import_csv(self): def _import_csv(self):
"""CSV 파일에서 근무 기록 일괄 가져오기.""" """CSV 파일에서 근무 기록 일괄 가져오기."""
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, "CSV 가져오기", self, tr('settings.import_csv'),
os.path.expanduser("~"), os.path.expanduser("~"),
"CSV files (*.csv);;All files (*.*)", "CSV files (*.csv);;All files (*.*)",
) )
@ -1114,19 +1103,17 @@ class SettingsView(QDialog):
from utils.csv_importer import parse_csv, import_records from utils.csv_importer import parse_csv, import_records
rows = parse_csv(path) rows = parse_csv(path)
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
QMessageBox.critical(self, "파싱 실패", str(e)) QMessageBox.critical(self, tr('settings.parse_failed'), str(e))
return return
if not rows: if not rows:
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.") QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body'))
return return
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"충돌 처리", tr('settings.conflict_title'),
f"{len(rows)}건의 행을 가져오겠습니다.\n\n" tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'),
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel, QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
) )
if reply == QMessageBox.Cancel: if reply == QMessageBox.Cancel:
@ -1136,12 +1123,12 @@ class SettingsView(QDialog):
try: try:
added, updated, skipped = import_records(self.db, rows, on_conflict=policy) added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
except Exception as e: except Exception as e:
QMessageBox.critical(self, "가져오기 실패", str(e)) QMessageBox.critical(self, tr('settings.import_failed'), str(e))
return return
QMessageBox.information( QMessageBox.information(
self, "완료", self, tr('settings.import_complete'),
f"가져오기 결과:\n• 추가: {added}\n• 갱신: {updated}\n• 건너뜀: {skipped}" tr('settings.import_result', added=added, updated=updated, skipped=skipped)
) )
def _check_updates(self): def _check_updates(self):
@ -1234,8 +1221,8 @@ class SettingsView(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"저장 완료", tr('settings.save_done'),
"설정이 저장되었습니다." tr('settings.save_done_body')
) )
# 부모 윈도우에 설정 변경 알림 # 부모 윈도우에 설정 변경 알림
@ -1251,9 +1238,8 @@ class SettingsView(QDialog):
set_language_and_retranslate(new_lang) set_language_and_retranslate(new_lang)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"재시작 / Restart", tr('settings.restart_title'),
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n" tr('settings.restart_body'),
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
@ -1285,10 +1271,8 @@ class SettingsView(QDialog):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"기존 연장근무 설정", tr('settings.initial_overtime_title'),
f"현재 설정: {old_hours}시간 {old_mins}\n" tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
f"변경할 값: {hours}시간 {mins}\n\n"
f"기존 연장근무 시간을 변경하시겠습니까?",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@ -1298,8 +1282,8 @@ class SettingsView(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"설정 완료", tr('settings.initial_overtime_done'),
f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다." tr('settings.initial_overtime_done_body', hours=hours, mins=mins)
) )
# 부모 윈도우 잔액 업데이트 # 부모 윈도우 잔액 업데이트
@ -1311,8 +1295,8 @@ class SettingsView(QDialog):
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, self,
"오류", tr('settings.error'),
f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}" tr('settings.initial_overtime_error', error=str(e))
) )
def update_overtime_balance_display(self): def update_overtime_balance_display(self):
@ -1320,7 +1304,7 @@ class SettingsView(QDialog):
balance_minutes = self.db.get_total_overtime_balance() balance_minutes = self.db.get_total_overtime_balance()
hours = balance_minutes // 60 hours = balance_minutes // 60
minutes = balance_minutes % 60 minutes = balance_minutes % 60
self.current_overtime_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance_minutes}분)") self.current_overtime_label.setText(tr('settings.current_overtime_balance', hours=hours, minutes=minutes, balance=balance_minutes))
def update_remaining_leave(self): def update_remaining_leave(self):
"""남은 연차 계산 및 표시""" """남은 연차 계산 및 표시"""
@ -1346,7 +1330,7 @@ class SettingsView(QDialog):
remaining = total_annual - total_used remaining = total_annual - total_used
self.remaining_leave_label.setText( self.remaining_leave_label.setText(
f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)" tr('settings.remaining_leave_fmt', remaining=remaining, total=total_annual, used=total_used)
) )
def export_work_records(self): def export_work_records(self):
@ -1357,14 +1341,14 @@ class SettingsView(QDialog):
records = stats.get('records', []) records = stats.get('records', [])
if not records: if not records:
QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.") QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records'))
return return
# 파일 경로 선택 # 파일 경로 선택
default_filename = f"work_records_{now.year}{now.month:02d}.csv" default_filename = f"work_records_{now.year}{now.month:02d}.csv"
filename, _ = QFileDialog.getSaveFileName( filename, _ = QFileDialog.getSaveFileName(
self, self,
"근무 기록 저장", tr('settings.save_work_title'),
default_filename, default_filename,
"CSV Files (*.csv)" "CSV Files (*.csv)"
) )
@ -1374,17 +1358,17 @@ class SettingsView(QDialog):
saved_path = CSVExporter.export_work_records(records, filename, db=self.db) saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
QMessageBox.information( QMessageBox.information(
self, self,
"내보내기 완료", tr('settings.export_done'),
f"근무 기록이 저장되었습니다.\n{saved_path}" tr('settings.work_exported', path=saved_path)
) )
except Exception as e: except Exception as e:
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
def export_overtime_summary(self): def export_overtime_summary(self):
"""연장근무 내역 내보내기""" """연장근무 내역 내보내기"""
filename, _ = QFileDialog.getSaveFileName( filename, _ = QFileDialog.getSaveFileName(
self, self,
"연장근무 내역 저장", tr('settings.save_ot_title'),
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv", f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
"CSV Files (*.csv)" "CSV Files (*.csv)"
) )
@ -1394,18 +1378,18 @@ class SettingsView(QDialog):
saved_path = CSVExporter.export_overtime_summary(self.db, filename) saved_path = CSVExporter.export_overtime_summary(self.db, filename)
QMessageBox.information( QMessageBox.information(
self, self,
"내보내기 완료", tr('settings.export_done'),
f"연장근무 내역이 저장되었습니다.\n{saved_path}" tr('settings.ot_exported', path=saved_path)
) )
except Exception as e: except Exception as e:
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
def export_monthly_summary(self): def export_monthly_summary(self):
"""월간 요약 내보내기""" """월간 요약 내보내기"""
now = datetime.now() now = datetime.now()
filename, _ = QFileDialog.getSaveFileName( filename, _ = QFileDialog.getSaveFileName(
self, self,
"월간 요약 저장", tr('settings.save_monthly_title'),
f"monthly_summary_{now.year}{now.month:02d}.csv", f"monthly_summary_{now.year}{now.month:02d}.csv",
"CSV Files (*.csv)" "CSV Files (*.csv)"
) )
@ -1415,11 +1399,11 @@ class SettingsView(QDialog):
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename) saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
QMessageBox.information( QMessageBox.information(
self, self,
"내보내기 완료", tr('settings.export_done'),
f"월간 요약이 저장되었습니다.\n{saved_path}" tr('settings.monthly_exported', path=saved_path)
) )
except Exception as e: except Exception as e:
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
def apply_used_leave(self): def apply_used_leave(self):
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)""" """기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
@ -1434,10 +1418,8 @@ class SettingsView(QDialog):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"기존 사용 연차 설정", tr('settings.initial_leave_title'),
f"현재 설정: {old_hours}시간 {old_mins}\n" tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
f"변경할 값: {hours}시간 {mins}\n\n"
f"기존 사용 연차를 변경하시겠습니까?",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
@ -1447,8 +1429,8 @@ class SettingsView(QDialog):
QMessageBox.information( QMessageBox.information(
self, self,
"설정 완료", tr('settings.initial_leave_done'),
f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다." tr('settings.initial_leave_done_body', hours=hours, mins=mins)
) )
# 남은 연차 재계산 # 남은 연차 재계산

View File

@ -15,7 +15,7 @@ from core.i18n import tr
from ui.styles import apply_dark_titlebar from ui.styles import apply_dark_titlebar
from ui.dark_components import ( from ui.dark_components import (
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card, dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM, transparent_label, tc,
) )
@ -27,7 +27,7 @@ class StatsView(QDialog):
self.db = db if db else Database() self.db = db if db else Database()
self.init_ui() self.init_ui()
self.load_stats() self.load_stats()
apply_dark_titlebar(self, dark=True) apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
def init_ui(self): def init_ui(self):
"""UI 초기화""" """UI 초기화"""
@ -42,9 +42,9 @@ class StatsView(QDialog):
layout.setContentsMargins(20, 16, 20, 14) layout.setContentsMargins(20, 16, 20, 14)
# 다크 톤 타이틀 # 다크 톤 타이틀
title = QLabel(f"📊 {tr('stats.title')}") title = QLabel(f"{tr('stats.title')}")
title.setStyleSheet( title.setStyleSheet(
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; " f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;" f"background: transparent; border: none; padding: 4px 0;"
) )
layout.addWidget(title) layout.addWidget(title)
@ -93,14 +93,14 @@ class StatsView(QDialog):
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장) # 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
cards_row = QHBoxLayout() cards_row = QHBoxLayout()
cards_row.setSpacing(10) cards_row.setSpacing(10)
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주", self.weekly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
theme='blue', icon='⏱️') theme='blue', icon='clock')
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주", self.weekly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_week'),
theme='cyan', icon='📅') theme='cyan', icon='calendar')
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주", self.weekly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
theme='green', icon='📊') theme='green', icon='chart')
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주", self.weekly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_week'),
theme='gold', icon='🔥') theme='gold', icon='flame')
for c in (self.weekly_total_card, self.weekly_days_card, for c in (self.weekly_total_card, self.weekly_days_card,
self.weekly_avg_card, self.weekly_ot_card): self.weekly_avg_card, self.weekly_ot_card):
cards_row.addWidget(c, 1) cards_row.addWidget(c, 1)
@ -109,8 +109,8 @@ class StatsView(QDialog):
# 주간 차트 (일별 근무시간) — 카드 안에 # 주간 차트 (일별 근무시간) — 카드 안에
from ui.chart_widget import make_chart_widget from ui.chart_widget import make_chart_widget
self.weekly_chart = make_chart_widget(widget) self.weekly_chart = make_chart_widget(widget)
chart_card = build_section_card("일별 근무 시간", self.weekly_chart, chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart,
theme='gray', icon='📈') theme='gray', icon='trending-up')
layout.addWidget(chart_card, 1) layout.addWidget(chart_card, 1)
widget.setLayout(layout) widget.setLayout(layout)
@ -127,14 +127,14 @@ class StatsView(QDialog):
# 카드 4개 # 카드 4개
cards_row = QHBoxLayout() cards_row = QHBoxLayout()
cards_row.setSpacing(10) cards_row.setSpacing(10)
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달", self.monthly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
theme='blue', icon='⏱️') theme='blue', icon='clock')
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달", self.monthly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_month'),
theme='cyan', icon='📅') theme='cyan', icon='calendar')
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달", self.monthly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
theme='green', icon='📊') theme='green', icon='chart')
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달", self.monthly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_month'),
theme='gold', icon='🔥') theme='gold', icon='flame')
for c in (self.monthly_total_card, self.monthly_days_card, for c in (self.monthly_total_card, self.monthly_days_card,
self.monthly_avg_card, self.monthly_ot_card): self.monthly_avg_card, self.monthly_ot_card):
cards_row.addWidget(c, 1) cards_row.addWidget(c, 1)
@ -143,9 +143,9 @@ class StatsView(QDialog):
# 추정 급여 (옵션 활성 시) # 추정 급여 (옵션 활성 시)
self.salary_label = QLabel("") self.salary_label = QLabel("")
self.salary_label.setStyleSheet( self.salary_label.setStyleSheet(
f"background: rgba(74, 222, 128, 0.12); " f"background: rgba(81, 207, 102, 0.12); "
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; " f"border: 1px solid {tc('green')}; border-radius: 8px; "
f"color: {ACCENT_GREEN}; font-weight: bold; " f"color: {tc('green')}; font-weight: bold; "
f"padding: 10px 14px; font-size: 11pt;" f"padding: 10px 14px; font-size: 11pt;"
) )
self.salary_label.setVisible(False) self.salary_label.setVisible(False)
@ -159,8 +159,8 @@ class StatsView(QDialog):
# 월간 차트 # 월간 차트
from ui.chart_widget import make_chart_widget from ui.chart_widget import make_chart_widget
self.monthly_chart = make_chart_widget(widget) self.monthly_chart = make_chart_widget(widget)
chart_card = build_section_card("요일별 평균", self.monthly_chart, chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart,
theme='gray', icon='📊') theme='gray', icon='chart')
layout.addWidget(chart_card, 1) layout.addWidget(chart_card, 1)
widget.setLayout(layout) widget.setLayout(layout)
@ -179,17 +179,17 @@ class StatsView(QDialog):
self.pattern_text.setWordWrap(True) self.pattern_text.setWordWrap(True)
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft) self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.pattern_text.setStyleSheet( self.pattern_text.setStyleSheet(
f"font-size: 11pt; color: {DARK_TEXT}; " f"font-size: 11pt; color: {tc('text')}; "
f"background: transparent; border: none; padding: 4px 0;" f"background: transparent; border: none; padding: 4px 0;"
) )
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text, layout.addWidget(build_section_card(tr('stats.pattern_insights'), self.pattern_text,
theme='cyan', icon='🔍')) theme='cyan', icon='search'))
# 출근 시각 분포 차트 # 출근 시각 분포 차트
from ui.chart_widget import make_chart_widget from ui.chart_widget import make_chart_widget
self.clock_in_chart = make_chart_widget(widget) self.clock_in_chart = make_chart_widget(widget)
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart, layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
theme='gray', icon=''), 1) theme='gray', icon='clock'), 1)
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@ -225,15 +225,15 @@ class StatsView(QDialog):
# 주간 통계 # 주간 통계
weekly_stats = self.db.get_weekly_stats() weekly_stats = self.db.get_weekly_stats()
total_hours = weekly_stats.get('total_hours', 0) or 0 total_hours = weekly_stats.get('total_hours', 0) or 0
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간") self._set_card_value(self.weekly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}") self._set_card_value(self.weekly_days_card, tr('stats.value_days', days=weekly_stats.get('work_days', 0)))
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0 avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간") self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}"))
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0 overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
overtime_hours = overtime_minutes // 60 overtime_hours = overtime_minutes // 60
overtime_mins = overtime_minutes % 60 overtime_mins = overtime_minutes % 60
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}") self._set_card_value(self.weekly_ot_card, tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
# 주간 차트 # 주간 차트
from ui.chart_widget import draw_daily_hours, draw_weekday_avg from ui.chart_widget import draw_daily_hours, draw_weekday_avg
@ -251,21 +251,21 @@ class StatsView(QDialog):
now = datetime.now() now = datetime.now()
monthly_stats = self.db.get_monthly_stats(now.year, now.month) monthly_stats = self.db.get_monthly_stats(now.year, now.month)
total_hours = monthly_stats.get('total_hours', 0) or 0 total_hours = monthly_stats.get('total_hours', 0) or 0
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간") self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
work_days = monthly_stats.get('work_days', 0) or 0 work_days = monthly_stats.get('work_days', 0) or 0
self._set_card_value(self.monthly_days_card, f"{work_days}") self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days))
if work_days > 0: if work_days > 0:
avg = total_hours / work_days avg = total_hours / work_days
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간") self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}"))
else: else:
self._set_card_value(self.monthly_avg_card, "0시간") self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0))
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0 overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
overtime_hours = overtime_minutes // 60 overtime_hours = overtime_minutes // 60
overtime_mins = overtime_minutes % 60 overtime_mins = overtime_minutes % 60
self._set_card_value(self.monthly_ot_card, self._set_card_value(self.monthly_ot_card,
f"{overtime_hours}시간 {overtime_mins}") tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
# 월간 차트 (요일별 평균) # 월간 차트 (요일별 평균)
if hasattr(self, 'monthly_chart'): if hasattr(self, 'monthly_chart'):
@ -300,8 +300,10 @@ class StatsView(QDialog):
from core.salary import estimate_pay, format_won from core.salary import estimate_pay, format_won
result = estimate_pay(records, wage, rate) result = estimate_pay(records, wage, rate)
self.salary_label.setText( self.salary_label.setText(
f"💰 이번 달 추정 급여: {format_won(result['total'])} " tr('stats.salary_estimate',
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})" total=format_won(result['total']),
base=format_won(result['base']),
overtime=format_won(result['overtime']))
) )
self.salary_label.setVisible(True) self.salary_label.setVisible(True)
@ -334,7 +336,7 @@ class StatsView(QDialog):
avg_minutes = sum(clock_in_times) / len(clock_in_times) avg_minutes = sum(clock_in_times) / len(clock_in_times)
avg_hour = int(avg_minutes // 60) avg_hour = int(avg_minutes // 60)
avg_min = int(avg_minutes % 60) avg_min = int(avg_minutes % 60)
insights.append(f"📌 평균 출근시간: {avg_hour:02d}:{avg_min:02d}") insights.append(tr('stats.avg_clock_in', time=f"{avg_hour:02d}:{avg_min:02d}"))
# 연장근무 빈도 # 연장근무 빈도
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0]) overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
@ -342,14 +344,14 @@ class StatsView(QDialog):
if total_days > 0: if total_days > 0:
overtime_rate = (overtime_days / total_days) * 100 overtime_rate = (overtime_days / total_days) * 100
insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)") insights.append(tr('stats.overtime_frequency', rate=f"{overtime_rate:.0f}", days=overtime_days, total=total_days))
# 가장 긴 근무일 # 가장 긴 근무일
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0] records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
if records_with_hours: if records_with_hours:
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0)) longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
if longest_work.get('total_hours', 0) > 0: if longest_work.get('total_hours', 0) > 0:
insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)") insights.append(tr('stats.longest_work', date=longest_work['date'], hours=f"{longest_work['total_hours']:.1f}"))
# 건강 경고 # 건강 경고
recent_records = records[-7:] # 최근 7일 recent_records = records[-7:] # 최근 7일
@ -364,15 +366,15 @@ class StatsView(QDialog):
consecutive_overtime = 0 consecutive_overtime = 0
if max_consecutive >= 3: if max_consecutive >= 3:
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!") insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive))
# 주 52시간 체크 # 주 52시간 체크
if len(recent_records) >= 7: if len(recent_records) >= 7:
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:]) week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
if week_total > 52: if week_total > 52:
insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간") insights.append(tr('stats.weekly_52_exceeded', hours=f"{week_total:.1f}"))
self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.") self.pattern_text.setText("\n\n".join(insights) if insights else tr('stats.no_pattern_data'))
# 테스트 코드 # 테스트 코드

View File

@ -33,8 +33,8 @@ def _ensure_icons():
for name, color_hex, points in [ for name, color_hex, points in [
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]), ('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]), ('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]), ('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]), ('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
]: ]:
path = os.path.join(_arrow_dir, f'{name}.png') path = os.path.join(_arrow_dir, f'{name}.png')
if not os.path.exists(path): if not os.path.exists(path):
@ -78,6 +78,9 @@ LIGHT_COLORS = {
'bg_primary': '#F5F5F7', 'bg_primary': '#F5F5F7',
'bg_secondary': '#FFFFFF', 'bg_secondary': '#FFFFFF',
'bg_tertiary': '#EDEDF0', 'bg_tertiary': '#EDEDF0',
# 인터랙션 표면
'surface_hover': '#E2E3E7',
'surface_pressed': '#D5D6DB',
# 텍스트 계층 # 텍스트 계층
'text_primary': '#1A1A2E', 'text_primary': '#1A1A2E',
'text_secondary': '#4A4A68', 'text_secondary': '#4A4A68',
@ -85,9 +88,15 @@ LIGHT_COLORS = {
'text_inverse': '#FFFFFF', 'text_inverse': '#FFFFFF',
# 액센트 # 액센트
'accent_primary': '#3B82F6', 'accent_primary': '#3B82F6',
'accent_primary_hover': '#2F74EE',
'accent_primary_pressed': '#2563EB',
'accent_success': '#10B981', 'accent_success': '#10B981',
'accent_success_hover': '#0EA372',
'accent_success_pressed': '#0C8F63',
'accent_warning': '#F59E0B', 'accent_warning': '#F59E0B',
'accent_danger': '#EF4444', 'accent_danger': '#EF4444',
'accent_danger_hover': '#DC2626',
'accent_danger_pressed': '#B91C1C',
# 테두리 # 테두리
'border_subtle': '#E5E7EB', 'border_subtle': '#E5E7EB',
'border_default': '#D1D5DB', 'border_default': '#D1D5DB',
@ -120,40 +129,58 @@ LIGHT_COLORS = {
} }
DARK_COLORS = { DARK_COLORS = {
'bg_primary': '#111118', # 배경 계층 — 모던 다크 (Notion/Linear 톤)
'bg_secondary': '#1C1C2E', 'bg_primary': '#1A1B1E', # 앱 배경
'bg_tertiary': '#282842', 'bg_secondary': '#25262B', # 카드 / 패널
'text_primary': '#ECECF4', 'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
'text_secondary': '#B0B0C8', # 인터랙션 표면
'text_tertiary': '#808098', 'surface_hover': '#34363D',
'surface_pressed': '#3A3D44',
# 텍스트 계층
'text_primary': '#E9ECEF',
'text_secondary': '#909296',
'text_tertiary': '#6C6E73',
'text_inverse': '#FFFFFF', 'text_inverse': '#FFFFFF',
'accent_primary': '#6B9EFF', # 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
'accent_success': '#4ADE80', 'accent_primary': '#4DABF7',
'accent_warning': '#FCD34D', 'accent_primary_hover': '#69B6F8',
'accent_danger': '#FB7185', 'accent_primary_pressed': '#3D97E0',
'border_subtle': '#32324E', 'accent_success': '#51CF66',
'border_default': '#44446A', 'accent_success_hover': '#69DB7C',
'border_focus': '#6B9EFF', 'accent_success_pressed': '#43B85A',
'badge_overtime_bg': '#3D2008', 'accent_warning': '#FAB005',
'badge_overtime_text': '#FDE68A', 'accent_danger': '#FA5252',
'badge_leave_bg': '#1E2D5F', 'accent_danger_hover': '#FF6B6B',
'badge_leave_text': '#A5D0FE', 'accent_danger_pressed': '#E64545',
'badge_total_bg': '#0A3324', # 테두리
'badge_total_text': '#86EFAC', 'border_subtle': '#2C2E33',
'progress_bg': '#282842', 'border_default': '#373A40',
'progress_start': '#6B9EFF', 'border_focus': '#4DABF7',
'progress_end': '#4ADE80', # 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
'status_overtime': '#FB7185', 'badge_overtime_bg': '#2C2E33',
'status_warning': '#FCD34D', 'badge_overtime_text': '#FAB005',
'status_normal': '#4ADE80', 'badge_leave_bg': '#2C2E33',
'status_break_active': '#FB7185', 'badge_leave_text': '#4DABF7',
'status_break_idle': '#808098', 'badge_total_bg': '#2C2E33',
'cal_normal': '#1A4D3A', 'badge_total_text': '#51CF66',
'cal_overtime': '#5C1A1A', # 프로그레스 — 단일 accent 솔리드
'cal_incomplete': '#5C3A10', 'progress_bg': '#2C2E33',
'scrollbar_bg': '#111118', 'progress_start': '#4DABF7',
'scrollbar_handle': '#44446A', 'progress_end': '#4DABF7',
'scrollbar_hover': '#5A5A88', # 상태 색상 (동적 텍스트 피드백)
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
'status_warning': '#FAB005',
'status_normal': '#51CF66',
'status_break_active': '#FA5252',
'status_break_idle': '#6C6E73',
# 캘린더 날짜 배경 — 미묘한 다크 틴트
'cal_normal': '#1E3A2A',
'cal_overtime': '#3A2122',
'cal_incomplete': '#3A331E',
# 스크롤바
'scrollbar_bg': '#1A1B1E',
'scrollbar_handle': '#373A40',
'scrollbar_hover': '#4DABF7',
} }
@ -192,7 +219,7 @@ QMainWindow, QDialog {{
}} }}
QWidget {{ QWidget {{
font-family: "Segoe UI", "맑은 고딕", sans-serif; font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
font-size: 9.5pt; font-size: 9.5pt;
color: {c['text_primary']}; color: {c['text_primary']};
}} }}
@ -206,14 +233,14 @@ QWidget#central_widget {{
*/ */
QLabel#app_title {{ QLabel#app_title {{
font-size: 12pt; font-size: 13pt;
font-weight: bold; font-weight: bold;
color: {c['text_primary']}; color: {c['text_primary']};
padding: 2px; padding: 2px;
}} }}
QLabel#date_label {{ QLabel#date_label {{
font-size: 9pt; font-size: 9.5pt;
color: {c['text_secondary']}; color: {c['text_secondary']};
padding-bottom: 4px; padding-bottom: 4px;
}} }}
@ -221,7 +248,7 @@ QLabel#date_label {{
QLabel#section_title {{ QLabel#section_title {{
font-size: 9.5pt; font-size: 9.5pt;
font-weight: bold; font-weight: bold;
color: {c['text_primary']}; color: {c['text_secondary']};
}} }}
QLabel#field_label {{ QLabel#field_label {{
@ -229,29 +256,30 @@ QLabel#field_label {{
color: {c['text_secondary']}; color: {c['text_secondary']};
}} }}
/* 출근/현재 시각 나란히 표시되는 중간 크기 모노스페이스 */
QLabel#time_value {{ QLabel#time_value {{
font-family: "Consolas", "D2Coding", monospace; font-family: "Consolas", "D2Coding", monospace;
font-size: 11pt; font-size: 15pt;
font-weight: bold; font-weight: bold;
color: {c['text_primary']}; color: {c['text_primary']};
}} }}
/* 히어로 남은 시간 (화면에서 가장 결과 표시). 카드 안에 투명 배치 */
QLabel#time_display {{ QLabel#time_display {{
font-family: "Consolas", "D2Coding", monospace; font-family: "Consolas", "D2Coding", monospace;
font-size: 22pt; font-size: 30pt;
font-weight: bold; font-weight: bold;
color: {c['text_primary']}; color: {c['text_primary']};
background: {c['bg_secondary']}; background: transparent;
border: 1px solid {c['border_subtle']}; border: none;
border-radius: 10px; padding: 4px 0;
padding: 10px;
}} }}
QLabel#expected_time {{ QLabel#expected_time {{
font-size: 10pt; font-size: 11.5pt;
font-weight: bold; font-weight: bold;
color: {c['text_primary']}; color: {c['text_secondary']};
padding: 4px; padding: 2px;
}} }}
QLabel#dialog_title {{ QLabel#dialog_title {{
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
qproperty-alignment: AlignCenter; qproperty-alignment: AlignCenter;
background: {c['badge_overtime_bg']}; background: {c['badge_overtime_bg']};
color: {c['badge_overtime_text']}; color: {c['badge_overtime_text']};
border-radius: 6px; border-radius: 8px;
}} }}
QLabel#badge_leave {{ QLabel#badge_leave {{
@ -306,7 +334,7 @@ QLabel#badge_leave {{
qproperty-alignment: AlignCenter; qproperty-alignment: AlignCenter;
background: {c['badge_leave_bg']}; background: {c['badge_leave_bg']};
color: {c['badge_leave_text']}; color: {c['badge_leave_text']};
border-radius: 6px; border-radius: 8px;
}} }}
QLabel#badge_total {{ QLabel#badge_total {{
@ -317,7 +345,7 @@ QLabel#badge_total {{
qproperty-alignment: AlignCenter; qproperty-alignment: AlignCenter;
background: {c['badge_total_bg']}; background: {c['badge_total_bg']};
color: {c['badge_total_text']}; color: {c['badge_total_text']};
border-radius: 6px; border-radius: 8px;
}} }}
QLabel#badge_balance {{ QLabel#badge_balance {{
@ -326,7 +354,7 @@ QLabel#badge_balance {{
padding: 10px; padding: 10px;
background: {c['bg_tertiary']}; background: {c['bg_tertiary']};
color: {c['text_primary']}; color: {c['text_primary']};
border-radius: 6px; border-radius: 8px;
}} }}
QLabel#badge_success {{ QLabel#badge_success {{
@ -335,7 +363,7 @@ QLabel#badge_success {{
padding: 8px; padding: 8px;
background: {c['badge_total_bg']}; background: {c['badge_total_bg']};
color: {c['badge_total_text']}; color: {c['badge_total_text']};
border-radius: 6px; border-radius: 8px;
}} }}
/* /*
@ -355,9 +383,9 @@ QLabel#separator {{
QGroupBox {{ QGroupBox {{
background: {c['bg_secondary']}; background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']}; border: 1px solid {c['border_subtle']};
border-radius: 10px; border-radius: 8px;
margin-top: 10px; margin-top: 10px;
padding: 14px; padding: 16px;
padding-top: 28px; padding-top: 28px;
font-size: 9.5pt; font-size: 9.5pt;
color: {c['text_primary']}; color: {c['text_primary']};
@ -378,52 +406,55 @@ QGroupBox::title {{
버튼 버튼
*/ */
/* 기본 버튼 그라데이션/베벨 없는 플랫 (border:none 기반) */
QPushButton {{ QPushButton {{
background: {c['bg_tertiary']}; background: {c['bg_tertiary']};
color: {c['text_primary']}; color: {c['text_primary']};
border: 1px solid {c['border_default']}; border: none;
border-radius: 6px; border-radius: 8px;
padding: 7px 14px; padding: 8px 14px;
font-size: 9pt; font-size: 9pt;
}} }}
QPushButton:hover {{ QPushButton:hover {{
background: {c['border_default']}; background: {c['surface_hover']};
}} }}
QPushButton:pressed {{ QPushButton:pressed {{
background: {c['border_subtle']}; background: {c['surface_pressed']};
}} }}
QPushButton:disabled {{ QPushButton:disabled {{
background: {c['bg_tertiary']}; background: {c['bg_secondary']};
color: {c['text_tertiary']}; color: {c['text_tertiary']};
border-color: {c['border_subtle']};
}} }}
QPushButton:checked {{ QPushButton:checked {{
background: {c['accent_primary']}; background: {c['accent_primary']};
color: {c['text_inverse']}; color: {c['text_inverse']};
border-color: {c['accent_primary']};
}} }}
/* 퇴근 버튼 (primary action) */ QPushButton:focus {{
outline: none;
}}
/* 퇴근 버튼 주요 액션 (단일 포인트 컬러) */
QPushButton#clock_out_button {{ QPushButton#clock_out_button {{
background: {c['accent_success']}; background: {c['accent_primary']};
color: {c['text_inverse']}; color: {c['text_inverse']};
font-size: 11pt; font-size: 11pt;
font-weight: bold; font-weight: bold;
padding: 8px; padding: 11px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
}} }}
QPushButton#clock_out_button:hover {{ QPushButton#clock_out_button:hover {{
background: {'#0EA572' if not is_dark else '#2BB885'}; background: {c['accent_primary_hover']};
}} }}
QPushButton#clock_out_button:pressed {{ QPushButton#clock_out_button:pressed {{
background: {'#0C8F63' if not is_dark else '#28A87A'}; background: {c['accent_primary_pressed']};
}} }}
/* 주요 액션 버튼 */ /* 주요 액션 버튼 */
@ -435,11 +466,11 @@ QPushButton#btn_primary {{
}} }}
QPushButton#btn_primary:hover {{ QPushButton#btn_primary:hover {{
background: {c['accent_primary']}DD; background: {c['accent_primary_hover']};
}} }}
QPushButton#btn_primary:pressed {{ QPushButton#btn_primary:pressed {{
background: {c['accent_primary']}BB; background: {c['accent_primary_pressed']};
}} }}
/* 위험 버튼 */ /* 위험 버튼 */
@ -450,11 +481,11 @@ QPushButton#btn_danger {{
}} }}
QPushButton#btn_danger:hover {{ QPushButton#btn_danger:hover {{
background: {c['accent_danger']}DD; background: {c['accent_danger_hover']};
}} }}
QPushButton#btn_danger:pressed {{ QPushButton#btn_danger:pressed {{
background: {c['accent_danger']}BB; background: {c['accent_danger_pressed']};
}} }}
/* 성공 버튼 */ /* 성공 버튼 */
@ -465,25 +496,44 @@ QPushButton#btn_success {{
}} }}
QPushButton#btn_success:hover {{ QPushButton#btn_success:hover {{
background: {c['accent_success']}DD; background: {c['accent_success_hover']};
}} }}
QPushButton#btn_success:pressed {{ QPushButton#btn_success:pressed {{
background: {c['accent_success']}BB; background: {c['accent_success_pressed']};
}} }}
/* 작은 버튼 */ /* 작은 버튼 미묘한 표면 */
QPushButton#btn_small {{ QPushButton#btn_small {{
font-size: 8.5pt; font-size: 8.5pt;
padding: 5px 10px; padding: 6px 10px;
}} }}
QPushButton#btn_small:hover {{ QPushButton#btn_small:hover {{
background: {c['accent_primary']}20; background: {c['surface_hover']};
}} }}
QPushButton#btn_small:pressed {{ QPushButton#btn_small:pressed {{
background: {c['accent_primary']}35; background: {c['surface_pressed']};
}}
/* 하단 네비게이션 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 ) */
QPushButton#nav_btn {{
background: transparent;
border: none;
border-radius: 8px;
padding: 8px 4px;
font-size: 8.5pt;
color: {c['text_secondary']};
}}
QPushButton#nav_btn:hover {{
background: {c['surface_hover']};
color: {c['text_primary']};
}}
QPushButton#nav_btn:pressed {{
background: {c['surface_pressed']};
}} }}
/* /*
@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
QLineEdit, QTextEdit, QComboBox {{ QLineEdit, QTextEdit, QComboBox {{
background: {c['bg_secondary']}; background: {c['bg_secondary']};
border: 1px solid {c['border_default']}; border: 1px solid {c['border_default']};
border-radius: 6px; border-radius: 8px;
padding: 6px 8px; padding: 6px 8px;
color: {c['text_primary']}; color: {c['text_primary']};
font-size: 9.5pt; font-size: 9.5pt;
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{ QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
background: {c['bg_secondary']}; background: {c['bg_secondary']};
border: 1px solid {c['border_default']}; border: 1px solid {c['border_default']};
border-radius: 6px; border-radius: 8px;
padding: 6px 28px 6px 8px; padding: 6px 28px 6px 8px;
color: {c['text_primary']}; color: {c['text_primary']};
font-size: 9.5pt; font-size: 9.5pt;
min-height: 20px; min-height: 20px;
}} }}
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{ /* 포커스 보더 컬러만 포인트 컬러로 (두께 유지 레이아웃 흔들림 없음) */
border: 2px solid {c['border_focus']}; QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
padding: 5px 7px;
}}
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{ QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
border: 2px solid {c['border_focus']}; border: 1px solid {c['border_focus']};
padding: 5px 27px 5px 7px;
}} }}
/* 비활성 입력 필드 */ /* 비활성 입력 필드 */
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
QSpinBox::up-button, QDoubleSpinBox::up-button, QSpinBox::up-button, QDoubleSpinBox::up-button,
QDateEdit::up-button, QTimeEdit::up-button {{ QDateEdit::up-button, QTimeEdit::up-button {{
subcontrol-position: top right; subcontrol-position: top right;
border-top-right-radius: 4px; border-top-right-radius: 7px;
}} }}
QSpinBox::down-button, QDoubleSpinBox::down-button, QSpinBox::down-button, QDoubleSpinBox::down-button,
QDateEdit::down-button, QTimeEdit::down-button {{ QDateEdit::down-button, QTimeEdit::down-button {{
subcontrol-position: bottom right; subcontrol-position: bottom right;
border-bottom-right-radius: 4px; border-bottom-right-radius: 7px;
}} }}
QSpinBox::up-button:hover, QSpinBox::down-button:hover, QSpinBox::up-button:hover, QSpinBox::down-button:hover,
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
QProgressBar {{ QProgressBar {{
border: none; border: none;
background: {c['progress_bg']}; background: {c['progress_bg']};
border-radius: 4px; border-radius: 3px;
height: 8px; min-height: 6px;
max-height: 6px;
text-align: center; text-align: center;
color: transparent; color: transparent;
font-size: 0px; font-size: 0px;
}} }}
QProgressBar::chunk {{ QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, background: {c['progress_start']};
stop:0 {c['progress_start']}, stop:1 {c['progress_end']}); border-radius: 3px;
border-radius: 4px;
}} }}
/* /*
@ -648,7 +694,7 @@ QProgressBar::chunk {{
QTableWidget {{ QTableWidget {{
background: {c['bg_secondary']}; background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']}; border: 1px solid {c['border_subtle']};
border-radius: 6px; border-radius: 8px;
gridline-color: {c['border_subtle']}; gridline-color: {c['border_subtle']};
color: {c['text_primary']}; color: {c['text_primary']};
font-size: 9pt; font-size: 9pt;
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
background: {c['bg_tertiary']}; background: {c['bg_tertiary']};
}} }}
/* 헤더 위젯 배경 (세로헤더 영역의 흰색 누수 방지) */
QHeaderView {{
background: {c['bg_secondary']};
border: none;
}}
QHeaderView::section {{ QHeaderView::section {{
background: {c['bg_tertiary']}; background: {c['bg_tertiary']};
color: {c['text_secondary']}; color: {c['text_secondary']};
padding: 8px; padding: 8px;
border: none; border: none;
border-bottom: 2px solid {c['accent_primary']};
font-weight: bold; font-weight: bold;
font-size: 9pt; font-size: 9pt;
}} }}
QHeaderView::section:horizontal {{
border-bottom: 2px solid {c['accent_primary']};
}}
/* 세로헤더(행번호) accent 밑줄 없이 미묘하게 */
QHeaderView::section:vertical {{
border-right: 1px solid {c['border_subtle']};
color: {c['text_tertiary']};
font-weight: normal;
padding: 4px 8px;
}}
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
QTableView QTableCornerButton::section {{
background: {c['bg_tertiary']};
border: none;
border-bottom: 2px solid {c['accent_primary']};
}}
/* /*
위젯 위젯
*/ */
QTabWidget::pane {{ QTabWidget::pane {{
border: 1px solid {c['border_subtle']}; border: 1px solid {c['border_subtle']};
border-radius: 6px; border-radius: 8px;
background: {c['bg_secondary']}; background: {c['bg_secondary']};
top: -1px; top: -1px;
}} }}
@ -694,8 +764,8 @@ QTabBar::tab {{
padding: 8px 20px; padding: 8px 20px;
border: 1px solid {c['border_subtle']}; border: 1px solid {c['border_subtle']};
border-bottom: none; border-bottom: none;
border-top-left-radius: 6px; border-top-left-radius: 8px;
border-top-right-radius: 6px; border-top-right-radius: 8px;
margin-right: 2px; margin-right: 2px;
font-size: 10pt; font-size: 10pt;
}} }}
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
QCalendarWidget {{ QCalendarWidget {{
background: {c['bg_secondary']}; background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']}; border: 1px solid {c['border_subtle']};
border-radius: 6px; border-radius: 8px;
font-size: 10pt; font-size: 10pt;
}} }}
@ -902,7 +972,7 @@ QToolTip {{
QMenu {{ QMenu {{
background: {c['bg_secondary']}; background: {c['bg_secondary']};
border: 1px solid {c['border_default']}; border: 1px solid {c['border_default']};
border-radius: 6px; border-radius: 8px;
padding: 4px; padding: 4px;
color: {c['text_primary']}; color: {c['text_primary']};
}} }}
@ -916,6 +986,16 @@ QMenu::item:selected {{
background: {c['accent_primary']}; background: {c['accent_primary']};
color: {c['text_inverse']}; color: {c['text_inverse']};
}} }}
QMenu::separator {{
height: 1px;
background: {c['border_subtle']};
margin: 4px 8px;
}}
QMenu::icon {{
padding-left: 8px;
}}
""" """

View File

@ -7,6 +7,8 @@ from __future__ import annotations
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from core.i18n import tr
class TodaySummaryCard(QFrame): class TodaySummaryCard(QFrame):
"""퇴근 처리 직후 표시되는 요약 카드.""" """퇴근 처리 직후 표시되는 요약 카드."""
@ -16,12 +18,12 @@ class TodaySummaryCard(QFrame):
self.setObjectName("today_summary_card") self.setObjectName("today_summary_card")
self.setStyleSheet(""" self.setStyleSheet("""
QFrame#today_summary_card { QFrame#today_summary_card {
background-color: rgba(76, 175, 80, 0.08); background-color: rgba(81, 207, 102, 0.08);
border: 1px solid rgba(76, 175, 80, 0.4); border: 1px solid rgba(81, 207, 102, 0.40);
border-radius: 8px; border-radius: 8px;
padding: 6px; padding: 6px;
} }
QLabel { padding: 1px; } QLabel { padding: 1px; background: transparent; border: none; }
""") """)
self.setVisible(False) self.setVisible(False)
@ -30,7 +32,7 @@ class TodaySummaryCard(QFrame):
layout.setSpacing(2) layout.setSpacing(2)
header = QHBoxLayout() header = QHBoxLayout()
title = QLabel("📋 오늘의 요약") title = QLabel(tr('today.title'))
title.setStyleSheet("font-weight: bold; font-size: 13px;") title.setStyleSheet("font-weight: bold; font-size: 13px;")
header.addWidget(title) header.addWidget(title)
header.addStretch() header.addStretch()
@ -43,9 +45,9 @@ class TodaySummaryCard(QFrame):
self.total_label = QLabel("") self.total_label = QLabel("")
self.detail_label = QLabel("") self.detail_label = QLabel("")
self.detail_label.setStyleSheet("color: #888; font-size: 11px;") self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
self.salary_label = QLabel("") self.salary_label = QLabel("")
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;") self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
layout.addWidget(self.total_label) layout.addWidget(self.total_label)
layout.addWidget(self.detail_label) layout.addWidget(self.detail_label)
@ -70,22 +72,22 @@ class TodaySummaryCard(QFrame):
""" """
h = int(total_hours) h = int(total_hours)
m = int((total_hours - h) * 60) m = int((total_hours - h) * 60)
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}") self.total_label.setText(tr('today.total_work', hours=h, minutes=m))
details = [] details = []
if lunch_minutes > 0: if lunch_minutes > 0:
details.append(f"점심 {lunch_minutes}") details.append(tr('today.detail_lunch', minutes=lunch_minutes))
if dinner_minutes > 0: if dinner_minutes > 0:
details.append(f"저녁 {dinner_minutes}") details.append(tr('today.detail_dinner', minutes=dinner_minutes))
if break_minutes > 0: if break_minutes > 0:
details.append(f"외출 {break_minutes}") details.append(tr('today.detail_break', minutes=break_minutes))
if overtime_actual > 0: if overtime_actual > 0:
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}") details.append(tr('today.detail_overtime', actual=overtime_actual, earned=overtime_earned))
self.detail_label.setText(" · ".join(details) if details else "") self.detail_label.setText(" · ".join(details) if details else "")
self.detail_label.setVisible(bool(details)) self.detail_label.setVisible(bool(details))
if salary_text: if salary_text:
self.salary_label.setText(f"💰 {salary_text}") self.salary_label.setText(f"{salary_text}")
self.salary_label.setVisible(True) self.salary_label.setVisible(True)
else: else:
self.salary_label.setVisible(False) self.salary_label.setVisible(False)

View File

@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
3. new_exe target_exe 이동 3. new_exe target_exe 이동
4. target_exe 재실행 + 업데이터 자가 종료 4. target_exe 재실행 + 업데이터 자가 종료
실패 .bak 복원 실패 .bak 복원
빌드: console=False (windowed) 사용자 눈엔 cmd 창이 보임.
모든 진단 출력은 ~/.clockout_logs/updater.log append.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
@ -21,9 +24,30 @@ import shutil
import subprocess import subprocess
import sys import sys
import time import time
from datetime import datetime
from pathlib import Path from pathlib import Path
# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'
def _log(msg: str) -> None:
"""진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
# stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
try:
print(line, end='', file=sys.stderr)
except Exception:
pass
try:
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_LOG_PATH, 'a', encoding='utf-8') as f:
f.write(line)
except OSError:
pass
def is_pid_running(pid: int) -> bool: def is_pid_running(pid: int) -> bool:
"""Windows에서 PID 실행 중인지 확인.""" """Windows에서 PID 실행 중인지 확인."""
if sys.platform != 'win32': if sys.platform != 'win32':
@ -81,8 +105,7 @@ def replace_file(new_path: Path, target_path: Path,
try: try:
backup.unlink() backup.unlink()
except OSError as e: except OSError as e:
print(f"[updater] old backup unlink failed (continuing): {e}", _log(f"[updater] old backup unlink failed (continuing): {e}")
file=sys.stderr)
# 2단계: target → backup 이동 (락 해제 대기 재시도) # 2단계: target → backup 이동 (락 해제 대기 재시도)
for attempt in range(max_retries): for attempt in range(max_retries):
@ -94,13 +117,12 @@ def replace_file(new_path: Path, target_path: Path,
except OSError as e: except OSError as e:
last_err = e last_err = e
wait = 0.3 * (2 ** attempt) wait = 0.3 * (2 ** attempt)
print(f"[updater] target move attempt {attempt+1}/{max_retries} " _log(f"[updater] target move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr) f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait) time.sleep(wait)
else: else:
# 모든 재시도 실패 # 모든 재시도 실패
print(f"[updater] target move failed after {max_retries} attempts: {last_err}", _log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
file=sys.stderr)
return None return None
# 3단계: new → target 이동 # 3단계: new → target 이동
@ -111,19 +133,18 @@ def replace_file(new_path: Path, target_path: Path,
except OSError as e: except OSError as e:
last_err = e last_err = e
wait = 0.3 * (2 ** attempt) wait = 0.3 * (2 ** attempt)
print(f"[updater] new move attempt {attempt+1}/{max_retries} " _log(f"[updater] new move attempt {attempt+1}/{max_retries} "
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr) f"failed ({e}); waiting {wait:.1f}s")
time.sleep(wait) time.sleep(wait)
# new 이동 실패 → backup으로 롤백 시도 # new 이동 실패 → backup으로 롤백 시도
print(f"[updater] new move failed after {max_retries} attempts: {last_err}", _log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
file=sys.stderr)
if backup.exists() and not target_path.exists(): if backup.exists() and not target_path.exists():
try: try:
shutil.move(str(backup), str(target_path)) shutil.move(str(backup), str(target_path))
print("[updater] rolled back from backup", file=sys.stderr) _log("[updater] rolled back from backup")
except OSError as e: except OSError as e:
print(f"[updater] rollback also failed: {e}", file=sys.stderr) _log(f"[updater] rollback also failed: {e}")
return None return None
@ -131,13 +152,20 @@ def launch(exe_path: Path) -> bool:
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기.""" """새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
try: try:
if sys.platform == 'win32': if sys.platform == 'win32':
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
DETACHED_PROCESS = 0x00000008 DETACHED_PROCESS = 0x00000008
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True) CREATE_NO_WINDOW = 0x08000000
subprocess.Popen(
[str(exe_path)],
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
close_fds=True,
)
else: else:
subprocess.Popen([str(exe_path)], close_fds=True) subprocess.Popen([str(exe_path)], close_fds=True)
return True return True
except OSError as e: except OSError as e:
print(f"[updater] launch failed: {e}", file=sys.stderr) _log(f"[updater] launch failed: {e}")
return False return False
@ -149,15 +177,17 @@ def main() -> int:
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함') parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
args = parser.parse_args() args = parser.parse_args()
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
new_exe = Path(args.new).resolve() new_exe = Path(args.new).resolve()
target_exe = Path(args.target).resolve() target_exe = Path(args.target).resolve()
if not new_exe.exists(): if not new_exe.exists():
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr) _log(f"[updater] new exe not found: {new_exe}")
return 2 return 2
if not wait_for_exit(args.pid, timeout_sec=30): if not wait_for_exit(args.pid, timeout_sec=30):
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr) _log(f"[updater] timeout waiting for PID {args.pid}")
return 3 return 3
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음. # Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
@ -166,13 +196,15 @@ def main() -> int:
backup = replace_file(new_exe, target_exe) backup = replace_file(new_exe, target_exe)
if backup is None: if backup is None:
_log("[updater] replace_file failed — aborting")
return 4 return 4
if args.no_launch: if args.no_launch:
_log("[updater] --no-launch set, exiting after replace")
return 0 return 0
if not launch(target_exe): if not launch(target_exe):
# 시작 실패 시 롤백 _log("[updater] launch failed — rolling back")
try: try:
target_exe.unlink() target_exe.unlink()
shutil.move(str(backup), str(target_exe)) shutil.move(str(backup), str(target_exe))
@ -181,7 +213,7 @@ def main() -> int:
pass pass
return 5 return 5
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink. _log("[updater] update complete, new app launched")
return 0 return 0

View File

@ -33,7 +33,7 @@ exe = EXE(
upx=True, upx=True,
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지 console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
disable_windowed_traceback=False, disable_windowed_traceback=False,
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,

View File

@ -138,6 +138,7 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
conn = db.get_connection() conn = db.get_connection()
cursor = conn.cursor() cursor = conn.cursor()
try: try:
cursor.execute("DELETE FROM overtime_usage WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],)) cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))

View File

@ -13,7 +13,9 @@ from typing import Optional, List
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010). # Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
# 일반 브라우저 UA로 위장해야 통과. # 일반 브라우저 UA로 위장해야 통과.
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3' # 버전은 core/version.py에서 동기화.
from core.version import __version__
USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/{__version__}'
# Discord embed 색상 (decimal) # Discord embed 색상 (decimal)
COLOR_GREEN = 0x57F287 COLOR_GREEN = 0x57F287

84
utils/font_loader.py Normal file
View File

@ -0,0 +1,84 @@
"""번들 폰트(NanumSquare) 로딩.
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
등록 실패 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
"""
from __future__ import annotations
import os
import sys
from PyQt5.QtGui import QFontDatabase, QFont
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
_FONT_FILES = [
'NanumSquareL.ttf',
'NanumSquareR.ttf',
'NanumSquareB.ttf',
'NanumSquareEB.ttf',
'NanumSquare_acR.ttf',
'NanumSquare_acB.ttf',
]
def _font_dir() -> str:
"""번들 font/ 디렉토리 절대 경로."""
if getattr(sys, 'frozen', False):
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
else:
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, 'font')
def load_bundled_fonts() -> list:
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
families: list = []
fdir = _font_dir()
if not os.path.isdir(fdir):
return families
for name in _FONT_FILES:
path = os.path.join(fdir, name)
if not os.path.exists(path):
continue
fid = QFontDatabase.addApplicationFont(path)
if fid == -1:
continue
for fam in QFontDatabase.applicationFontFamilies(fid):
if fam not in families:
families.append(fam)
return families
def _pick_primary(families: list) -> str:
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
if 'NanumSquare' in families:
return 'NanumSquare'
for fam in families:
low = fam.lower()
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
return fam
return 'Malgun Gothic'
def apply_app_font(app, point_size: int = 9) -> str:
"""앱 전역 기본 폰트를 NanumSquare로 설정.
Returns:
실제 적용된 primary family 이름 (폴백 'Malgun Gothic').
"""
families = load_bundled_fonts()
primary = _pick_primary(families)
font = QFont(primary, point_size)
font.setStyleStrategy(QFont.PreferAntialias)
app.setFont(font)
return primary
if __name__ == '__main__':
from PyQt5.QtWidgets import QApplication
_app = QApplication(sys.argv)
fams = load_bundled_fonts()
print('font dir:', _font_dir())
print('registered families:', fams)
print('picked primary:', _pick_primary(fams))

100
utils/holiday_api.py Normal file
View File

@ -0,0 +1,100 @@
"""
공공데이터포털 한국천문연구원 특일정보 OpenAPI 클라이언트.
엔드포인트: getRestDeInfo (국경일/공휴일 임시공휴일 포함)
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
`holidays` 패키지가 누락하는 임시공휴일·근로자의 등을
정부 공인 데이터로 보강하기 위해 사용.
설계:
- 네트워크 실패는 silent (None 반환) 호출자가 fallback 처리
- API 키는 코드 박혀있으나 dev 본인 계정의 특일정보 API 한정
(50 이내 사용 환경에서 일일 한도 1000 충분)
"""
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
import urllib.error
from typing import List, Dict, Optional
# 공공데이터포털 특일정보 API 서비스 키.
# 소스코드/바이너리 노출 방지를 위해 환경변수에서 읽습니다.
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
_SERVICE_KEY = os.environ.get('CLOCKOUT_HOLIDAY_API_KEY', '')
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
Returns:
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 )
"""
params = {
'serviceKey': _SERVICE_KEY,
'solYear': str(year),
'_type': 'json',
'numOfRows': '100',
'pageNo': '1',
}
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except (urllib.error.URLError, urllib.error.HTTPError,
json.JSONDecodeError, OSError, TimeoutError):
return None
return _parse_response(data)
def _parse_response(data: Dict) -> Optional[List[Dict]]:
"""API 응답 JSON을 표준 형식으로 정규화.
API 응답 패턴:
- resultCode == '00' 정상
- items.item: 단일 결과면 dict, 여러 개면 list
- items가 문자열일 (totalCount=0) 정상으로 간주
"""
try:
response = data.get('response') or {}
header = response.get('header') or {}
if header.get('resultCode') != '00':
return None
body = response.get('body') or {}
items_root = body.get('items')
if not items_root:
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
item = items_root.get('item') if isinstance(items_root, dict) else None
if item is None:
return []
if isinstance(item, dict):
item = [item]
out = []
for entry in item:
locdate = entry.get('locdate')
name = entry.get('dateName')
is_holiday = (entry.get('isHoliday') == 'Y')
if not locdate or not name:
continue
# locdate: 20260501 (int 또는 str)
ds = str(locdate)
if len(ds) != 8 or not ds.isdigit():
continue
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
return out
except (AttributeError, TypeError, KeyError):
return None
def is_configured() -> bool:
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10

View File

@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
return QIcon(pixmap) return QIcon(pixmap)
def setup_menu(self): def setup_menu(self):
"""트레이 메뉴 설정""" """트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
menu = QMenu() menu = QMenu()
show_action = QAction(tr('tray.open'), self) # (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
show_action.triggered.connect(self.show_window) self._icon_actions = []
menu.addAction(show_action)
mini_action = QAction(tr('tray.mini_widget'), self) def add(text, slot, icon_name=None):
mini_action.triggered.connect(self._open_mini_widget) action = QAction(text, self)
menu.addAction(mini_action) action.triggered.connect(slot)
menu.addAction(action)
if icon_name:
self._icon_actions.append((action, icon_name))
return action
add(tr('tray.open'), self.show_window, 'home')
add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
menu.addSeparator() menu.addSeparator()
lunch_action = QAction(tr('tray.toggle_lunch'), self) add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
lunch_action.triggered.connect(self._toggle_lunch) add(tr('btn.break_out'), self._break_out)
menu.addAction(lunch_action) add(tr('btn.break_in'), self._break_in)
break_out_action = QAction(tr('btn.break_out'), self)
break_out_action.triggered.connect(self._break_out)
menu.addAction(break_out_action)
break_in_action = QAction(tr('btn.break_in'), self)
break_in_action.triggered.connect(self._break_in)
menu.addAction(break_in_action)
menu.addSeparator() menu.addSeparator()
clock_out_action = QAction("" + tr('btn.clock_out'), self) add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
clock_out_action.triggered.connect(self.quick_clock_out)
menu.addAction(clock_out_action)
menu.addSeparator() menu.addSeparator()
stats_action = QAction("📊 " + tr('menu.stats'), self) add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
stats_action.triggered.connect(lambda: self._call_parent('show_stats')) add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
menu.addAction(stats_action) add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
cal_action = QAction("📅 " + tr('menu.calendar'), self)
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
schedule_action = QAction("🗓️ 스케줄", self)
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
menu.addAction(schedule_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)
menu.addSeparator() menu.addSeparator()
quit_action = QAction(tr('tray.quit'), self) add(tr('tray.quit'), self.quit_app)
quit_action.triggered.connect(self.quit_app)
menu.addAction(quit_action)
self.setContextMenu(menu) self.setContextMenu(menu)
self.refresh_theme()
def refresh_theme(self):
"""트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
QMenu() 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
명시적으로 적용한다. 테마 변경 main_window.apply_theme에서 호출.
"""
menu = self.contextMenu()
if menu is None:
return
# 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
qss = self.parent_window.styleSheet() if self.parent_window else ''
if not qss:
try:
from ui.styles import get_theme
qss = get_theme('dark')
except Exception:
qss = ''
if qss:
menu.setStyleSheet(qss)
# 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
try:
from ui.icons import get_icon
from ui.styles import ThemeColors
color = ThemeColors.get('text_primary')
for action, name in getattr(self, '_icon_actions', []):
action.setIcon(get_icon(name, color, 16))
except Exception:
pass
def _call_parent(self, method_name: str): def _call_parent(self, method_name: str):
if self.parent_window and hasattr(self.parent_window, method_name): if self.parent_window and hasattr(self.parent_window, method_name):

View File

@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
pid = os.getpid() pid = os.getpid()
try: try:
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0 # CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0 # 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
if sys.platform == 'win32':
DETACHED_PROCESS = 0x00000008
CREATE_NO_WINDOW = 0x08000000
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
else:
creationflags = 0
subprocess.Popen( subprocess.Popen(
[str(updater_exe), [str(updater_exe),
'--pid', str(pid), '--pid', str(pid),