i18n: complete UI/achievement keying, fix help_view tr() and i18n syntax errors
This commit is contained in:
parent
f5751460e3
commit
63b0e324b9
610
AGENTS.md
610
AGENTS.md
@ -1,176 +1,476 @@
|
||||
# Project Conventions and Operational Gotchas
|
||||
# Clock-out Time Calculator — Agent Guide
|
||||
|
||||
## 🛠️ Setup & Execution
|
||||
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
|
||||
- **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.0–2.2 + 15+ for v2.3+)
|
||||
- `python _i18n_gui_test.py` — ko/en switch on real widgets
|
||||
- `python _gui_smoke_test.py` — widget instantiation
|
||||
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
|
||||
> Last verified against the working tree at version **2.11.2** (`core/version.py`).
|
||||
> 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.
|
||||
|
||||
## 🗄️ Architecture Notes (Core Business Logic)
|
||||
---
|
||||
|
||||
### Database (10+ tables in `database.db`)
|
||||
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
|
||||
## 1. Project Overview
|
||||
|
||||
### Invariants
|
||||
- **`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).
|
||||
**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.
|
||||
|
||||
### Settings system
|
||||
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
||||
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
||||
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
||||
- **Primary language:** Python 3.9+
|
||||
- **GUI framework:** PyQt5
|
||||
- **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout
|
||||
- **Packaging:** PyInstaller (`main.exe` + `updater.exe`)
|
||||
- **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
|
||||
- `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.
|
||||
The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
|
||||
|
||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||
---
|
||||
|
||||
### 1. Time-off subtraction order in `update_display()`
|
||||
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. Technology Stack
|
||||
|
||||
### 2. Hot-path caching
|
||||
`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`.
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| 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
|
||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
||||
Dependencies are declared in `requirements.txt`:
|
||||
|
||||
### 4. Workday boundary
|
||||
`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.
|
||||
```text
|
||||
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
|
||||
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
|
||||
Install with:
|
||||
|
||||
```bash
|
||||
# Manual two-step
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`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/
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
||||
|
||||
# Or one-shot
|
||||
.\release.ps1 v2.7.0
|
||||
python -m PyInstaller --clean main.spec
|
||||
```
|
||||
|
||||
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
||||
- `holidays` package only baked in if installed in build env.
|
||||
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
||||
- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
|
||||
- `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`).
|
||||
- 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.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
|
||||
- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only.
|
||||
- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
|
||||
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
|
||||
## 🐞 Past Incidents (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)
|
||||
```bash
|
||||
.\release.ps1 v2.11.2
|
||||
```
|
||||
|
||||
`--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** (S1–S52, S52A–E) 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 -Raw` mangled Korean. Use `[System.IO.File]::ReadAllText(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.
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.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
|
||||
|
||||
@ -11,6 +11,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
|
||||
# 백그라운드 휴일 동기화 스레드 비활성화 (DB lock / segfault 방지)
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
@ -87,7 +90,7 @@ def test_stats_view():
|
||||
from ui.stats_view import StatsView
|
||||
dlg = StatsView(db=db)
|
||||
# 데이터 없어도 정상 로드
|
||||
assert dlg.weekly_total_hours.text() is not None
|
||||
assert dlg.weekly_total_card is not None
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
@ -101,12 +104,25 @@ def test_main_window_init():
|
||||
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
||||
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
||||
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
|
||||
# auto_lunch 캐시 초기 None
|
||||
assert w._auto_lunch_enabled_cache is None
|
||||
# auto_lunch 캐시 초기 None (AutoLunchManager 낶)
|
||||
assert w._auto_lunch._enabled_cache is None
|
||||
# 단축키 7개 등록되었는지
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
shortcuts = w.findChildren(QShortcut)
|
||||
|
||||
@ -9,6 +9,7 @@ import sys
|
||||
import tempfile
|
||||
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
- notification_log: 휴식 권고 카운트
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from core.i18n import tr
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Callable, Optional, List, Tuple
|
||||
@ -426,40 +427,40 @@ def _bool_eval(condition_fn):
|
||||
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
||||
_STREAK_DEFS = [
|
||||
# (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, '👋'),
|
||||
('streak_3', '뿌리내림', '3일 연속 영업일 출근', 3,
|
||||
('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3,
|
||||
_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, '📅'),
|
||||
('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, '🔥'),
|
||||
('streak_10', '2주 연속', '10 영업일 연속 출근', 10,
|
||||
('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10,
|
||||
_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, '🏔️'),
|
||||
('streak_50', '50일 연속', '50 영업일 연속 출근', 50,
|
||||
('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50,
|
||||
_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, '💎'),
|
||||
('streak_quarter', '분기 완주', '약 65 영업일 (3개월)', 65,
|
||||
('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65,
|
||||
_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, '👑'),
|
||||
('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, '🌟'),
|
||||
('streak_200', '사이언스', '200 영업일 연속', 200,
|
||||
('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200,
|
||||
_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, '🛡️'),
|
||||
('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
|
||||
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, '💼'),
|
||||
('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, '🏛️'),
|
||||
('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, '🎖️'),
|
||||
]
|
||||
|
||||
@ -476,37 +477,37 @@ def _count_weekday_clockins(db, weekday: int) -> int:
|
||||
|
||||
|
||||
_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, '🌅'),
|
||||
('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, '🌒'),
|
||||
])
|
||||
|
||||
|
||||
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
||||
_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, '🌄'),
|
||||
('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, '🐦'),
|
||||
('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, '🌞'),
|
||||
('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, '🥱'),
|
||||
('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, '🌑'),
|
||||
('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, '🌌'),
|
||||
('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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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개 코어) ----
|
||||
_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, '🚪'),
|
||||
('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, '🎉'),
|
||||
('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, '🏃'),
|
||||
('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, '🏖️'),
|
||||
('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, '🪐'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 4. 연장근무 적립 ----
|
||||
_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, '💰'),
|
||||
('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, '💵'),
|
||||
('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, '🏦'),
|
||||
('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, '💎'),
|
||||
('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, '🏆'),
|
||||
('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, '🎯'),
|
||||
('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, '🏔️'),
|
||||
('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, '🌑'),
|
||||
('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, '⚠️'),
|
||||
('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, '🚑'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 5. 연장근무 사용 ----
|
||||
_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, '🛌'),
|
||||
('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, '🎁'),
|
||||
('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, '🛀'),
|
||||
('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, '🏖️'),
|
||||
('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, '💆'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 6. 연차 ----
|
||||
_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, '🌴'),
|
||||
('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, '🍃'),
|
||||
('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, '⏱️'),
|
||||
('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, '🏝️'),
|
||||
('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, '🌅'),
|
||||
('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, '🛬'),
|
||||
('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, '🌊'),
|
||||
('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),
|
||||
TIER_BRONZE, '🏥'),
|
||||
]
|
||||
@ -602,22 +603,22 @@ _LEAVE_DEFS = [
|
||||
|
||||
# ---- 7. 식사 (점심/저녁) ----
|
||||
_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, '🍱'),
|
||||
('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, '🥢'),
|
||||
('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, '🍜'),
|
||||
('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, '🍽️'),
|
||||
('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, '🍛'),
|
||||
('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, '🌃'),
|
||||
('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),
|
||||
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),
|
||||
TIER_BRONZE, '⏰'),
|
||||
]
|
||||
@ -625,13 +626,13 @@ _MEAL_DEFS = [
|
||||
|
||||
# ---- 8. 외출 ----
|
||||
_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),
|
||||
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),
|
||||
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),
|
||||
TIER_GOLD, '🚶♂️'),
|
||||
]
|
||||
@ -639,39 +640,39 @@ _BREAK_DEFS = [
|
||||
|
||||
# ---- 9. 시간대별 ----
|
||||
_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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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, '🌚'),
|
||||
('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, '🌌'),
|
||||
]
|
||||
|
||||
@ -692,50 +693,50 @@ def _count_clockouts_in_hour(db, hour: int) -> int:
|
||||
|
||||
# ---- 10. 공휴일·주말 ----
|
||||
_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, '🌃'),
|
||||
('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, '🌑'),
|
||||
('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, '💀'),
|
||||
('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, '📆'),
|
||||
('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, '⚠️'),
|
||||
('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, '🎄'),
|
||||
('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, '🎊'),
|
||||
('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, '🎆'),
|
||||
('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, '🎀'),
|
||||
('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, '🎤'),
|
||||
('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, '💝'),
|
||||
('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, '🌹'),
|
||||
('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, '🍫'),
|
||||
('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, '🎃'),
|
||||
('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, '🃏'),
|
||||
('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, '🎋'),
|
||||
('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, '🎇'),
|
||||
('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')),
|
||||
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')),
|
||||
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')),
|
||||
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, '🌏'),
|
||||
]
|
||||
|
||||
@ -760,79 +761,79 @@ def _make_month_first_eval(month: int):
|
||||
|
||||
|
||||
_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, '⛄'),
|
||||
('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, '🌨️'),
|
||||
('season_mar', '봄을 맞이', '3월 첫 출근', 1,
|
||||
('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1,
|
||||
_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, '🌷'),
|
||||
('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, '🌺'),
|
||||
('season_jun', '여름의 시작', '6월 첫 출근', 1,
|
||||
('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1,
|
||||
_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, '🌻'),
|
||||
('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, '🍦'),
|
||||
('season_sep', '가을의 시작', '9월 첫 출근', 1,
|
||||
('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1,
|
||||
_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, '🌾'),
|
||||
('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, '🍁'),
|
||||
('season_dec', '겨울의 시작', '12월 첫 출근', 1,
|
||||
('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1,
|
||||
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 12. 앱 사용 마일스톤 ----
|
||||
_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),
|
||||
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, '🗓️'),
|
||||
('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, '📚'),
|
||||
('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, '💎'),
|
||||
('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, '🌟'),
|
||||
('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, '🎖️'),
|
||||
('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, '🏆'),
|
||||
('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, '🎖️'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 13. 통계·분석 (view counter 기반) ----
|
||||
_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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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),
|
||||
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'),
|
||||
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),
|
||||
TIER_BRONZE, '🦄'),
|
||||
]
|
||||
@ -957,23 +958,23 @@ def _has_500_anniv_clockin(db) -> bool:
|
||||
|
||||
|
||||
_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, '🪞'),
|
||||
('secret_jackpot', '잭팟 시각', '출근 시각 모든 자릿수 동일', 1,
|
||||
('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1,
|
||||
_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, '🌑'),
|
||||
('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, '🔮'),
|
||||
('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, '🎯'),
|
||||
('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, '🥧'),
|
||||
('secret_fibonacci', '피보나치', '출근 분이 피보나치 수', 1,
|
||||
('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1,
|
||||
_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, '🎲'),
|
||||
('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, '🧙'),
|
||||
]
|
||||
|
||||
@ -984,30 +985,30 @@ def _setting_changed_from_default(db, key: str, default_value: str) -> bool:
|
||||
|
||||
|
||||
_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')),
|
||||
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'),
|
||||
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'
|
||||
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
||||
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'),
|
||||
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
|
||||
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
||||
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 '')
|
||||
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
||||
('notification_clock_out', 'notification_lunch',
|
||||
'notification_overtime', 'notification_health'))),
|
||||
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 '')),
|
||||
TIER_SILVER, '☁️'),
|
||||
]
|
||||
@ -1032,21 +1033,21 @@ def _earned_secret_count(db) -> int:
|
||||
|
||||
|
||||
_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, '🏆'),
|
||||
('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, '🎖️'),
|
||||
('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, '🥈'),
|
||||
('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, '🥇'),
|
||||
('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, '💎'),
|
||||
('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, '🌟'),
|
||||
('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, '🔍'),
|
||||
('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, '🌑'),
|
||||
]
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from core.settings_keys import (
|
||||
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
|
||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||
)
|
||||
from utils.debug_log import dlog
|
||||
|
||||
|
||||
class Database:
|
||||
@ -53,8 +54,8 @@ class Database:
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"connection close failed: {e}")
|
||||
|
||||
def _enable_concurrency(self):
|
||||
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
|
||||
@ -118,7 +119,8 @@ class Database:
|
||||
sentinel = self.get_setting('holidays_synced_date', '')
|
||||
if sentinel == today:
|
||||
return
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
dlog(f"holiday sync precheck failed: {e}")
|
||||
return
|
||||
|
||||
cur_year = _dt.now().year
|
||||
@ -131,8 +133,8 @@ class Database:
|
||||
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
|
||||
if added >= 0:
|
||||
db.set_setting('holidays_synced_date', today)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"holiday sync worker failed: {e}")
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
||||
t.start()
|
||||
@ -1070,7 +1072,8 @@ class Database:
|
||||
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
||||
recs = self.get_recurring_leaves(active_on=date_str)
|
||||
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
|
||||
|
||||
return concrete_days + recurring_days
|
||||
@ -1356,17 +1359,21 @@ class Database:
|
||||
for row in rows:
|
||||
key = row['key']
|
||||
value = row['value']
|
||||
# 타입 변환
|
||||
if value.lower() in ['true', 'false']:
|
||||
settings[key] = value.lower() == 'true'
|
||||
else:
|
||||
# 타입 변환 (bool 우선, 숫자, 문자열 순)
|
||||
lower = (value or '').lower()
|
||||
if lower in ('1', 'true', 'yes', 'on'):
|
||||
settings[key] = True
|
||||
continue
|
||||
if lower in ('0', 'false', 'no', 'off', ''):
|
||||
settings[key] = False
|
||||
continue
|
||||
try:
|
||||
settings[key] = int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
settings[key] = int(value)
|
||||
settings[key] = float(value)
|
||||
except ValueError:
|
||||
try:
|
||||
settings[key] = float(value)
|
||||
except ValueError:
|
||||
settings[key] = value
|
||||
settings[key] = value
|
||||
return settings
|
||||
|
||||
def save_settings(self, settings: Dict):
|
||||
@ -1388,7 +1395,8 @@ class Database:
|
||||
pass
|
||||
elif 'work_hours' in synced and 'work_minutes' not in synced:
|
||||
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):
|
||||
pass
|
||||
|
||||
@ -1674,7 +1682,8 @@ class Database:
|
||||
''', (date, leave_type, days, memo))
|
||||
conn.commit()
|
||||
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
|
||||
self.set_leave_balance(current_balance - days)
|
||||
# get_leave_balance()가 leave_records를 실시간으로 계산하므로
|
||||
# 별도로 leave_balance 설정값을 갱신할 필요가 없음.
|
||||
|
||||
# ===== 공휴일 관련 메서드 =====
|
||||
|
||||
@ -1838,7 +1847,8 @@ class Database:
|
||||
try:
|
||||
import holidays as _holidays
|
||||
kr = _holidays.country_holidays('KR', years=y)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
dlog(f"holidays package fallback failed for {y}: {e}")
|
||||
continue # 둘 다 실패면 해당 연도만 스킵
|
||||
for d, name in kr.items():
|
||||
date_str = d.isoformat()
|
||||
|
||||
1834
core/i18n.py
1834
core/i18n.py
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,8 @@ from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
'mon': 0, 'monday': 0,
|
||||
@ -25,6 +27,7 @@ _WEEKDAY_MAP = {
|
||||
'sat': 5, 'saturday': 5,
|
||||
'sun': 6, 'sunday': 6,
|
||||
}
|
||||
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -135,19 +138,16 @@ def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||
return None
|
||||
|
||||
|
||||
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
|
||||
def describe_pattern(pattern: str) -> str:
|
||||
"""사용자에게 보여줄 패턴 설명. ko."""
|
||||
"""사용자에게 보여줄 패턴 설명."""
|
||||
parsed = _parse_pattern(pattern)
|
||||
if parsed is None:
|
||||
return pattern
|
||||
kind, info = parsed
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
||||
prefix = '매주' if kind == 'weekly' else '격주'
|
||||
return f"{prefix} {','.join(names)}요일"
|
||||
names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info]
|
||||
prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly')
|
||||
return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names))
|
||||
if kind == 'monthly':
|
||||
return f"매월 {info}일"
|
||||
return tr('recurring.pattern_monthly', day=info)
|
||||
return pattern
|
||||
|
||||
@ -21,7 +21,8 @@ class TimeCalculator:
|
||||
if work_minutes is not None:
|
||||
self.work_minutes = int(work_minutes)
|
||||
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:
|
||||
self.work_minutes = 480
|
||||
|
||||
|
||||
@ -4,13 +4,15 @@ utils.csv_importer 단위 테스트.
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records
|
||||
from core.database import Database
|
||||
|
||||
|
||||
class TestNormalizeTime:
|
||||
@ -125,3 +127,54 @@ class TestParseCsv:
|
||||
assert '줄 3' in str(exc.value)
|
||||
finally:
|
||||
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
|
||||
|
||||
@ -162,5 +162,12 @@ class TestFetchNetwork:
|
||||
|
||||
|
||||
class TestConfigured:
|
||||
def test_key_set(self):
|
||||
assert is_configured() is True
|
||||
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
|
||||
|
||||
@ -16,6 +16,7 @@ from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
from core.achievements import get_all_with_status, get_stats
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||
|
||||
@ -29,7 +30,6 @@ TIER_THEMES = {
|
||||
'bg_bot': '#241810',
|
||||
'text': '#ffd9a8',
|
||||
'label': '🥉',
|
||||
'name': '브론즈',
|
||||
},
|
||||
'silver': {
|
||||
'border': '#a8a8a8',
|
||||
@ -38,7 +38,6 @@ TIER_THEMES = {
|
||||
'bg_bot': '#1c1c22',
|
||||
'text': '#e8e8f0',
|
||||
'label': '🥈',
|
||||
'name': '실버',
|
||||
},
|
||||
'gold': {
|
||||
'border': '#ffb700',
|
||||
@ -47,7 +46,6 @@ TIER_THEMES = {
|
||||
'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0',
|
||||
'label': '🥇',
|
||||
'name': '골드',
|
||||
},
|
||||
'platinum': {
|
||||
'border': '#7fdbff',
|
||||
@ -56,7 +54,6 @@ TIER_THEMES = {
|
||||
'bg_bot': '#0e1f28',
|
||||
'text': '#c5ecff',
|
||||
'label': '💎',
|
||||
'name': '플래티넘',
|
||||
},
|
||||
'legend': {
|
||||
'border': '#ff6b9d',
|
||||
@ -65,20 +62,9 @@ TIER_THEMES = {
|
||||
'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4',
|
||||
'label': '🌟',
|
||||
'name': '레전드',
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
||||
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
||||
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
||||
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
||||
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
||||
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
||||
'ambition': '야망', 'meta': '메타',
|
||||
}
|
||||
|
||||
|
||||
class AchievementsView(QDialog):
|
||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||
@ -86,7 +72,7 @@ class AchievementsView(QDialog):
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("도전과제")
|
||||
self.setWindowTitle(tr('achieve.title'))
|
||||
self.setMinimumSize(960, 720)
|
||||
self.resize(1100, 800)
|
||||
self._increment_view_count()
|
||||
@ -121,21 +107,21 @@ class AchievementsView(QDialog):
|
||||
if a['earned_date'] is None and not a['is_secret']]
|
||||
secret_items = [a for a in all_items if a['is_secret']]
|
||||
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
|
||||
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),
|
||||
f"✓ 완료 · {len(earned_items)}")
|
||||
tr('achieve.tab_completed', count=len(earned_items)))
|
||||
self.tabs.addTab(
|
||||
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)
|
||||
|
||||
# === 닫기 버튼 ===
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.setMinimumWidth(100)
|
||||
close_btn.setStyleSheet(button_qss('default'))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
@ -183,7 +169,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
secret_lbl = QLabel(
|
||||
f"<div style='line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 시크릿</span><br>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||
@ -196,7 +182,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
pct_lbl = QLabel(
|
||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>달성률</span><br>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||
f"{pct:.1f}%</span></div>"
|
||||
)
|
||||
@ -242,7 +228,7 @@ class AchievementsView(QDialog):
|
||||
grid.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
if not items:
|
||||
empty = QLabel("(아직 없음)")
|
||||
empty = QLabel(tr('achieve.empty'))
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
@ -344,9 +330,9 @@ class AchievementsView(QDialog):
|
||||
name.setWordWrap(True)
|
||||
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:
|
||||
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(
|
||||
f"font-size: 8.5pt; "
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
||||
@ -368,7 +354,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
# 2행: 설명
|
||||
if is_locked_secret:
|
||||
desc_text = "🔒 달성하면 공개됩니다"
|
||||
desc_text = tr('achieve.secret_locked')
|
||||
else:
|
||||
desc_text = item['description'] or ''
|
||||
desc = QLabel(desc_text)
|
||||
@ -381,7 +367,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
# 3행: 진행 게이지 또는 획득 일자
|
||||
if is_earned:
|
||||
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
||||
earned = QLabel(tr('achieve.earned_date', date=item['earned_date']))
|
||||
earned.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
|
||||
@ -12,6 +12,7 @@ import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.i18n import tr
|
||||
from ui.styles import ThemeColors, apply_dark_titlebar
|
||||
|
||||
|
||||
@ -37,7 +38,7 @@ class CalendarView(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("월간 근무 기록")
|
||||
title = QLabel(tr('cal.dialog_title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
@ -55,8 +56,8 @@ class CalendarView(QDialog):
|
||||
# 범례
|
||||
legend_layout = QHBoxLayout()
|
||||
legend_layout.setSpacing(12)
|
||||
for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
|
||||
('#FAB005', '휴가'), ('#6C6E73', '없음')]:
|
||||
for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')),
|
||||
('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]:
|
||||
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
_item.setTextFormat(Qt.RichText)
|
||||
legend_layout.addWidget(_item)
|
||||
@ -64,7 +65,7 @@ class CalendarView(QDialog):
|
||||
layout.addLayout(legend_layout)
|
||||
|
||||
# 선택된 날짜 상세 정보
|
||||
detail_group = QGroupBox("선택된 날짜 정보")
|
||||
detail_group = QGroupBox(tr('cal.detail_group_title'))
|
||||
detail_layout = QVBoxLayout()
|
||||
detail_layout.setSpacing(6)
|
||||
detail_layout.setContentsMargins(10, 20, 10, 8)
|
||||
@ -78,13 +79,13 @@ class CalendarView(QDialog):
|
||||
button_layout = QHBoxLayout()
|
||||
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.setEnabled(False)
|
||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||
button_layout.addWidget(self.edit_time_button)
|
||||
|
||||
self.delete_record_button = QPushButton("기록 삭제")
|
||||
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
||||
self.delete_record_button.setObjectName("btn_danger")
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||
@ -95,17 +96,17 @@ class CalendarView(QDialog):
|
||||
layout.addWidget(detail_group)
|
||||
|
||||
# 메모 그룹
|
||||
memo_group = QGroupBox("메모")
|
||||
memo_group = QGroupBox(tr('cal.memo_group'))
|
||||
memo_layout = QVBoxLayout()
|
||||
memo_layout.setSpacing(6)
|
||||
memo_layout.setContentsMargins(10, 20, 10, 8)
|
||||
|
||||
self.memo_edit = QTextEdit()
|
||||
self.memo_edit.setMaximumHeight(70)
|
||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||
self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder'))
|
||||
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.setEnabled(False)
|
||||
self.save_memo_button.clicked.connect(self.save_memo)
|
||||
@ -166,10 +167,10 @@ class CalendarView(QDialog):
|
||||
menu = QMenu(self)
|
||||
edit_action = delete_action = add_action = None
|
||||
if existing:
|
||||
edit_action = menu.addAction(f"{date_str} 편집")
|
||||
delete_action = menu.addAction(f"{date_str} 삭제")
|
||||
edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
|
||||
delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
|
||||
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))
|
||||
if action is None:
|
||||
@ -217,9 +218,9 @@ class CalendarView(QDialog):
|
||||
if ot_earned > 0:
|
||||
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||
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:
|
||||
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):
|
||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||
@ -231,8 +232,8 @@ class CalendarView(QDialog):
|
||||
|
||||
def _delete_record(self, date_str: str):
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
||||
tr('cal.delete_confirm_title'),
|
||||
tr('cal.delete_confirm_body', date=date_str),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
@ -245,10 +246,10 @@ class CalendarView(QDialog):
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||
conn.commit()
|
||||
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:
|
||||
conn.rollback()
|
||||
QMessageBox.critical(self, "오류", str(e))
|
||||
QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@ -270,33 +271,33 @@ class CalendarView(QDialog):
|
||||
|
||||
if record:
|
||||
# 상세 정보 표시
|
||||
detail = f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail += f"출근: {record['clock_in']}\n"
|
||||
detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
|
||||
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
||||
|
||||
if record.get('clock_out'):
|
||||
detail += f"퇴근: {record['clock_out']}\n"
|
||||
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
|
||||
detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
|
||||
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
||||
|
||||
if record.get('lunch_break'):
|
||||
detail += f"점심시간: 사용함\n"
|
||||
detail += tr('cal.detail_lunch_used') + '\n'
|
||||
else:
|
||||
detail += f"점심시간: 미사용\n"
|
||||
detail += tr('cal.detail_lunch_unused') + '\n'
|
||||
|
||||
if record.get('dinner_break'):
|
||||
detail += f"저녁시간: 사용함\n"
|
||||
detail += tr('cal.detail_dinner_used') + '\n'
|
||||
else:
|
||||
detail += f"저녁시간: 미사용\n"
|
||||
detail += tr('cal.detail_dinner_unused') + '\n'
|
||||
|
||||
if record.get('overtime_earned', 0) > 0:
|
||||
earned_min = record['overtime_earned']
|
||||
earned_hours = 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:
|
||||
detail += f"퇴근: 미기록\n"
|
||||
detail += tr('cal.detail_clock_out_none') + '\n'
|
||||
|
||||
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.edit_time_button.setEnabled(True)
|
||||
@ -306,7 +307,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||
self.save_memo_button.setEnabled(True)
|
||||
else:
|
||||
self.detail_text.setText(f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.detail_text.setText(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.delete_record_button.setEnabled(False)
|
||||
self.memo_edit.setPlainText('')
|
||||
@ -319,10 +320,8 @@ class CalendarView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"출근 기록 삭제",
|
||||
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
|
||||
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
|
||||
f"※ 이 작업은 되돌릴 수 없습니다.",
|
||||
tr('cal.delete_selected_title'),
|
||||
tr('cal.delete_selected_body', date=self.selected_date_str),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
@ -332,8 +331,8 @@ class CalendarView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"삭제 완료",
|
||||
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
|
||||
tr('cal.delete_done_title'),
|
||||
tr('cal.delete_done_body', date=self.selected_date_str)
|
||||
)
|
||||
|
||||
# 캘린더 새로고침
|
||||
@ -353,8 +352,8 @@ class CalendarView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"메모 저장",
|
||||
f"{self.selected_date_str}의 메모가 저장되었습니다."
|
||||
tr('cal.save_memo_title'),
|
||||
tr('cal.save_memo_body', date=self.selected_date_str)
|
||||
)
|
||||
|
||||
# 상세 정보 새로고침
|
||||
@ -400,7 +399,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
from PyQt5.QtWidgets import QTimeEdit
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
self.setWindowTitle("출퇴근 시간 수정")
|
||||
self.setWindowTitle(tr('cal.edit_dialog_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
@ -409,19 +408,19 @@ class EditWorkTimeDialog(QDialog):
|
||||
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")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 출근 시간
|
||||
clock_in_layout = QHBoxLayout()
|
||||
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.setFixedWidth(40)
|
||||
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.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
||||
clock_in_layout.addWidget(clock_in_minus_btn)
|
||||
@ -432,7 +431,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
self.clock_in_edit.setTime(clock_in_time)
|
||||
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.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
||||
clock_in_layout.addWidget(clock_in_plus_btn)
|
||||
@ -441,12 +440,12 @@ class EditWorkTimeDialog(QDialog):
|
||||
# 퇴근 시간
|
||||
clock_out_layout = QHBoxLayout()
|
||||
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.setFixedWidth(40)
|
||||
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.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
||||
clock_out_layout.addWidget(clock_out_minus_btn)
|
||||
@ -458,7 +457,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
self.clock_out_edit.setTime(clock_out_time)
|
||||
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.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
||||
clock_out_layout.addWidget(clock_out_plus_btn)
|
||||
@ -467,27 +466,27 @@ class EditWorkTimeDialog(QDialog):
|
||||
# 점심/저녁 체크박스 - 한 줄에
|
||||
from PyQt5.QtWidgets import QCheckBox
|
||||
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)))
|
||||
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)))
|
||||
check_layout.addWidget(self.dinner_check)
|
||||
layout.addLayout(check_layout)
|
||||
|
||||
# 안내 메시지
|
||||
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
|
||||
note = QLabel(tr('cal.edit_note'))
|
||||
note.setObjectName("note_text")
|
||||
layout.addWidget(note)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_success")
|
||||
save_button.clicked.connect(self.save_changes)
|
||||
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(save_button)
|
||||
@ -513,8 +512,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
if clock_out <= clock_in:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"시간 오류",
|
||||
"퇴근 시간은 출근 시간보다 늦어야 합니다."
|
||||
tr('cal.time_error_title'),
|
||||
tr('cal.time_error_body')
|
||||
)
|
||||
return
|
||||
|
||||
@ -597,15 +596,12 @@ class EditWorkTimeDialog(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"수정 완료",
|
||||
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
|
||||
f"출근: {clock_in}\n"
|
||||
f"퇴근: {clock_out}\n"
|
||||
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
|
||||
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
|
||||
f"외출시간: {break_minutes}분\n"
|
||||
f"총 근무시간: {total_hours:.1f}시간\n"
|
||||
f"연장근무: {overtime_earned}분 적립"
|
||||
tr('cal.edit_done_title'),
|
||||
tr('cal.edit_done_body',
|
||||
date=self.date_str, clock_in=clock_in, clock_out=clock_out,
|
||||
lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
|
||||
dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
|
||||
break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
|
||||
)
|
||||
|
||||
self.accept()
|
||||
@ -613,8 +609,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"오류",
|
||||
f"수정 중 오류가 발생했습니다:\n{str(e)}"
|
||||
tr('cal.edit_error_title'),
|
||||
tr('cal.edit_error_body', error=str(e))
|
||||
)
|
||||
finally:
|
||||
if conn:
|
||||
|
||||
@ -9,6 +9,8 @@ from typing import List, Tuple
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
from matplotlib.figure import Figure
|
||||
@ -89,7 +91,7 @@ class _Fallback(QWidget):
|
||||
def make_chart_widget(parent=None) -> QWidget:
|
||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||
if not _MPL:
|
||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||
return _Fallback(tr('chart.need_matplotlib'))
|
||||
_refresh_chart_colors()
|
||||
widget = QWidget(parent)
|
||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||
@ -115,7 +117,7 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
_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)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
@ -127,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)]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
||||
bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_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,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||
@ -157,9 +159,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
for i, bar in enumerate(bars):
|
||||
if bar.contains(event)[0]:
|
||||
h = hours[i]; ot = overtimes[i]
|
||||
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
||||
text = tr('chart.hover_text',
|
||||
date=full_dates[i], hours=f"{h:.1f}")
|
||||
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.set_text(text)
|
||||
annot.set_visible(True)
|
||||
@ -191,7 +194,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
_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)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
@ -211,7 +214,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
if not minutes_list:
|
||||
ax = fig.add_subplot(111)
|
||||
_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)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
@ -225,12 +228,13 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||
edgecolor=_CHART_BG, linewidth=1)
|
||||
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,
|
||||
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_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||
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,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
_apply_dark_axes(ax)
|
||||
@ -257,11 +261,13 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
weekday_counts[d.weekday()] += 1
|
||||
|
||||
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)
|
||||
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||
ax.bar(labels, avg, color=colors)
|
||||
ax.set_ylabel('평균 시간')
|
||||
ax.set_ylabel(tr('chart.ylabel_avg_hours'))
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
@ -134,8 +134,8 @@ if __name__ == "__main__":
|
||||
dialog = ClockInDialog()
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
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:
|
||||
print("취소됨")
|
||||
print(tr('clock_in_dialog.cancelled'))
|
||||
|
||||
sys.exit()
|
||||
|
||||
@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
||||
import sqlite3
|
||||
|
||||
|
||||
class LockMonitor:
|
||||
@ -61,14 +62,23 @@ class LockMonitor:
|
||||
clock_in_str = when.strftime("%H:%M:%S")
|
||||
existing = self.db.get_today_record()
|
||||
if existing:
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
||||
(clock_in_str, today),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
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()
|
||||
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()
|
||||
|
||||
@ -10,6 +10,7 @@ from datetime import datetime
|
||||
from core.settings_keys import (
|
||||
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||
)
|
||||
from core.i18n import tr
|
||||
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_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
||||
|
||||
title = "📊 지난주 요약"
|
||||
body = (f"기간: {last_mon} ~ {last_sun}\n"
|
||||
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
|
||||
f"일 평균: {avg_h:.1f}시간\n"
|
||||
f"연장근무: {ot_h}시간 {ot_m}분\n"
|
||||
f"가장 긴 날: {longest_str}")
|
||||
title = tr('notif.weekly_report.title')
|
||||
body = 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)
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
self.db.log_notification('system', 'weekly_report')
|
||||
|
||||
@ -70,13 +71,17 @@ class NotificationOrchestrator:
|
||||
try:
|
||||
from utils.discord_webhook import send, COLOR_BLUE
|
||||
fields = [
|
||||
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
|
||||
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
|
||||
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
|
||||
{"name": "가장 긴 날", "value": longest_str, "inline": False},
|
||||
{"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True},
|
||||
{"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True},
|
||||
{"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True},
|
||||
{"name": tr('field.longest_day'), "value": longest_str, "inline": False},
|
||||
]
|
||||
ok = send(url, "📊 지난주 요약",
|
||||
f"기간: {last_mon} ~ {last_sun}",
|
||||
ok = send(url, tr('notif.weekly_report.title'),
|
||||
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)
|
||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||
except Exception as e:
|
||||
@ -147,8 +152,8 @@ class NotificationOrchestrator:
|
||||
for a in unlocked:
|
||||
self.db.log_notification('system', f'achievement:{a.code}')
|
||||
if notif_on:
|
||||
title = f"{a.badge_icon} 도전과제 달성!"
|
||||
body = f"{a.name}\n{a.description}"
|
||||
title = tr('notif.achievement.title', icon=a.badge_icon)
|
||||
body = tr('notif.achievement.body', name=a.name, description=a.description)
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
# Discord 통합 push (여러 개면 묶어서)
|
||||
self._discord_achievements(unlocked)
|
||||
@ -167,8 +172,8 @@ class NotificationOrchestrator:
|
||||
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||
ok = discord_webhook.send(
|
||||
url,
|
||||
f"🏆 도전과제 {len(unlocked)}개 달성!",
|
||||
f"새로 잠금 해제된 도전과제 입니다.{extra}",
|
||||
tr('discord.achievement.title', count=len(unlocked)),
|
||||
tr('discord.achievement.body', extra=extra),
|
||||
color=discord_webhook.COLOR_YELLOW,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
@ -9,6 +9,8 @@ from datetime import datetime, date
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class GoalWidget(QWidget):
|
||||
"""월간 목표 진행률 표시."""
|
||||
@ -20,13 +22,13 @@ class GoalWidget(QWidget):
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title = QLabel("이번 달 목표")
|
||||
title = QLabel(tr('goal.title'))
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
self.ot_label = QLabel("연장근무:")
|
||||
self.ot_label = QLabel(tr('goal.overtime'))
|
||||
self.ot_label.setFixedWidth(100)
|
||||
self.ot_bar = QProgressBar()
|
||||
self.ot_bar.setTextVisible(True)
|
||||
@ -37,7 +39,7 @@ class GoalWidget(QWidget):
|
||||
|
||||
# 일평균
|
||||
avg_row = QHBoxLayout()
|
||||
self.avg_label = QLabel("일평균:")
|
||||
self.avg_label = QLabel(tr('goal.avg_daily'))
|
||||
self.avg_label.setFixedWidth(100)
|
||||
self.avg_bar = QProgressBar()
|
||||
self.avg_bar.setTextVisible(True)
|
||||
|
||||
@ -59,7 +59,7 @@ class HelpView(QDialog):
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton("온보딩 다시 보기")
|
||||
onboarding_button = QPushButton(tr('help.onboarding_button'))
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
|
||||
@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -22,7 +23,7 @@ class LeaveCalendarView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("연차 캘린더")
|
||||
self.setWindowTitle(tr('leave_cal.title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
@ -37,7 +38,7 @@ class LeaveCalendarView(QDialog):
|
||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||
used = total - balance
|
||||
title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||
title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@ -45,9 +46,9 @@ class LeaveCalendarView(QDialog):
|
||||
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
legend = QHBoxLayout()
|
||||
for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
|
||||
('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
|
||||
('#748FFC', '종일+예정')]:
|
||||
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')),
|
||||
('#748FFC', tr('leave_cal.legend_full_planned'))]:
|
||||
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
l.setStyleSheet("padding: 2px 6px;")
|
||||
legend.addWidget(l)
|
||||
@ -68,7 +69,7 @@ class LeaveCalendarView(QDialog):
|
||||
# 닫기 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
@ -109,12 +110,12 @@ class LeaveCalendarView(QDialog):
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
match = [r for r in records if r['date'] == date_str]
|
||||
if not match:
|
||||
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
|
||||
self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
|
||||
return
|
||||
parts = []
|
||||
for r in match:
|
||||
t = r.get('leave_type', 'annual')
|
||||
d = float(r.get('days') or 0)
|
||||
memo = r.get('memo') or ''
|
||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
||||
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))
|
||||
|
||||
@ -90,8 +90,8 @@ class LeaveView(QDialog):
|
||||
cal_button.clicked.connect(self._show_calendar)
|
||||
button_layout.addWidget(cal_button)
|
||||
|
||||
schedule_button = QPushButton("스케줄")
|
||||
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||
schedule_button = QPushButton(tr('view.leave.btn_schedule'))
|
||||
schedule_button.setToolTip(tr('view.leave.schedule_tooltip'))
|
||||
schedule_button.clicked.connect(self._show_schedule)
|
||||
button_layout.addWidget(schedule_button)
|
||||
|
||||
@ -137,13 +137,13 @@ class LeaveView(QDialog):
|
||||
days = record['days']
|
||||
hours = days * 8
|
||||
if days == 1.0:
|
||||
days_str = "1일"
|
||||
days_str = tr('view.leave.used_1day')
|
||||
elif days == 0.5:
|
||||
days_str = "0.5일 (4시간)"
|
||||
days_str = tr('view.leave.used_half_day')
|
||||
elif hours < 8:
|
||||
days_str = f"{days}일 ({hours}시간)"
|
||||
days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours)
|
||||
else:
|
||||
days_str = f"{days}일"
|
||||
days_str = tr('view.leave.used_days_fmt', days=days)
|
||||
days_item = QTableWidgetItem(days_str)
|
||||
days_item.setTextAlignment(Qt.AlignCenter)
|
||||
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
@ -365,17 +365,17 @@ class AddLeaveDialog(QDialog):
|
||||
if date_dt.weekday() in (5, 6): # 토/일
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"주말 등록 불가",
|
||||
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
|
||||
tr('view.leave.weekend_register_forbidden_title'),
|
||||
tr('view.leave.weekend_register_forbidden_body')
|
||||
)
|
||||
return
|
||||
if self.db.is_holiday(date):
|
||||
holiday = self.db.get_holiday(date)
|
||||
name = (holiday or {}).get('name', '공휴일')
|
||||
name = (holiday or {}).get('name', tr('label.holiday_default'))
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"공휴일 등록 불가",
|
||||
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
|
||||
tr('view.leave.holiday_register_forbidden_title'),
|
||||
tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
|
||||
)
|
||||
return
|
||||
|
||||
@ -385,9 +385,8 @@ class AddLeaveDialog(QDialog):
|
||||
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"중복 등록 초과",
|
||||
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
|
||||
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
|
||||
tr('view.leave.duplicate_register_title'),
|
||||
tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QMessageBox)
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -35,8 +36,8 @@ class MealTimeDialog(QDialog):
|
||||
self.meal_type = meal_type
|
||||
self._clock_in = clock_in_time
|
||||
self._clock_out = clock_out_time
|
||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||
meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short')
|
||||
self.setWindowTitle(tr('meal.dialog_title', meal=meal_label))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 260)
|
||||
|
||||
@ -44,10 +45,9 @@ class MealTimeDialog(QDialog):
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
|
||||
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||
info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
|
||||
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.setWordWrap(True)
|
||||
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||
@ -63,7 +63,7 @@ class MealTimeDialog(QDialog):
|
||||
|
||||
# 시작
|
||||
start_row = QHBoxLayout()
|
||||
start_row.addWidget(QLabel("시작:"))
|
||||
start_row.addWidget(QLabel(tr('meal.label_start')))
|
||||
self.start_edit = QTimeEdit()
|
||||
self.start_edit.setDisplayFormat("HH:mm")
|
||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||
@ -73,7 +73,7 @@ class MealTimeDialog(QDialog):
|
||||
|
||||
# 종료
|
||||
end_row = QHBoxLayout()
|
||||
end_row.addWidget(QLabel("종료:"))
|
||||
end_row.addWidget(QLabel(tr('meal.label_end')))
|
||||
self.end_edit = QTimeEdit()
|
||||
self.end_edit.setDisplayFormat("HH:mm")
|
||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||
@ -92,10 +92,10 @@ class MealTimeDialog(QDialog):
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
@ -137,24 +137,24 @@ class MealTimeDialog(QDialog):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
self.preview.setText(f"{reason}")
|
||||
self.preview.setText(reason)
|
||||
self.preview.setStyleSheet("color: #FA5252;")
|
||||
else:
|
||||
self.preview.setText(f"총 {minutes}분")
|
||||
self.preview.setText(tr('meal.preview_total', minutes=minutes))
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||
minutes: int) -> tuple[bool, str]:
|
||||
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||
if minutes <= 0:
|
||||
return False, "시작이 종료보다 늦습니다"
|
||||
return False, tr('meal.error_start_after_end')
|
||||
if minutes > 8 * 60:
|
||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||
return False, "식사 시간이 8시간을 초과합니다"
|
||||
return False, tr('meal.error_too_long')
|
||||
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:
|
||||
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, ""
|
||||
|
||||
def accept(self):
|
||||
@ -162,7 +162,7 @@ class MealTimeDialog(QDialog):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, "입력 오류", reason)
|
||||
QMessageBox.warning(self, tr('meal.input_error_title'), reason)
|
||||
return
|
||||
super().accept()
|
||||
|
||||
|
||||
@ -102,8 +102,8 @@ class MiniWidget(QWidget):
|
||||
qss = ''
|
||||
if qss:
|
||||
menu.setStyleSheet(qss)
|
||||
open_main = menu.addAction("메인 창 열기")
|
||||
close_mini = menu.addAction("미니 위젯 닫기")
|
||||
open_main = menu.addAction(tr('mini.open_main'))
|
||||
close_mini = menu.addAction(tr('mini.close'))
|
||||
action = menu.exec_(event.globalPos())
|
||||
if action == open_main and self.parent_window:
|
||||
self.parent_window.show()
|
||||
|
||||
@ -32,17 +32,10 @@ WORK_PRESETS = [
|
||||
class WelcomePage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("환영합니다!")
|
||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||
self.setTitle(tr('onboarding.welcome_title'))
|
||||
self.setSubTitle(tr('onboarding.welcome_subtitle'))
|
||||
layout = QVBoxLayout()
|
||||
intro = QLabel(
|
||||
"이 앱은:\n"
|
||||
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
|
||||
"• 30분 단위 연장근무 적립\n"
|
||||
"• 연차·반차·외출 시간 추적\n"
|
||||
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
|
||||
"[다음] 버튼을 눌러 시작하세요."
|
||||
)
|
||||
intro = QLabel(tr('onboarding.welcome_intro'))
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
self.setLayout(layout)
|
||||
@ -51,8 +44,8 @@ class WelcomePage(QWizardPage):
|
||||
class WorkPatternPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("근무 패턴")
|
||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||
self.setTitle(tr('onboarding.work_pattern_title'))
|
||||
self.setSubTitle(tr('onboarding.work_pattern_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.button_group = QButtonGroup(self)
|
||||
@ -127,20 +120,18 @@ class WorkPatternPage(QWizardPage):
|
||||
class ClockInDetectionPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("출근 시간 감지 방식")
|
||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||
self.setTitle(tr('onboarding.detection_title'))
|
||||
self.setSubTitle(tr('onboarding.detection_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
|
||||
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
|
||||
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
|
||||
self.option_boot = QRadioButton(tr('onboarding.detection_boot'))
|
||||
self.option_unlock = QRadioButton(tr('onboarding.detection_unlock'))
|
||||
self.option_manual = QRadioButton(tr('onboarding.detection_manual'))
|
||||
self.option_boot.setChecked(True)
|
||||
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
||||
layout.addWidget(opt)
|
||||
|
||||
info = QLabel(
|
||||
"\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
)
|
||||
info = QLabel(tr('onboarding.detection_info'))
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||
layout.addWidget(info)
|
||||
@ -159,35 +150,35 @@ class ClockInDetectionPage(QWizardPage):
|
||||
class LeaveSalaryPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("연차 + 급여 (옵션)")
|
||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||
self.setTitle(tr('onboarding.leave_salary_title'))
|
||||
self.setSubTitle(tr('onboarding.leave_salary_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
# 연차
|
||||
leave_box = QGroupBox("연간 연차")
|
||||
leave_box = QGroupBox(tr('onboarding.leave_group'))
|
||||
leave_layout = QHBoxLayout()
|
||||
self.leave_spin = QSpinBox()
|
||||
self.leave_spin.setRange(0, 30)
|
||||
self.leave_spin.setValue(15)
|
||||
self.leave_spin.setSuffix(" 일")
|
||||
leave_layout.addWidget(QLabel("내 연차:"))
|
||||
self.leave_spin.setSuffix(tr('label.unit_day'))
|
||||
leave_layout.addWidget(QLabel(tr('onboarding.my_leave')))
|
||||
leave_layout.addWidget(self.leave_spin)
|
||||
leave_layout.addStretch()
|
||||
leave_box.setLayout(leave_layout)
|
||||
layout.addWidget(leave_box)
|
||||
|
||||
# 급여 (옵션)
|
||||
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
|
||||
salary_box = QGroupBox(tr('onboarding.salary_group'))
|
||||
salary_layout = QVBoxLayout()
|
||||
self.salary_enabled = QCheckBox("급여 추정 활성화")
|
||||
self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled'))
|
||||
salary_layout.addWidget(self.salary_enabled)
|
||||
|
||||
wage_row = QHBoxLayout()
|
||||
wage_row.addWidget(QLabel("시급:"))
|
||||
wage_row.addWidget(QLabel(tr('onboarding.hourly_wage')))
|
||||
self.wage_spin = QSpinBox()
|
||||
self.wage_spin.setRange(0, 1000000)
|
||||
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.setEnabled(False)
|
||||
wage_row.addWidget(self.wage_spin)
|
||||
@ -195,11 +186,11 @@ class LeaveSalaryPage(QWizardPage):
|
||||
salary_layout.addLayout(wage_row)
|
||||
|
||||
rate_row = QHBoxLayout()
|
||||
rate_row.addWidget(QLabel("연장수당 가산률:"))
|
||||
rate_row.addWidget(QLabel(tr('onboarding.overtime_rate')))
|
||||
self.rate_combo = QComboBox()
|
||||
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
|
||||
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
|
||||
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0)
|
||||
self.rate_combo.setCurrentIndex(1)
|
||||
self.rate_combo.setEnabled(False)
|
||||
rate_row.addWidget(self.rate_combo)
|
||||
@ -218,30 +209,25 @@ class LeaveSalaryPage(QWizardPage):
|
||||
class DiscordPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("Discord 알림 (선택)")
|
||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||
self.setTitle(tr('onboarding.discord_title'))
|
||||
self.setSubTitle(tr('onboarding.discord_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
|
||||
self.enable_check = QCheckBox(tr('onboarding.discord_enable'))
|
||||
layout.addWidget(self.enable_check)
|
||||
|
||||
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)
|
||||
layout.addWidget(self.url_edit)
|
||||
|
||||
guide = QLabel(
|
||||
"셋업 방법:\n"
|
||||
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
|
||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||
"3. 위 입력란에 붙여넣기"
|
||||
)
|
||||
guide = QLabel(tr('onboarding.discord_guide'))
|
||||
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||
guide.setWordWrap(True)
|
||||
layout.addWidget(guide)
|
||||
|
||||
test_row = QHBoxLayout()
|
||||
self.test_btn = QPushButton("테스트 메시지 보내기")
|
||||
self.test_btn = QPushButton(tr('onboarding.discord_test'))
|
||||
self.test_btn.setEnabled(False)
|
||||
self.test_btn.clicked.connect(self._test_webhook)
|
||||
test_row.addWidget(self.test_btn)
|
||||
@ -257,39 +243,30 @@ class DiscordPage(QWizardPage):
|
||||
def _test_webhook(self):
|
||||
url = self.url_edit.text().strip()
|
||||
if not url:
|
||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||
QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body'))
|
||||
return
|
||||
from utils import discord_webhook
|
||||
if not discord_webhook.is_valid_webhook_url(url):
|
||||
QMessageBox.warning(
|
||||
self, "URL 형식 오류",
|
||||
"Discord 웹훅 URL 형식이 아닙니다.\n"
|
||||
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
|
||||
self, tr('onboarding.discord_url_invalid_title'),
|
||||
tr('onboarding.discord_url_invalid_body')
|
||||
)
|
||||
return
|
||||
ok = discord_webhook.send_test(url)
|
||||
if ok:
|
||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||
QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body'))
|
||||
else:
|
||||
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
|
||||
QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body'))
|
||||
|
||||
|
||||
class FinishPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("준비 완료!")
|
||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||
self.setTitle(tr('onboarding.finish_title'))
|
||||
self.setSubTitle(tr('onboarding.finish_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
msg = QLabel(
|
||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||
"단축키:\n"
|
||||
" • Ctrl+O — 출퇴근 토글\n"
|
||||
" • F1 — 도움말\n"
|
||||
" • F5 — 업데이트 확인\n"
|
||||
" • Ctrl+, — 설정"
|
||||
)
|
||||
msg = QLabel(tr('onboarding.finish_msg'))
|
||||
msg.setWordWrap(True)
|
||||
layout.addWidget(msg)
|
||||
self.setLayout(layout)
|
||||
@ -301,7 +278,7 @@ class OnboardingWizard(QWizard):
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("Clock-out Calculator — 시작 설정")
|
||||
self.setWindowTitle(tr('onboarding.window_title'))
|
||||
self.setMinimumSize(600, 500)
|
||||
self.setWizardStyle(QWizard.ModernStyle)
|
||||
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
||||
@ -323,7 +300,7 @@ class OnboardingWizard(QWizard):
|
||||
# 1. 근무 패턴
|
||||
wm, lm, dm = self.work_page.selected_minutes()
|
||||
if wm < 30:
|
||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||
QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small'))
|
||||
return
|
||||
|
||||
settings = {
|
||||
|
||||
@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -18,7 +19,7 @@ class PastRecordDialog(QDialog):
|
||||
def __init__(self, parent=None, date_str: str = ''):
|
||||
super().__init__(parent)
|
||||
self.date_str = date_str
|
||||
self.setWindowTitle(f"기록 추가 — {date_str}")
|
||||
self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 320)
|
||||
|
||||
@ -26,13 +27,13 @@ class PastRecordDialog(QDialog):
|
||||
layout.setSpacing(8)
|
||||
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;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 출근
|
||||
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.setDisplayFormat("HH:mm")
|
||||
self.clock_in_edit.setTime(QTime(9, 0))
|
||||
@ -42,8 +43,8 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 퇴근
|
||||
co_row = QHBoxLayout()
|
||||
co_row.addWidget(QLabel("퇴근:"))
|
||||
self.clock_out_check = QCheckBox("입력")
|
||||
co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
|
||||
self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
|
||||
self.clock_out_check.setChecked(True)
|
||||
self.clock_out_edit = QTimeEdit()
|
||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||
@ -56,18 +57,18 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox("점심시간 포함")
|
||||
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
||||
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.dinner_check)
|
||||
meal_row.addStretch()
|
||||
layout.addLayout(meal_row)
|
||||
|
||||
# 메모
|
||||
layout.addWidget(QLabel("메모 (선택):"))
|
||||
layout.addWidget(QLabel(tr('past_record.label_memo')))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
||||
self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
|
||||
layout.addWidget(self.memo_edit)
|
||||
|
||||
layout.addStretch()
|
||||
@ -75,10 +76,10 @@ class PastRecordDialog(QDialog):
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self._validate_and_accept)
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
@ -92,8 +93,8 @@ class PastRecordDialog(QDialog):
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co <= ci:
|
||||
QMessageBox.warning(self, "입력 오류",
|
||||
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
||||
QMessageBox.warning(self, tr('past_record.input_error_title'),
|
||||
tr('past_record.input_error_body'))
|
||||
return
|
||||
self.accept()
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
from PyQt5.QtCore import QDate, Qt
|
||||
|
||||
from core.recurring_leaves import describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -27,7 +28,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("반복 연차 관리")
|
||||
self.setWindowTitle(tr('recurring.title'))
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._reload_list()
|
||||
@ -37,29 +38,29 @@ class RecurringLeaveDialog(QDialog):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 기존 패턴 목록
|
||||
list_group = QGroupBox("등록된 반복 패턴")
|
||||
list_group = QGroupBox(tr('recurring.list_group'))
|
||||
lg = QVBoxLayout()
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setMinimumHeight(160)
|
||||
lg.addWidget(self.list_widget)
|
||||
del_btn = QPushButton("선택 삭제")
|
||||
del_btn = QPushButton(tr('recurring.btn_delete_selected'))
|
||||
del_btn.clicked.connect(self._delete_selected)
|
||||
lg.addWidget(del_btn)
|
||||
list_group.setLayout(lg)
|
||||
layout.addWidget(list_group)
|
||||
|
||||
# 신규 등록
|
||||
add_group = QGroupBox("신규 패턴 추가")
|
||||
add_group = QGroupBox(tr('recurring.add_group'))
|
||||
ag = QVBoxLayout()
|
||||
|
||||
# 패턴 종류
|
||||
kind_row = QHBoxLayout()
|
||||
kind_row.addWidget(QLabel("주기:"))
|
||||
kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
|
||||
self.kind_group = QButtonGroup(self)
|
||||
self.rb_weekly = QRadioButton("매주")
|
||||
self.rb_weekly = QRadioButton(tr('recurring.weekly'))
|
||||
self.rb_weekly.setChecked(True)
|
||||
self.rb_biweekly = QRadioButton("격주")
|
||||
self.rb_monthly = QRadioButton("매월 N일")
|
||||
self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
|
||||
self.rb_monthly = QRadioButton(tr('recurring.monthly'))
|
||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||
self.kind_group.addButton(rb)
|
||||
kind_row.addWidget(rb)
|
||||
@ -68,10 +69,10 @@ class RecurringLeaveDialog(QDialog):
|
||||
|
||||
# 요일 체크박스 (weekly/biweekly)
|
||||
wd_row = QHBoxLayout()
|
||||
wd_row.addWidget(QLabel("요일:"))
|
||||
wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
|
||||
self.weekday_checks = []
|
||||
for ko, en in _KO_WEEKDAYS:
|
||||
cb = QCheckBox(ko)
|
||||
cb = QCheckBox(tr(f'label.weekday_{en}'))
|
||||
self.weekday_checks.append((cb, en))
|
||||
wd_row.addWidget(cb)
|
||||
wd_row.addStretch()
|
||||
@ -79,40 +80,40 @@ class RecurringLeaveDialog(QDialog):
|
||||
|
||||
# 매월 N일
|
||||
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.setRange(1, 31)
|
||||
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.addStretch()
|
||||
ag.addLayout(month_row)
|
||||
|
||||
# 차감 일수
|
||||
days_row = QHBoxLayout()
|
||||
days_row.addWidget(QLabel("차감:"))
|
||||
days_row.addWidget(QLabel(tr('recurring.label_deduction')))
|
||||
self.days_combo = QComboBox()
|
||||
self.days_combo.addItem("1.0일 (종일)", 1.0)
|
||||
self.days_combo.addItem("0.5일 (반차)", 0.5)
|
||||
self.days_combo.addItem("0.25일 (반반차)", 0.25)
|
||||
self.days_combo.addItem(tr('recurring.deduction_full'), 1.0)
|
||||
self.days_combo.addItem(tr('recurring.deduction_half'), 0.5)
|
||||
self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25)
|
||||
days_row.addWidget(self.days_combo)
|
||||
days_row.addStretch()
|
||||
ag.addLayout(days_row)
|
||||
|
||||
# 시작/종료 날짜
|
||||
date_row = QHBoxLayout()
|
||||
date_row.addWidget(QLabel("시작:"))
|
||||
date_row.addWidget(QLabel(tr('recurring.label_start')))
|
||||
self.start_edit = QDateEdit()
|
||||
self.start_edit.setDate(QDate.currentDate())
|
||||
self.start_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.start_edit)
|
||||
|
||||
date_row.addWidget(QLabel("종료:"))
|
||||
date_row.addWidget(QLabel(tr('recurring.label_end')))
|
||||
self.end_edit = QDateEdit()
|
||||
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||
self.end_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.end_edit)
|
||||
self.no_end_check = QCheckBox("종료 없음 (무기한)")
|
||||
self.no_end_check = QCheckBox(tr('recurring.no_end'))
|
||||
self.no_end_check.toggled.connect(
|
||||
lambda v: self.end_edit.setEnabled(not v)
|
||||
)
|
||||
@ -122,14 +123,14 @@ class RecurringLeaveDialog(QDialog):
|
||||
|
||||
# 메모
|
||||
memo_row = QHBoxLayout()
|
||||
memo_row.addWidget(QLabel("메모:"))
|
||||
memo_row.addWidget(QLabel(tr('recurring.label_memo')))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
|
||||
self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
|
||||
memo_row.addWidget(self.memo_edit)
|
||||
ag.addLayout(memo_row)
|
||||
|
||||
# 추가 버튼
|
||||
add_btn = QPushButton("추가")
|
||||
add_btn = QPushButton(tr('recurring.btn_add'))
|
||||
add_btn.setObjectName("btn_primary")
|
||||
add_btn.clicked.connect(self._save)
|
||||
ag.addWidget(add_btn)
|
||||
@ -138,7 +139,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
layout.addWidget(add_group)
|
||||
|
||||
# 닫기
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
@ -148,7 +149,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
self.list_widget.clear()
|
||||
for r in self.db.get_recurring_leaves():
|
||||
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']}) "
|
||||
f"· {r['start_date']} ~ {end}")
|
||||
if r.get('memo'):
|
||||
@ -163,8 +164,8 @@ class RecurringLeaveDialog(QDialog):
|
||||
return
|
||||
rec_id = item.data(Qt.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
|
||||
self, tr('recurring.delete_confirm_title'),
|
||||
tr('recurring.delete_confirm_body', item=item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -184,7 +185,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
def _save(self):
|
||||
pattern = self._build_pattern()
|
||||
if not pattern:
|
||||
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
|
||||
QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
|
||||
return
|
||||
days = self.days_combo.currentData()
|
||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||
@ -193,7 +194,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
memo = self.memo_edit.text().strip()
|
||||
|
||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||
QMessageBox.information(self, "추가 완료",
|
||||
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
|
||||
QMessageBox.information(self, tr('recurring.add_done_title'),
|
||||
tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
|
||||
self.memo_edit.clear()
|
||||
self._reload_list()
|
||||
|
||||
@ -18,6 +18,7 @@ from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -38,7 +39,7 @@ class ScheduleView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("스케줄")
|
||||
self.setWindowTitle(tr('schedule.title'))
|
||||
self.setMinimumSize(820, 560)
|
||||
self._build_ui()
|
||||
self._reload()
|
||||
@ -49,16 +50,16 @@ class ScheduleView(QDialog):
|
||||
|
||||
# 상단 툴바
|
||||
bar = QHBoxLayout()
|
||||
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
|
||||
title = QLabel(tr('schedule.header'))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
bar.addWidget(title)
|
||||
bar.addStretch()
|
||||
|
||||
rec_btn = QPushButton("반복 패턴 관리")
|
||||
rec_btn = QPushButton(tr('schedule.btn_recurring'))
|
||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||
bar.addWidget(rec_btn)
|
||||
|
||||
add_btn = QPushButton("연차 등록")
|
||||
add_btn = QPushButton(tr('schedule.btn_add_leave'))
|
||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||
bar.addWidget(add_btn)
|
||||
|
||||
@ -66,11 +67,11 @@ class ScheduleView(QDialog):
|
||||
|
||||
# 범례
|
||||
legend = QHBoxLayout()
|
||||
for label, color in [("공휴일", _C_HOLIDAY),
|
||||
("연차 사용", _C_LEAVE_FULL_PAST),
|
||||
("연차 예정", _C_LEAVE_FULL_PLAN),
|
||||
("반차/반반차", _C_LEAVE_HALF_PAST),
|
||||
("반복 패턴", _C_RECURRING)]:
|
||||
for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
|
||||
(tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
|
||||
(tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
|
||||
(tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
|
||||
(tr('schedule.legend_recurring'), _C_RECURRING)]:
|
||||
sw = QLabel(f" {label} ")
|
||||
sw.setStyleSheet(
|
||||
f"background-color: {color.name()}; color: white; "
|
||||
@ -94,7 +95,7 @@ class ScheduleView(QDialog):
|
||||
right = QWidget()
|
||||
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;")
|
||||
right_layout.addWidget(self.detail_title)
|
||||
|
||||
@ -109,7 +110,7 @@ class ScheduleView(QDialog):
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
@ -189,18 +190,19 @@ class ScheduleView(QDialog):
|
||||
def _on_date_click(self, qd: QDate):
|
||||
d = date(qd.year(), qd.month(), qd.day())
|
||||
date_str = d.isoformat()
|
||||
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
|
||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
|
||||
weekday_kr = [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')]
|
||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
|
||||
self.detail_list.clear()
|
||||
|
||||
# 휴일
|
||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||
if holiday:
|
||||
item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}")
|
||||
item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
|
||||
item.setForeground(QBrush(QColor("#e53935")))
|
||||
self.detail_list.addItem(item)
|
||||
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)
|
||||
|
||||
# 연차 (구체)
|
||||
@ -208,7 +210,7 @@ class ScheduleView(QDialog):
|
||||
days = float(r.get('days') or 0)
|
||||
t = r.get('leave_type', '연차')
|
||||
memo = r.get('memo') or ''
|
||||
label = f"{t} {days}일"
|
||||
label = tr('schedule.leave_label', type=t, days=days)
|
||||
if memo:
|
||||
label += f" — {memo}"
|
||||
label += f" [id={r['id']}]"
|
||||
@ -221,13 +223,14 @@ class ScheduleView(QDialog):
|
||||
from core.recurring_leaves import expand_for_date
|
||||
for occ in expand_for_date(recurring, d):
|
||||
item = QListWidgetItem(
|
||||
f"{describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
|
||||
tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
|
||||
days=occ.days, type=occ.leave_type)
|
||||
)
|
||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
if self.detail_list.count() == 0:
|
||||
self.detail_list.addItem("일정 없음")
|
||||
self.detail_list.addItem(tr('schedule.no_events'))
|
||||
|
||||
def _on_page_change(self, year: int, month: int):
|
||||
self._reload()
|
||||
@ -241,7 +244,7 @@ class ScheduleView(QDialog):
|
||||
return
|
||||
kind, _id = data
|
||||
menu = QMenu(self)
|
||||
del_act = menu.addAction("삭제")
|
||||
del_act = menu.addAction(tr('schedule.delete'))
|
||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._delete_record(kind, _id)
|
||||
@ -249,8 +252,8 @@ class ScheduleView(QDialog):
|
||||
def _delete_record(self, kind: str, _id: int):
|
||||
if kind == 'concrete':
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
|
||||
self, tr('schedule.delete_leave_confirm_title'),
|
||||
tr('schedule.delete_leave_confirm_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -261,8 +264,8 @@ class ScheduleView(QDialog):
|
||||
self._on_date_click(d)
|
||||
elif kind == 'recurring':
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
|
||||
self, tr('schedule.delete_recurring_confirm_title'),
|
||||
tr('schedule.delete_recurring_confirm_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
|
||||
@ -58,7 +58,7 @@ class SettingsView(QDialog):
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# 제목
|
||||
title = QLabel("설정")
|
||||
title = QLabel(tr('settings.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title)
|
||||
@ -137,16 +137,16 @@ class SettingsView(QDialog):
|
||||
|
||||
# 근무 패턴 프리셋
|
||||
preset_layout = QHBoxLayout()
|
||||
preset_label = QLabel("근무 패턴:")
|
||||
preset_label = QLabel(tr('settings.work_pattern'))
|
||||
preset_label.setFixedWidth(130)
|
||||
self.work_preset_combo = QComboBox()
|
||||
# (label, work_minutes, lunch_minutes)
|
||||
self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60))
|
||||
self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30))
|
||||
self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60))
|
||||
self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30))
|
||||
self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0))
|
||||
self.work_preset_combo.addItem("사용자 정의", None)
|
||||
self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.custom'), None)
|
||||
self.work_preset_combo.setFixedWidth(260)
|
||||
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
|
||||
preset_layout.addWidget(preset_label)
|
||||
@ -156,18 +156,18 @@ class SettingsView(QDialog):
|
||||
|
||||
# 하루 기본 근무 시간 (시 + 분 분리 입력)
|
||||
work_hours_layout = QHBoxLayout()
|
||||
work_hours_label = QLabel("하루 기본 근무:")
|
||||
work_hours_label = QLabel(tr('settings.daily_work'))
|
||||
work_hours_label.setFixedWidth(130)
|
||||
self.work_hours_spin = QSpinBox()
|
||||
self.work_hours_spin.setRange(0, 12)
|
||||
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_minutes_spin = QSpinBox()
|
||||
self.work_minutes_spin.setRange(0, 59)
|
||||
self.work_minutes_spin.setValue(0)
|
||||
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_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||
@ -180,34 +180,34 @@ class SettingsView(QDialog):
|
||||
|
||||
# 점심시간 기본값
|
||||
lunch_layout = QHBoxLayout()
|
||||
lunch_label = QLabel("점심시간 기본:")
|
||||
lunch_label = QLabel(tr('settings.lunch_default'))
|
||||
lunch_label.setFixedWidth(130)
|
||||
self.lunch_spin = QSpinBox()
|
||||
self.lunch_spin.setRange(0, 120)
|
||||
self.lunch_spin.setValue(60)
|
||||
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.valueChanged.connect(self._on_work_time_user_edit)
|
||||
lunch_layout.addWidget(lunch_label)
|
||||
lunch_layout.addWidget(self.lunch_spin)
|
||||
|
||||
# 점심시간 자동 적용
|
||||
self.auto_lunch_check = QCheckBox("자동 적용")
|
||||
self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용")
|
||||
self.auto_lunch_check = QCheckBox(tr('settings.auto_apply'))
|
||||
self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip'))
|
||||
lunch_layout.addWidget(self.auto_lunch_check)
|
||||
lunch_layout.addStretch()
|
||||
layout.addLayout(lunch_layout)
|
||||
|
||||
# 저녁시간 기본값
|
||||
dinner_layout = QHBoxLayout()
|
||||
dinner_label = QLabel("저녁시간 기본:")
|
||||
dinner_label = QLabel(tr('settings.dinner_default'))
|
||||
dinner_label.setFixedWidth(130)
|
||||
self.dinner_spin = QSpinBox()
|
||||
self.dinner_spin.setRange(0, 120)
|
||||
self.dinner_spin.setValue(60)
|
||||
self.dinner_spin.setSingleStep(5)
|
||||
self.dinner_spin.setSuffix(" 분")
|
||||
self.dinner_spin.setSuffix(tr('settings.suffix_minute'))
|
||||
self.dinner_spin.setFixedWidth(110)
|
||||
dinner_layout.addWidget(dinner_label)
|
||||
dinner_layout.addWidget(self.dinner_spin)
|
||||
@ -306,30 +306,30 @@ class SettingsView(QDialog):
|
||||
|
||||
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||
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.lunch_notification_check = QCheckBox("점심시간 등록 알림")
|
||||
self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch'))
|
||||
self.lunch_notification_check.setChecked(True)
|
||||
check_row1.addWidget(self.clock_out_notification_check)
|
||||
check_row1.addWidget(self.lunch_notification_check)
|
||||
layout.addLayout(check_row1)
|
||||
|
||||
check_row2 = QHBoxLayout()
|
||||
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
||||
self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner'))
|
||||
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)
|
||||
check_row2.addWidget(self.dinner_notification_check)
|
||||
check_row2.addWidget(self.overtime_notification_check)
|
||||
layout.addLayout(check_row2)
|
||||
|
||||
check_row3 = QHBoxLayout()
|
||||
self.health_notification_check = QCheckBox("건강 경고 알림")
|
||||
self.health_notification_check = QCheckBox(tr('settings.notif_health'))
|
||||
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.setToolTip(
|
||||
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
|
||||
tr('settings.notif_break_tooltip')
|
||||
)
|
||||
check_row3.addWidget(self.health_notification_check)
|
||||
check_row3.addWidget(self.health_break_notification_check)
|
||||
@ -337,75 +337,75 @@ class SettingsView(QDialog):
|
||||
|
||||
# 퇴근 N분 전 알림 시점 설정
|
||||
before_row = QHBoxLayout()
|
||||
before_label = QLabel("퇴근 알림 시점:")
|
||||
before_label = QLabel(tr('settings.notif_before'))
|
||||
before_label.setFixedWidth(110)
|
||||
self.notif_before_spin = QSpinBox()
|
||||
self.notif_before_spin.setRange(1, 120)
|
||||
self.notif_before_spin.setSingleStep(5)
|
||||
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.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
||||
self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip'))
|
||||
before_row.addWidget(before_label)
|
||||
before_row.addWidget(self.notif_before_spin)
|
||||
before_row.addStretch()
|
||||
layout.addLayout(before_row)
|
||||
|
||||
# === 고급 임계값 (접이식 그룹박스) ===
|
||||
adv_box = QGroupBox("고급 임계값")
|
||||
adv_box = QGroupBox(tr('settings.advanced_thresholds'))
|
||||
adv_box.setCheckable(True)
|
||||
adv_box.setChecked(False) # 기본 접힘
|
||||
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
|
||||
adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip'))
|
||||
adv_layout = QVBoxLayout()
|
||||
adv_layout.setSpacing(4)
|
||||
|
||||
# 점심 알림 임계 (출근 후 N시간)
|
||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
|
||||
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
|
||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
||||
self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin))
|
||||
|
||||
# 저녁 알림 임계 (출근 후 N시간)
|
||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
|
||||
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
|
||||
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
|
||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour'))
|
||||
self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip'))
|
||||
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.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
|
||||
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
|
||||
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour'))
|
||||
self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin))
|
||||
|
||||
# 주 X시간 임계
|
||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
|
||||
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
|
||||
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
|
||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour'))
|
||||
self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip'))
|
||||
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.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
|
||||
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
|
||||
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day'))
|
||||
self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback=''))
|
||||
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.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
|
||||
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
|
||||
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
||||
self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin))
|
||||
|
||||
adv_box.setLayout(adv_layout)
|
||||
layout.addWidget(adv_box)
|
||||
|
||||
# 시간 형식 + 테마 한 줄에
|
||||
format_row = QHBoxLayout()
|
||||
time_format_label = QLabel("시간 형식:")
|
||||
time_format_label = QLabel(tr('settings.time_format'))
|
||||
time_format_label.setFixedWidth(70)
|
||||
self.time_format_combo = QComboBox()
|
||||
self.time_format_combo.addItem("24시간 (17:30)", "24")
|
||||
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12")
|
||||
self.time_format_combo.addItem(tr('settings.time_format_24'), "24")
|
||||
self.time_format_combo.addItem(tr('settings.time_format_12'), "12")
|
||||
self.time_format_combo.setFixedWidth(180)
|
||||
|
||||
theme_label = QLabel("테마:")
|
||||
theme_label = QLabel(tr('settings.theme'))
|
||||
theme_label.setFixedWidth(40)
|
||||
self.theme_combo = QComboBox()
|
||||
self.theme_combo.addItem("라이트", "light")
|
||||
self.theme_combo.addItem("다크", "dark")
|
||||
self.theme_combo.addItem(tr('settings.theme_light'), "light")
|
||||
self.theme_combo.addItem(tr('settings.theme_dark'), "dark")
|
||||
self.theme_combo.setFixedWidth(90)
|
||||
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
||||
|
||||
@ -419,7 +419,7 @@ class SettingsView(QDialog):
|
||||
|
||||
# 접근성: 글꼴 크기 + 고대비
|
||||
a11y_row = QHBoxLayout()
|
||||
a11y_row.addWidget(QLabel("글꼴 크기:"))
|
||||
a11y_row.addWidget(QLabel(tr('settings.font_scale')))
|
||||
self.font_scale_combo = QComboBox()
|
||||
self.font_scale_combo.addItem("100%", "1.0")
|
||||
self.font_scale_combo.addItem("125%", "1.25")
|
||||
@ -427,8 +427,8 @@ class SettingsView(QDialog):
|
||||
self.font_scale_combo.setFixedWidth(90)
|
||||
a11y_row.addWidget(self.font_scale_combo)
|
||||
a11y_row.addSpacing(16)
|
||||
self.high_contrast_check = QCheckBox("고대비 모드")
|
||||
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
|
||||
self.high_contrast_check = QCheckBox(tr('settings.high_contrast'))
|
||||
self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip'))
|
||||
a11y_row.addWidget(self.high_contrast_check)
|
||||
a11y_row.addStretch()
|
||||
layout.addLayout(a11y_row)
|
||||
@ -436,13 +436,13 @@ class SettingsView(QDialog):
|
||||
# 언어 선택
|
||||
from core.i18n import available_languages, language_label
|
||||
lang_row = QHBoxLayout()
|
||||
lang_label = QLabel("언어 / Language:")
|
||||
lang_label = QLabel(tr('label.language'))
|
||||
lang_label.setFixedWidth(120)
|
||||
self.language_combo = QComboBox()
|
||||
for code in available_languages():
|
||||
self.language_combo.addItem(language_label(code), code)
|
||||
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(self.language_combo)
|
||||
lang_row.addStretch()
|
||||
@ -459,16 +459,16 @@ class SettingsView(QDialog):
|
||||
|
||||
# 잔액 + 계산 단위 한 줄
|
||||
top_row = QHBoxLayout()
|
||||
self.current_overtime_label = QLabel("현재 잔액: 계산 중...")
|
||||
self.current_overtime_label = QLabel(tr('settings.current_balance'))
|
||||
self.current_overtime_label.setObjectName("badge_success")
|
||||
top_row.addWidget(self.current_overtime_label)
|
||||
top_row.addStretch()
|
||||
|
||||
unit_label = QLabel("계산 단위:")
|
||||
unit_label = QLabel(tr('settings.calc_unit'))
|
||||
self.overtime_unit_combo = QComboBox()
|
||||
self.overtime_unit_combo.addItem("30분", 30)
|
||||
self.overtime_unit_combo.addItem("1시간", 60)
|
||||
self.overtime_unit_combo.addItem("15분", 15)
|
||||
self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30)
|
||||
self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60)
|
||||
self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15)
|
||||
self.overtime_unit_combo.setFixedWidth(100)
|
||||
top_row.addWidget(unit_label)
|
||||
top_row.addWidget(self.overtime_unit_combo)
|
||||
@ -476,28 +476,28 @@ class SettingsView(QDialog):
|
||||
|
||||
# 초기 연장근무 설정
|
||||
initial_overtime_layout = QHBoxLayout()
|
||||
initial_overtime_label = QLabel("기존 연장근무:")
|
||||
initial_overtime_label = QLabel(tr('settings.initial_overtime'))
|
||||
initial_overtime_label.setFixedWidth(100)
|
||||
self.initial_overtime_hours = QSpinBox()
|
||||
self.initial_overtime_hours.setRange(0, 200)
|
||||
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_mins = QSpinBox()
|
||||
self.initial_overtime_mins.setRange(0, 59)
|
||||
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)
|
||||
|
||||
apply_overtime_btn = QPushButton("적용")
|
||||
apply_overtime_btn = QPushButton(tr('btn.apply'))
|
||||
apply_overtime_btn.setObjectName("btn_small")
|
||||
apply_overtime_btn.setFixedWidth(50)
|
||||
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.setToolTip("퇴근 시 연장근무 자동 적립")
|
||||
self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip'))
|
||||
|
||||
initial_overtime_layout.addWidget(initial_overtime_label)
|
||||
initial_overtime_layout.addWidget(self.initial_overtime_hours)
|
||||
@ -507,7 +507,7 @@ class SettingsView(QDialog):
|
||||
initial_overtime_layout.addWidget(self.auto_overtime_check)
|
||||
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")
|
||||
layout.addWidget(initial_overtime_note)
|
||||
|
||||
@ -516,22 +516,22 @@ class SettingsView(QDialog):
|
||||
|
||||
def create_goal_group(self) -> QGroupBox:
|
||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||
group = QGroupBox("월간 목표 (0=비활성)")
|
||||
group = QGroupBox(tr('settings.goal_group'))
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
ot_label = QLabel("월 연장근무 상한:")
|
||||
ot_label = QLabel(tr('settings.monthly_ot_cap'))
|
||||
ot_label.setFixedWidth(150)
|
||||
self.goal_ot_h = QSpinBox()
|
||||
self.goal_ot_h.setRange(0, 100)
|
||||
self.goal_ot_h.setSuffix(" 시간")
|
||||
self.goal_ot_h.setSuffix(tr('settings.suffix_hour'))
|
||||
self.goal_ot_h.setFixedWidth(100)
|
||||
self.goal_ot_m = QSpinBox()
|
||||
self.goal_ot_m.setRange(0, 59)
|
||||
self.goal_ot_m.setSingleStep(30)
|
||||
self.goal_ot_m.setSuffix(" 분")
|
||||
self.goal_ot_m.setSuffix(tr('settings.suffix_minute'))
|
||||
self.goal_ot_m.setFixedWidth(90)
|
||||
ot_row.addWidget(ot_label)
|
||||
ot_row.addWidget(self.goal_ot_h)
|
||||
@ -541,18 +541,18 @@ class SettingsView(QDialog):
|
||||
|
||||
# 일평균 목표
|
||||
avg_row = QHBoxLayout()
|
||||
avg_label = QLabel("일 평균 근무 목표:")
|
||||
avg_label = QLabel(tr('settings.daily_avg_goal'))
|
||||
avg_label.setFixedWidth(150)
|
||||
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
||||
self.goal_avg.setRange(0, 24)
|
||||
self.goal_avg.setSuffix(" 시간")
|
||||
self.goal_avg.setSuffix(tr('settings.suffix_hour'))
|
||||
self.goal_avg.setFixedWidth(100)
|
||||
avg_row.addWidget(avg_label)
|
||||
avg_row.addWidget(self.goal_avg)
|
||||
avg_row.addStretch()
|
||||
layout.addLayout(avg_row)
|
||||
|
||||
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
|
||||
note = QLabel(tr('settings.goal_note', fallback=''))
|
||||
note.setObjectName("note_text")
|
||||
layout.addWidget(note)
|
||||
|
||||
@ -567,40 +567,40 @@ class SettingsView(QDialog):
|
||||
|
||||
# 연차 개수 + 남은 연차 한 줄
|
||||
top_row = QHBoxLayout()
|
||||
annual_leave_label = QLabel("연간 연차:")
|
||||
annual_leave_label = QLabel(tr('settings.annual_leave'))
|
||||
annual_leave_label.setFixedWidth(70)
|
||||
self.annual_leave_days = QSpinBox()
|
||||
self.annual_leave_days.setRange(0, 30)
|
||||
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)
|
||||
top_row.addWidget(annual_leave_label)
|
||||
top_row.addWidget(self.annual_leave_days)
|
||||
top_row.addStretch()
|
||||
|
||||
self.remaining_leave_label = QLabel("남은 연차: 계산 중...")
|
||||
self.remaining_leave_label = QLabel(tr('settings.remaining_leave'))
|
||||
self.remaining_leave_label.setObjectName("badge_leave")
|
||||
top_row.addWidget(self.remaining_leave_label)
|
||||
layout.addLayout(top_row)
|
||||
|
||||
# 기존 사용 연차 설정
|
||||
used_leave_layout = QHBoxLayout()
|
||||
used_leave_label = QLabel("기존 사용:")
|
||||
used_leave_label = QLabel(tr('settings.used_leave'))
|
||||
used_leave_label.setFixedWidth(70)
|
||||
self.used_leave_hours = QSpinBox()
|
||||
self.used_leave_hours.setRange(0, 200)
|
||||
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_mins = QSpinBox()
|
||||
self.used_leave_mins.setRange(0, 59)
|
||||
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.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.setFixedWidth(50)
|
||||
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
|
||||
@ -612,7 +612,7 @@ class SettingsView(QDialog):
|
||||
used_leave_layout.addStretch()
|
||||
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")
|
||||
layout.addWidget(used_leave_note)
|
||||
|
||||
@ -627,33 +627,33 @@ class SettingsView(QDialog):
|
||||
|
||||
# 공휴일 목록 + 버튼 한 줄
|
||||
button_layout = QHBoxLayout()
|
||||
holiday_list_label = QLabel("등록:")
|
||||
holiday_list_label = QLabel(tr('settings.registered'))
|
||||
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")
|
||||
button_layout.addWidget(self.holiday_count_label)
|
||||
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.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록")
|
||||
add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip'))
|
||||
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
|
||||
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.clicked.connect(self.add_custom_holiday)
|
||||
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.clicked.connect(self.view_holidays)
|
||||
button_layout.addWidget(view_holidays_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다")
|
||||
holiday_note = QLabel(tr('settings.holiday_note', fallback=''))
|
||||
holiday_note.setObjectName("note_text")
|
||||
layout.addWidget(holiday_note)
|
||||
|
||||
@ -668,7 +668,7 @@ class SettingsView(QDialog):
|
||||
"""공휴일 개수 표시 업데이트"""
|
||||
current_year = datetime.now().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):
|
||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||
@ -680,19 +680,13 @@ class SettingsView(QDialog):
|
||||
current_year = now.year
|
||||
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
||||
include_next = now.month >= 11
|
||||
target_label = (f"{current_year}년 + {current_year + 1}년"
|
||||
if include_next else f"{current_year}년")
|
||||
target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1)
|
||||
if include_next else tr('settings.korean_holidays_years_label_single', year=current_year))
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"한국 공휴일 자동 추가",
|
||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
"포함:\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||
"• 정부 지정 대체·임시공휴일\n\n"
|
||||
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
||||
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
||||
tr('settings.korean_holidays_title'),
|
||||
tr('settings.korean_holidays_body', years=target_label),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
@ -707,18 +701,16 @@ class SettingsView(QDialog):
|
||||
self.update_holiday_count()
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"패키지 미설치",
|
||||
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n"
|
||||
"음력/임시공휴일 자동 등록을 원하시면:\n"
|
||||
" pip install holidays"
|
||||
tr('settings.package_not_installed'),
|
||||
tr('settings.package_fallback_body', hint=tr('settings.package_install_hint'))
|
||||
)
|
||||
return
|
||||
|
||||
self.update_holiday_count()
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"추가 완료",
|
||||
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다."
|
||||
tr('settings.add_done'),
|
||||
tr('settings.korean_holidays_added', year=current_year, count=added)
|
||||
)
|
||||
|
||||
def add_custom_holiday(self):
|
||||
@ -729,8 +721,8 @@ class SettingsView(QDialog):
|
||||
today = datetime.now().date().isoformat()
|
||||
date_str, ok = QInputDialog.getText(
|
||||
self,
|
||||
"공휴일 추가",
|
||||
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):",
|
||||
tr('settings.holiday_add_title'),
|
||||
tr('settings.holiday_date_prompt'),
|
||||
QLineEdit.Normal,
|
||||
today
|
||||
)
|
||||
@ -744,16 +736,16 @@ class SettingsView(QDialog):
|
||||
except ValueError:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"입력 오류",
|
||||
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)"
|
||||
tr('msg.input_error.title'),
|
||||
tr('msg.input_error.date_format')
|
||||
)
|
||||
return
|
||||
|
||||
# 공휴일 이름 입력
|
||||
name, ok = QInputDialog.getText(
|
||||
self,
|
||||
"공휴일 추가",
|
||||
"공휴일 이름을 입력하세요:",
|
||||
tr('settings.holiday_add_title'),
|
||||
tr('settings.holiday_name_prompt'),
|
||||
QLineEdit.Normal,
|
||||
""
|
||||
)
|
||||
@ -767,8 +759,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"추가 완료",
|
||||
f"공휴일이 추가되었습니다.\n{date_str}: {name}"
|
||||
tr('settings.add_done'),
|
||||
tr('settings.holiday_added', date=date_str, name=name)
|
||||
)
|
||||
|
||||
def view_holidays(self):
|
||||
@ -779,26 +771,28 @@ class SettingsView(QDialog):
|
||||
if not holidays:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"공휴일 목록",
|
||||
f"{current_year}년에 등록된 공휴일이 없습니다."
|
||||
tr('settings.holiday_list_title'),
|
||||
tr('stats.no_data')
|
||||
)
|
||||
return
|
||||
|
||||
# 목록 생성
|
||||
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n"
|
||||
holiday_list = tr('settings.holiday_list_header', year=current_year)
|
||||
for h in holidays:
|
||||
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
||||
weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()]
|
||||
recurring = " (매년)" if h['is_recurring'] else ""
|
||||
holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n"
|
||||
weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}")
|
||||
recurring = tr('label.recurring_yearly') if h['is_recurring'] else ""
|
||||
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(
|
||||
self,
|
||||
"공휴일 목록",
|
||||
holiday_list + "\n\n공휴일을 삭제하시겠습니까?",
|
||||
tr('settings.holiday_list_title'),
|
||||
holiday_list + tr('settings.holiday_delete_confirm'),
|
||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
||||
)
|
||||
|
||||
@ -819,8 +813,8 @@ class SettingsView(QDialog):
|
||||
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
||||
item, ok = QInputDialog.getItem(
|
||||
self,
|
||||
"공휴일 삭제",
|
||||
"삭제할 공휴일을 선택하세요:",
|
||||
tr('settings.holiday_delete_title'),
|
||||
tr('settings.holiday_delete_prompt'),
|
||||
items,
|
||||
0,
|
||||
False
|
||||
@ -830,7 +824,7 @@ class SettingsView(QDialog):
|
||||
date_str = item.split(":")[0]
|
||||
self.db.delete_holiday_by_date(date_str)
|
||||
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:
|
||||
"""데이터 관리 그룹"""
|
||||
@ -841,22 +835,22 @@ class SettingsView(QDialog):
|
||||
# CSV 내보내기 버튼들 한 줄
|
||||
export_layout = QHBoxLayout()
|
||||
|
||||
export_work_btn = QPushButton("근무기록")
|
||||
export_work_btn = QPushButton(tr('settings.export_work'))
|
||||
export_work_btn.setObjectName("btn_small")
|
||||
export_work_btn.clicked.connect(self.export_work_records)
|
||||
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.clicked.connect(self.export_overtime_summary)
|
||||
export_layout.addWidget(export_overtime_btn)
|
||||
|
||||
monthly_btn = QPushButton("월간 요약")
|
||||
monthly_btn = QPushButton(tr('settings.export_monthly'))
|
||||
monthly_btn.setObjectName("btn_small")
|
||||
monthly_btn.clicked.connect(self.export_monthly_summary)
|
||||
export_layout.addWidget(monthly_btn)
|
||||
|
||||
export_label = QLabel("CSV 내보내기")
|
||||
export_label = QLabel(tr('settings.export_csv'))
|
||||
export_label.setObjectName("note_text")
|
||||
export_layout.addWidget(export_label)
|
||||
export_layout.addStretch()
|
||||
@ -865,12 +859,12 @@ class SettingsView(QDialog):
|
||||
|
||||
# CSV 가져오기
|
||||
import_layout = QHBoxLayout()
|
||||
import_btn = QPushButton("CSV 가져오기")
|
||||
import_btn = QPushButton(tr('settings.import_csv'))
|
||||
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_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_layout.addWidget(import_label)
|
||||
import_layout.addStretch()
|
||||
@ -878,14 +872,14 @@ class SettingsView(QDialog):
|
||||
|
||||
# DB 경로 설정 (클라우드 동기화 가능)
|
||||
db_path_layout = QHBoxLayout()
|
||||
db_path_label = QLabel("DB 경로:")
|
||||
db_path_label = QLabel(tr('settings.db_path_label'))
|
||||
db_path_label.setFixedWidth(60)
|
||||
self.db_path_edit = QLineEdit()
|
||||
self.db_path_edit.setReadOnly(True)
|
||||
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.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.")
|
||||
db_path_btn.setToolTip(tr('settings.db_path_tooltip'))
|
||||
db_path_btn.clicked.connect(self._change_db_path)
|
||||
db_path_layout.addWidget(db_path_label)
|
||||
db_path_layout.addWidget(self.db_path_edit, 1)
|
||||
@ -893,39 +887,35 @@ class SettingsView(QDialog):
|
||||
layout.addLayout(db_path_layout)
|
||||
|
||||
# 자동 외출 (화면 잠금 시)
|
||||
self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀")
|
||||
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
||||
self.auto_break_check = QCheckBox(tr('settings.auto_break_lock'))
|
||||
self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback=''))
|
||||
layout.addWidget(self.auto_break_check)
|
||||
|
||||
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
||||
feedback_layout = QHBoxLayout()
|
||||
feedback_label = QLabel("Gitea 피드백:")
|
||||
feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback=''))
|
||||
feedback_label.setFixedWidth(80)
|
||||
self.gitea_token_edit = QLineEdit()
|
||||
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(self.gitea_token_edit, 1)
|
||||
layout.addLayout(feedback_layout)
|
||||
|
||||
self.gitea_feedback_enabled_check = QCheckBox(
|
||||
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
||||
)
|
||||
self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback'))
|
||||
layout.addWidget(self.gitea_feedback_enabled_check)
|
||||
|
||||
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
||||
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
||||
self.clock_in_unlock_check.setToolTip(
|
||||
"PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다."
|
||||
)
|
||||
self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock'))
|
||||
self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback=''))
|
||||
layout.addWidget(self.clock_in_unlock_check)
|
||||
|
||||
# 업데이트 확인
|
||||
update_layout = QHBoxLayout()
|
||||
from core.version import __version__
|
||||
version_label = QLabel(f"버전: v{__version__}")
|
||||
version_label = QLabel(tr('settings.version', version=__version__))
|
||||
version_label.setObjectName("note_text")
|
||||
update_btn = QPushButton("업데이트 확인 (F5)")
|
||||
update_btn = QPushButton(tr('settings.check_update'))
|
||||
update_btn.setObjectName("btn_small")
|
||||
update_btn.clicked.connect(self._check_updates)
|
||||
update_layout.addWidget(version_label)
|
||||
@ -941,7 +931,7 @@ class SettingsView(QDialog):
|
||||
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
||||
new_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"데이터베이스 파일 선택",
|
||||
tr('settings.select_db'),
|
||||
current,
|
||||
"SQLite Database (*.db)"
|
||||
)
|
||||
@ -952,10 +942,8 @@ class SettingsView(QDialog):
|
||||
self.db_path_edit.setText(new_path)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"DB 경로 변경",
|
||||
f"새 경로가 저장되었습니다:\n{new_path}\n\n"
|
||||
"기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n"
|
||||
"프로그램을 재시작하세요."
|
||||
tr('settings.db_path_label')[:-1],
|
||||
tr('settings.db_path_saved', path=new_path)
|
||||
)
|
||||
|
||||
def load_settings(self):
|
||||
@ -1105,7 +1093,7 @@ class SettingsView(QDialog):
|
||||
def _import_csv(self):
|
||||
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "CSV 가져오기",
|
||||
self, tr('settings.import_csv'),
|
||||
os.path.expanduser("~"),
|
||||
"CSV files (*.csv);;All files (*.*)",
|
||||
)
|
||||
@ -1115,19 +1103,17 @@ class SettingsView(QDialog):
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
rows = parse_csv(path)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
QMessageBox.critical(self, "파싱 실패", str(e))
|
||||
QMessageBox.critical(self, tr('settings.parse_failed'), str(e))
|
||||
return
|
||||
|
||||
if not rows:
|
||||
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
||||
QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body'))
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"충돌 처리",
|
||||
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
||||
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
||||
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
||||
tr('settings.conflict_title'),
|
||||
tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'),
|
||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||
)
|
||||
if reply == QMessageBox.Cancel:
|
||||
@ -1137,12 +1123,12 @@ class SettingsView(QDialog):
|
||||
try:
|
||||
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "가져오기 실패", str(e))
|
||||
QMessageBox.critical(self, tr('settings.import_failed'), str(e))
|
||||
return
|
||||
|
||||
QMessageBox.information(
|
||||
self, "완료",
|
||||
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
||||
self, tr('settings.import_complete'),
|
||||
tr('settings.import_result', added=added, updated=updated, skipped=skipped)
|
||||
)
|
||||
|
||||
def _check_updates(self):
|
||||
@ -1235,8 +1221,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"저장 완료",
|
||||
"설정이 저장되었습니다."
|
||||
tr('settings.save_done'),
|
||||
tr('settings.save_done_body')
|
||||
)
|
||||
|
||||
# 부모 윈도우에 설정 변경 알림
|
||||
@ -1252,9 +1238,8 @@ class SettingsView(QDialog):
|
||||
set_language_and_retranslate(new_lang)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"재시작 / Restart",
|
||||
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
|
||||
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
|
||||
tr('settings.restart_title'),
|
||||
tr('settings.restart_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -1286,10 +1271,8 @@ class SettingsView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"기존 연장근무 설정",
|
||||
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
||||
f"변경할 값: {hours}시간 {mins}분\n\n"
|
||||
f"기존 연장근무 시간을 변경하시겠습니까?",
|
||||
tr('settings.initial_overtime_title'),
|
||||
tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -1299,8 +1282,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"설정 완료",
|
||||
f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다."
|
||||
tr('settings.initial_overtime_done'),
|
||||
tr('settings.initial_overtime_done_body', hours=hours, mins=mins)
|
||||
)
|
||||
|
||||
# 부모 윈도우 잔액 업데이트
|
||||
@ -1312,8 +1295,8 @@ class SettingsView(QDialog):
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"오류",
|
||||
f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}"
|
||||
tr('settings.error'),
|
||||
tr('settings.initial_overtime_error', error=str(e))
|
||||
)
|
||||
|
||||
def update_overtime_balance_display(self):
|
||||
@ -1321,7 +1304,7 @@ class SettingsView(QDialog):
|
||||
balance_minutes = self.db.get_total_overtime_balance()
|
||||
hours = 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):
|
||||
"""남은 연차 계산 및 표시"""
|
||||
@ -1347,7 +1330,7 @@ class SettingsView(QDialog):
|
||||
remaining = total_annual - total_used
|
||||
|
||||
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):
|
||||
@ -1358,14 +1341,14 @@ class SettingsView(QDialog):
|
||||
records = stats.get('records', [])
|
||||
|
||||
if not records:
|
||||
QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.")
|
||||
QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records'))
|
||||
return
|
||||
|
||||
# 파일 경로 선택
|
||||
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"근무 기록 저장",
|
||||
tr('settings.save_work_title'),
|
||||
default_filename,
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
@ -1375,17 +1358,17 @@ class SettingsView(QDialog):
|
||||
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"내보내기 완료",
|
||||
f"근무 기록이 저장되었습니다.\n{saved_path}"
|
||||
tr('settings.export_done'),
|
||||
tr('settings.work_exported', path=saved_path)
|
||||
)
|
||||
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):
|
||||
"""연장근무 내역 내보내기"""
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"연장근무 내역 저장",
|
||||
tr('settings.save_ot_title'),
|
||||
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
@ -1395,18 +1378,18 @@ class SettingsView(QDialog):
|
||||
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"내보내기 완료",
|
||||
f"연장근무 내역이 저장되었습니다.\n{saved_path}"
|
||||
tr('settings.export_done'),
|
||||
tr('settings.ot_exported', path=saved_path)
|
||||
)
|
||||
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):
|
||||
"""월간 요약 내보내기"""
|
||||
now = datetime.now()
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"월간 요약 저장",
|
||||
tr('settings.save_monthly_title'),
|
||||
f"monthly_summary_{now.year}{now.month:02d}.csv",
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
@ -1416,11 +1399,11 @@ class SettingsView(QDialog):
|
||||
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"내보내기 완료",
|
||||
f"월간 요약이 저장되었습니다.\n{saved_path}"
|
||||
tr('settings.export_done'),
|
||||
tr('settings.monthly_exported', path=saved_path)
|
||||
)
|
||||
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):
|
||||
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
|
||||
@ -1435,10 +1418,8 @@ class SettingsView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"기존 사용 연차 설정",
|
||||
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
||||
f"변경할 값: {hours}시간 {mins}분\n\n"
|
||||
f"기존 사용 연차를 변경하시겠습니까?",
|
||||
tr('settings.initial_leave_title'),
|
||||
tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -1448,8 +1429,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"설정 완료",
|
||||
f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다."
|
||||
tr('settings.initial_leave_done'),
|
||||
tr('settings.initial_leave_done_body', hours=hours, mins=mins)
|
||||
)
|
||||
|
||||
# 남은 연차 재계산
|
||||
|
||||
@ -93,13 +93,13 @@ class StatsView(QDialog):
|
||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||
cards_row = QHBoxLayout()
|
||||
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='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='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='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='flame')
|
||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||
self.weekly_avg_card, self.weekly_ot_card):
|
||||
@ -109,7 +109,7 @@ class StatsView(QDialog):
|
||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||
from ui.chart_widget import make_chart_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='trending-up')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
@ -127,13 +127,13 @@ class StatsView(QDialog):
|
||||
# 카드 4개
|
||||
cards_row = QHBoxLayout()
|
||||
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='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='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='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='flame')
|
||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||
self.monthly_avg_card, self.monthly_ot_card):
|
||||
@ -159,7 +159,7 @@ class StatsView(QDialog):
|
||||
# 월간 차트
|
||||
from ui.chart_widget import make_chart_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='chart')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
@ -182,13 +182,13 @@ class StatsView(QDialog):
|
||||
f"font-size: 11pt; color: {tc('text')}; "
|
||||
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='search'))
|
||||
|
||||
# 출근 시각 분포 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.clock_in_chart = make_chart_widget(widget)
|
||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||
layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
|
||||
theme='gray', icon='clock'), 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
@ -225,15 +225,15 @@ class StatsView(QDialog):
|
||||
# 주간 통계
|
||||
weekly_stats = self.db.get_weekly_stats()
|
||||
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_days_card, f"{weekly_stats.get('work_days', 0)}일")
|
||||
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, tr('stats.value_days', days=weekly_stats.get('work_days', 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_hours = 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
|
||||
@ -251,21 +251,21 @@ class StatsView(QDialog):
|
||||
now = datetime.now()
|
||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||
self._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
|
||||
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:
|
||||
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:
|
||||
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_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
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'):
|
||||
@ -300,8 +300,10 @@ class StatsView(QDialog):
|
||||
from core.salary import estimate_pay, format_won
|
||||
result = estimate_pay(records, wage, rate)
|
||||
self.salary_label.setText(
|
||||
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
||||
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
||||
tr('stats.salary_estimate',
|
||||
total=format_won(result['total']),
|
||||
base=format_won(result['base']),
|
||||
overtime=format_won(result['overtime']))
|
||||
)
|
||||
self.salary_label.setVisible(True)
|
||||
|
||||
@ -334,7 +336,7 @@ class StatsView(QDialog):
|
||||
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
||||
avg_hour = 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])
|
||||
@ -342,14 +344,14 @@ class StatsView(QDialog):
|
||||
|
||||
if total_days > 0:
|
||||
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]
|
||||
if records_with_hours:
|
||||
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 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일
|
||||
@ -364,15 +366,15 @@ class StatsView(QDialog):
|
||||
consecutive_overtime = 0
|
||||
|
||||
if max_consecutive >= 3:
|
||||
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!")
|
||||
insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive))
|
||||
|
||||
# 주 52시간 체크
|
||||
if len(recent_records) >= 7:
|
||||
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
||||
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'))
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
|
||||
@ -7,6 +7,8 @@ from __future__ import annotations
|
||||
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class TodaySummaryCard(QFrame):
|
||||
"""퇴근 처리 직후 표시되는 요약 카드."""
|
||||
@ -30,7 +32,7 @@ class TodaySummaryCard(QFrame):
|
||||
layout.setSpacing(2)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("오늘의 요약")
|
||||
title = QLabel(tr('today.title'))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@ -70,17 +72,17 @@ class TodaySummaryCard(QFrame):
|
||||
"""
|
||||
h = int(total_hours)
|
||||
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 = []
|
||||
if lunch_minutes > 0:
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
details.append(tr('today.detail_lunch', minutes=lunch_minutes))
|
||||
if dinner_minutes > 0:
|
||||
details.append(f"저녁 {dinner_minutes}분")
|
||||
details.append(tr('today.detail_dinner', minutes=dinner_minutes))
|
||||
if break_minutes > 0:
|
||||
details.append(f"외출 {break_minutes}분")
|
||||
details.append(tr('today.detail_break', minutes=break_minutes))
|
||||
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.setVisible(bool(details))
|
||||
|
||||
|
||||
@ -138,6 +138,7 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
||||
conn = db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
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 break_records WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||
|
||||
@ -13,7 +13,9 @@ from typing import Optional, List
|
||||
|
||||
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
||||
# 일반 브라우저 UA로 위장해야 통과.
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
|
||||
# 버전은 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)
|
||||
COLOR_GREEN = 0x57F287
|
||||
|
||||
@ -14,15 +14,17 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
||||
# 공공데이터포털 특일정보 API 서비스 키.
|
||||
# 소스코드/바이너리 노출 방지를 위해 환경변수에서 읽습니다.
|
||||
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
||||
_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)'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user