Compare commits
No commits in common. "d8c6a9d78421632a3b0bb14f2ab65527396691cf" and "f5751460e3a33d98470f00bd88ce7bfad221d2f2" have entirely different histories.
d8c6a9d784
...
f5751460e3
610
AGENTS.md
610
AGENTS.md
@ -1,476 +1,176 @@
|
|||||||
# Clock-out Time Calculator — Agent Guide
|
# Project Conventions and Operational Gotchas
|
||||||
|
|
||||||
> Last verified against the working tree at version **2.11.2** (`core/version.py`).
|
## 🛠️ Setup & Execution
|
||||||
> 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.
|
- **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`).
|
||||||
|
|
||||||
---
|
## 🗄️ Architecture Notes (Core Business Logic)
|
||||||
|
|
||||||
## 1. Project Overview
|
### 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.
|
||||||
|
|
||||||
**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.
|
### 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).
|
||||||
|
|
||||||
- **Primary language:** Python 3.9+
|
### Settings system
|
||||||
- **GUI framework:** PyQt5
|
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
||||||
- **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout
|
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||||
- **Packaging:** PyInstaller (`main.exe` + `updater.exe`)
|
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
||||||
- **Distribution:** Gitea Releases on a self-hosted instance
|
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
||||||
- **Current version:** `2.11.2` (single source of truth: `core/version.py`)
|
|
||||||
- **Repository:** `kindnick/Clock_out_Time_Calculator`
|
|
||||||
|
|
||||||
The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
|
### i18n
|
||||||
|
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
|
||||||
|
- Sentence formatting via Python `str.format(**kwargs)`.
|
||||||
|
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
|
||||||
|
|
||||||
---
|
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||||
|
|
||||||
## 2. Technology Stack
|
### 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.
|
||||||
|
|
||||||
| Layer | Technology |
|
### 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`.
|
||||||
| 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 |
|
|
||||||
|
|
||||||
Dependencies are declared in `requirements.txt`:
|
### 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).
|
||||||
|
|
||||||
```text
|
### 4. Workday boundary
|
||||||
PyQt5>=5.15.0
|
`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.
|
||||||
pywin32>=305
|
|
||||||
python-dateutil>=2.8.0
|
|
||||||
matplotlib>=3.4.0
|
|
||||||
plyer>=2.0.0
|
|
||||||
holidays>=0.40
|
|
||||||
```
|
|
||||||
|
|
||||||
Install with:
|
### 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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
# Manual two-step
|
||||||
```
|
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||||
|
|
||||||
`pywin32` is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Directory Structure and Module Map
|
|
||||||
|
|
||||||
```text
|
|
||||||
Clock-out Time Calculator/
|
|
||||||
├── main.py # Application entry point / bootstrap
|
|
||||||
├── updater.py # Standalone update helper process (stdlib only)
|
|
||||||
├── main.spec # PyInstaller spec for main.exe
|
|
||||||
├── updater.spec # PyInstaller spec for updater.exe
|
|
||||||
├── release.ps1 # One-shot release script (PowerShell)
|
|
||||||
├── requirements.txt # Python dependencies
|
|
||||||
├── pytest.ini # pytest configuration
|
|
||||||
├── run_as_admin.bat # Convenience launcher
|
|
||||||
├── core/ # Business logic and data access
|
|
||||||
│ ├── database.py # SQLite schema, migrations, CRUD
|
|
||||||
│ ├── time_calculator.py # Pure time-math engine
|
|
||||||
│ ├── event_monitor.py # Windows Event Log clock-in detection
|
|
||||||
│ ├── notifier.py # Notification rule engine
|
|
||||||
│ ├── salary.py # Optional pay estimation
|
|
||||||
│ ├── i18n.py # Korean/English translation dictionaries
|
|
||||||
│ ├── settings_keys.py # Setting key constants
|
|
||||||
│ ├── achievements.py # 357 achievement definitions + evaluator
|
|
||||||
│ ├── recurring_leaves.py # Recurring leave pattern expansion
|
|
||||||
│ └── version.py # __version__ single source of truth
|
|
||||||
├── ui/ # PyQt5 views and widgets
|
|
||||||
│ ├── main_window.py # Central 1 Hz main window
|
|
||||||
│ ├── styles.py # Theme colors and QSS
|
|
||||||
│ ├── dark_components.py # Reusable dark-styled widgets
|
|
||||||
│ ├── icons.py # Icon resource helpers
|
|
||||||
│ ├── i18n_runtime.py # Runtime retranslation registry
|
|
||||||
│ ├── settings_view.py # Settings dialog
|
|
||||||
│ ├── stats_view.py # Weekly/monthly/pattern statistics
|
|
||||||
│ ├── chart_widget.py # matplotlib QtAgg wrapper + fallback
|
|
||||||
│ ├── calendar_view.py # Work-record calendar
|
|
||||||
│ ├── leave_calendar_view.py # Color-coded leave calendar
|
|
||||||
│ ├── break_view.py # Break history dialog
|
|
||||||
│ ├── overtime_view.py # Overtime bank/usage dialog
|
|
||||||
│ ├── leave_view.py # Leave management dialog
|
|
||||||
│ ├── recurring_leave_dialog.py
|
|
||||||
│ ├── schedule_view.py # Schedule view
|
|
||||||
│ ├── clock_in_dialog.py # Manual clock-in dialog
|
|
||||||
│ ├── meal_time_dialog.py # Lunch/dinner start-end input
|
|
||||||
│ ├── past_record_dialog.py # Add past record dialog
|
|
||||||
│ ├── achievements_view.py # Achievements browser
|
|
||||||
│ ├── onboarding_view.py # 5-step first-run wizard
|
|
||||||
│ ├── today_summary.py # Post-clock-out card
|
|
||||||
│ ├── goal_widget.py # Monthly goal progress bars
|
|
||||||
│ ├── mini_widget.py # Always-on-top frameless widget
|
|
||||||
│ ├── help_view.py # 6-tab help dialog
|
|
||||||
│ ├── accessibility.py # Font scale / high-contrast helpers
|
|
||||||
│ └── controllers/ # Thin controllers split from MainWindow
|
|
||||||
│ ├── lock_monitor.py # Screen-lock auto-break / unlock clock-in
|
|
||||||
│ ├── auto_lunch.py # Auto-lunch after 4 hours
|
|
||||||
│ ├── notification_orchestrator.py # 5-min-tick orchestrator
|
|
||||||
│ └── meal_controller.py # Lunch/dinner toggle handling
|
|
||||||
├── utils/ # Helpers and integrations
|
|
||||||
│ ├── backup.py # Daily SQLite backup with 7-rotation
|
|
||||||
│ ├── lock_detector.py # Win32 screen-lock detection
|
|
||||||
│ ├── discord_webhook.py # Discord embed pushes
|
|
||||||
│ ├── csv_importer.py # CSV import (standard format)
|
|
||||||
│ ├── csv_exporter.py # CSV export
|
|
||||||
│ ├── crash_handler.py # Global excepthook + optional Gitea report
|
|
||||||
│ ├── updater_client.py # Gitea Releases API client
|
|
||||||
│ ├── system_tray.py # System tray menu
|
|
||||||
│ ├── time_format.py # format_hours_minutes helper
|
|
||||||
│ ├── font_loader.py # NanumSquare font loading
|
|
||||||
│ ├── resource_manager.py # PyInstaller _MEIPASS path resolver
|
|
||||||
│ ├── debug_log.py # CLOCKOUT_DEBUG gated logging
|
|
||||||
│ └── holiday_api.py # Korean holiday API client
|
|
||||||
├── tests/ # pytest unit tests (13 files)
|
|
||||||
├── font/ # Bundled NanumSquare fonts
|
|
||||||
├── resources/ # Icons and resource links
|
|
||||||
├── analysis/ # Currently empty (only __init__.py)
|
|
||||||
├── build/ # Build staging directory
|
|
||||||
└── dist/ # Built EXEs and release ZIPs
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** `utils/http_api.py` is referenced in older documentation but is **not present** in the current working tree.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. How to Build, Run, and Smoke-Test
|
|
||||||
|
|
||||||
### Development run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
`main.py` inserts the project root into `sys.path`, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches `MainWindow`.
|
|
||||||
|
|
||||||
A convenience batch file is provided:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
run_as_admin.bat
|
|
||||||
```
|
|
||||||
|
|
||||||
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
|
|
||||||
|
|
||||||
### Module-level smoke tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python core/event_monitor.py
|
|
||||||
python core/time_calculator.py
|
|
||||||
```
|
|
||||||
|
|
||||||
These run lightweight self-tests when invoked as scripts.
|
|
||||||
|
|
||||||
### Production build (manual two-step)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m PyInstaller --clean updater.spec
|
|
||||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||||
python -m PyInstaller --clean main.spec
|
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
||||||
|
|
||||||
|
# Or one-shot
|
||||||
|
.\release.ps1 v2.7.0
|
||||||
```
|
```
|
||||||
|
|
||||||
- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
|
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
||||||
- `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`).
|
- `holidays` package only baked in if installed in build env.
|
||||||
- The staging copy is critical: `main.spec --clean` wipes `dist/`, so without `build/staging/updater.exe` the updater would be deleted mid-build.
|
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
||||||
|
|
||||||
### Production build (one-shot)
|
## 🚦 External Integrations
|
||||||
|
|
||||||
```bash
|
- **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.
|
||||||
.\release.ps1 v2.11.2
|
- **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)
|
||||||
```
|
```
|
||||||
|
|
||||||
The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`.
|
`--DryRun` previews without git push or API calls.
|
||||||
|
|
||||||
### 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`/`Set-Content` default to ANSI and mangle Korean in `CHANGELOG.md` and `core/version.py`. Use `[System.IO.File]::ReadAllText`/`WriteAllText(path, UTF8)`. |
|
|
||||||
| PowerShell NativeCommandError | Use `Invoke-Native` helper with `$ErrorActionPreference = 'Continue'` and explicit `$LASTEXITCODE`. |
|
|
||||||
| Frozen chart numpy failure | Added `numpy.core._multiarray_tests` to `main.spec` hiddenimports. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Additional References
|
|
||||||
|
|
||||||
- `README.md` — end-user feature overview (Korean/English mixed).
|
|
||||||
- `CLAUDE.md` — additional Korean-language guidance for this project.
|
|
||||||
- `INSTALL.md` — installation and troubleshooting instructions.
|
|
||||||
- `CHANGELOG.md` — release notes in Korean.
|
|
||||||
- `resources/resource_links.md` — links to external resources.
|
|
||||||
|
|
||||||
When modifying code, update this `AGENTS.md` if you change any conventions, build steps, module map, or external integrations described here.
|
|
||||||
|
|||||||
19
CHANGELOG.md
19
CHANGELOG.md
@ -4,25 +4,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## [2.12.0] — 2026-06-16
|
|
||||||
|
|
||||||
### Added — 전체 i18n 키화 완료
|
|
||||||
- **UI 전체 사용자 대면 문자열 i18n 키화** — `ui/` 디렉터리 22개 파일의 버튼/라벨/메시지박스/차트/온볼딩/설정/통계 등을 `tr()` 기반으로 전환, `core/i18n.py`에 ko/en 번역 추가.
|
|
||||||
- **도전과제 메타데이터 i18n** — `core/achievements.py`의 모든 도전과제 이름/설명을 `achieve.{code}.name/desc` 키로 분리하고 영문 번역 추가.
|
|
||||||
- **차트 위젯 라벨 키화** — matplotlib 차트의 축/툴팁/범례/빈 기록 메시지 등을 언어별로 표시.
|
|
||||||
- **반복 연차 패턴 설명 키화** — `core/recurring_leaves.describe_pattern()`이 요일/주기 접두사를 i18n 키로 조합.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- `ui/help_view.py`의 "온보팅 다시 보기" 버튼이 `tr()` 호출이 아닌 리터럴 문자열로 잘못 들어가던 버그 수정.
|
|
||||||
- `core/i18n.py` 영문 achievement 번역 중 `Children's`/`Teachers'` 작은따옴표로 인한 SyntaxError 수정.
|
|
||||||
- `_i18n_gui_test.py`에 `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 추가하여 백그라운드 휴일 동기화 스레드로 인한 세그멘테이션 폴트 방지.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **공공데이터포털 공휴일 API 키 외부화** — `utils/holiday_api.py`가 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수를 사용하도록 변경.
|
|
||||||
- `ui/main_window.py` 1Hz 핫패스 DB 호출 캐싱 추가.
|
|
||||||
- `utils/csv_importer.py` overwrite 시 `overtime_usage`도 함께 삭제.
|
|
||||||
- `ui/controllers/lock_monitor.py` 컨텍스트 매니저 적용 및 race condition 처리 개선.
|
|
||||||
|
|
||||||
## [2.11.2] — 2026-06-04
|
## [2.11.2] — 2026-06-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -11,9 +11,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
||||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||||
|
|
||||||
# 백그라운드 휴일 동기화 스레드 비활성화 (DB lock / segfault 방지)
|
|
||||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
app = QApplication.instance() or QApplication(sys.argv)
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
@ -90,7 +87,7 @@ def test_stats_view():
|
|||||||
from ui.stats_view import StatsView
|
from ui.stats_view import StatsView
|
||||||
dlg = StatsView(db=db)
|
dlg = StatsView(db=db)
|
||||||
# 데이터 없어도 정상 로드
|
# 데이터 없어도 정상 로드
|
||||||
assert dlg.weekly_total_card is not None
|
assert dlg.weekly_total_hours.text() is not None
|
||||||
dlg.deleteLater()
|
dlg.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
@ -104,25 +101,12 @@ def test_main_window_init():
|
|||||||
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
||||||
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
||||||
from ui.main_window import MainWindow
|
from ui.main_window import MainWindow
|
||||||
from datetime import date as _date
|
w = MainWindow()
|
||||||
# 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 # 퇴근 완료 기록이므로 False
|
assert w.is_clocked_in == False
|
||||||
assert w.lunch_break_enabled == False
|
assert w.lunch_break_enabled == False
|
||||||
# auto_lunch 캐시 초기 None (AutoLunchManager 낶)
|
# auto_lunch 캐시 초기 None
|
||||||
assert w._auto_lunch._enabled_cache is None
|
assert w._auto_lunch_enabled_cache is None
|
||||||
# 단축키 7개 등록되었는지
|
# 단축키 7개 등록되었는지
|
||||||
from PyQt5.QtWidgets import QShortcut
|
from PyQt5.QtWidgets import QShortcut
|
||||||
shortcuts = w.findChildren(QShortcut)
|
shortcuts = w.findChildren(QShortcut)
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
- notification_log: 휴식 권고 카운트
|
- notification_log: 휴식 권고 카운트
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from core.i18n import tr
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Callable, Optional, List, Tuple
|
from typing import Callable, Optional, List, Tuple
|
||||||
@ -427,40 +426,40 @@ def _bool_eval(condition_fn):
|
|||||||
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
||||||
_STREAK_DEFS = [
|
_STREAK_DEFS = [
|
||||||
# (code, name, desc, target, evaluator, tier, icon)
|
# (code, name, desc, target, evaluator, tier, icon)
|
||||||
('streak_first', tr('achieve.streak_first.name'), tr('achieve.streak_first.desc'), 1,
|
('streak_first', '첫걸음', '첫 출근 기록', 1,
|
||||||
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
|
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
|
||||||
('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3,
|
('streak_3', '뿌리내림', '3일 연속 영업일 출근', 3,
|
||||||
_make_streak_eval(3), TIER_BRONZE, '🌱'),
|
_make_streak_eval(3), TIER_BRONZE, '🌱'),
|
||||||
('streak_5', tr('achieve.streak_5.name'), tr('achieve.streak_5.desc'), 5,
|
('streak_5', '첫 주 완주', '5 영업일 연속 출근', 5,
|
||||||
_make_streak_eval(5), TIER_SILVER, '📅'),
|
_make_streak_eval(5), TIER_SILVER, '📅'),
|
||||||
('streak_7_cal', tr('achieve.streak_7_cal.name'), tr('achieve.streak_7_cal.desc'), 7,
|
('streak_7_cal', '7일 연속', '주말 포함 7일 연속 출근', 7,
|
||||||
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
|
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
|
||||||
('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10,
|
('streak_10', '2주 연속', '10 영업일 연속 출근', 10,
|
||||||
_make_streak_eval(10), TIER_SILVER, '💪'),
|
_make_streak_eval(10), TIER_SILVER, '💪'),
|
||||||
('streak_22', tr('achieve.streak_22.name'), tr('achieve.streak_22.desc'), 22,
|
('streak_22', '한 달 개근', '한 달 영업일 100% 출근 (22일)', 22,
|
||||||
_make_streak_eval(22), TIER_GOLD, '🏔️'),
|
_make_streak_eval(22), TIER_GOLD, '🏔️'),
|
||||||
('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50,
|
('streak_50', '50일 연속', '50 영업일 연속 출근', 50,
|
||||||
_make_streak_eval(50), TIER_GOLD, '🎯'),
|
_make_streak_eval(50), TIER_GOLD, '🎯'),
|
||||||
('streak_100', tr('achieve.streak_100.name'), tr('achieve.streak_100.desc'), 100,
|
('streak_100', '100일 연속', '100 영업일 연속 출근', 100,
|
||||||
_make_streak_eval(100), TIER_PLATINUM, '💎'),
|
_make_streak_eval(100), TIER_PLATINUM, '💎'),
|
||||||
('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65,
|
('streak_quarter', '분기 완주', '약 65 영업일 (3개월)', 65,
|
||||||
_make_streak_eval(65), TIER_PLATINUM, '🏆'),
|
_make_streak_eval(65), TIER_PLATINUM, '🏆'),
|
||||||
('streak_half_year', tr('achieve.streak_half_year.name'), tr('achieve.streak_half_year.desc'), 130,
|
('streak_half_year', '반년 마라톤', '약 130 영업일 (6개월)', 130,
|
||||||
_make_streak_eval(130), TIER_PLATINUM, '👑'),
|
_make_streak_eval(130), TIER_PLATINUM, '👑'),
|
||||||
('streak_year', tr('achieve.streak_year.name'), tr('achieve.streak_year.desc'), 260,
|
('streak_year', '1년 풀 시즌', '약 260 영업일 (1년)', 260,
|
||||||
_make_streak_eval(260), TIER_LEGEND, '🌟'),
|
_make_streak_eval(260), TIER_LEGEND, '🌟'),
|
||||||
('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200,
|
('streak_200', '사이언스', '200 영업일 연속', 200,
|
||||||
_make_streak_eval(200), TIER_LEGEND, '🌌'),
|
_make_streak_eval(200), TIER_LEGEND, '🌌'),
|
||||||
('streak_365_cal', tr('achieve.streak_365_cal.name'), tr('achieve.streak_365_cal.desc'), 365,
|
('streak_365_cal', '불사신', '365일 달력 연속', 365,
|
||||||
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
|
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
|
||||||
('streak_resilience', tr('achieve.streak_resilience.name'), tr('achieve.streak_resilience.desc'), 1,
|
('streak_resilience', '회복력', '결근 후 다음날 즉시 출근 (자동: 달력 streak 깨진 후 재시작)', 1,
|
||||||
_bool_eval(lambda db: _consecutive_workdays(db) >= 1
|
_bool_eval(lambda db: _consecutive_workdays(db) >= 1
|
||||||
and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'),
|
and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'),
|
||||||
('streak_total_100', tr('achieve.streak_total_100.name'), tr('achieve.streak_total_100.desc'), 100,
|
('streak_total_100', '누적 100회', '누적 출근 100회', 100,
|
||||||
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
|
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
|
||||||
('streak_total_500', tr('achieve.streak_total_500.name'), tr('achieve.streak_total_500.desc'), 500,
|
('streak_total_500', '누적 500회', '누적 출근 500회', 500,
|
||||||
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
|
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
|
||||||
('streak_total_1000', tr('achieve.streak_total_1000.name'), tr('achieve.streak_total_1000.desc'), 1000,
|
('streak_total_1000', '누적 1000회', '누적 출근 1000회', 1000,
|
||||||
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
|
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -477,37 +476,37 @@ def _count_weekday_clockins(db, weekday: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
_STREAK_DEFS.extend([
|
_STREAK_DEFS.extend([
|
||||||
('streak_monday_10', tr('achieve.streak_monday_10.name'), tr('achieve.streak_monday_10.desc'), 10,
|
('streak_monday_10', '월요일 정복', '월요일 10주 연속 출근', 10,
|
||||||
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
|
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
|
||||||
('streak_friday_10', tr('achieve.streak_friday_10.name'), tr('achieve.streak_friday_10.desc'), 10,
|
('streak_friday_10', '금요일 무결', '금요일 10주 연속 출근', 10,
|
||||||
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
|
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
||||||
_PUNCTUAL_DEFS = [
|
_PUNCTUAL_DEFS = [
|
||||||
('punc_before_8_1', tr('achieve.punc_before_8_1.name'), tr('achieve.punc_before_8_1.desc'), 1,
|
('punc_before_8_1', '얼리버드', '08:00 이전 출근 1회', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
|
||||||
('punc_before_8_10', tr('achieve.punc_before_8_10.name'), tr('achieve.punc_before_8_10.desc'), 10,
|
('punc_before_8_10', '참새족', '08:00 이전 10회', 10,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
|
||||||
('punc_before_8_30', tr('achieve.punc_before_8_30.name'), tr('achieve.punc_before_8_30.desc'), 30,
|
('punc_before_8_30', '일찍 자고 일찍', '08:00 이전 30회', 30,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
|
||||||
('punc_before_6_1', tr('achieve.punc_before_6_1.name'), tr('achieve.punc_before_6_1.desc'), 1,
|
('punc_before_6_1', '새벽잠 없음', '06:00 이전 1회', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
|
||||||
('punc_before_6_10', tr('achieve.punc_before_6_10.name'), tr('achieve.punc_before_6_10.desc'), 10,
|
('punc_before_6_10', '어둠을 가르는 자', '06:00 이전 10회', 10,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
|
||||||
('punc_before_5', tr('achieve.punc_before_5.name'), tr('achieve.punc_before_5.desc'), 1,
|
('punc_before_5', '새벽 챔피언', '05:00 이전 출근', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
|
||||||
('punc_at_9', tr('achieve.punc_at_9.name'), tr('achieve.punc_at_9.desc'), 1,
|
('punc_at_9', '9시 정각', '09:00 정각(±1분) 출근 1회', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
|
||||||
TIER_BRONZE, '🎯'),
|
TIER_BRONZE, '🎯'),
|
||||||
('punc_at_9_5', tr('achieve.punc_at_9_5.name'), tr('achieve.punc_at_9_5.desc'), 5,
|
('punc_at_9_5', '완벽한 9시', '09:00 정각(±1분) 5회', 5,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
|
||||||
TIER_SILVER, '🏹'),
|
TIER_SILVER, '🏹'),
|
||||||
('punc_late_5min', tr('achieve.punc_late_5min.name'), tr('achieve.punc_late_5min.desc'), 1,
|
('punc_late_5min', '5분 늦음', '09:00~09:05 출근 1회 (자조)', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
|
||||||
TIER_BRONZE, '🛌'),
|
TIER_BRONZE, '🛌'),
|
||||||
('punc_at_909', tr('achieve.punc_at_909.name'), tr('achieve.punc_at_909.desc'), 1,
|
('punc_at_909', '운명의 시각', '09:09 출근 (시크릿)', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
|
||||||
TIER_GOLD, '🎰'),
|
TIER_GOLD, '🎰'),
|
||||||
]
|
]
|
||||||
@ -526,76 +525,76 @@ def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> i
|
|||||||
|
|
||||||
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
|
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
|
||||||
_BALANCE_DEFS = [
|
_BALANCE_DEFS = [
|
||||||
('bal_first_punct', tr('achieve.bal_first_punct.name'), tr('achieve.bal_first_punct.desc'), 1,
|
('bal_first_punct', '첫 칼퇴', '정시 퇴근 첫 달성', 1,
|
||||||
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
|
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
|
||||||
('bal_punct_10', tr('achieve.bal_punct_10.name'), tr('achieve.bal_punct_10.desc'), 10,
|
('bal_punct_10', '칼퇴러', '정시 퇴근 10회', 10,
|
||||||
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
|
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
|
||||||
('bal_punct_30', tr('achieve.bal_punct_30.name'), tr('achieve.bal_punct_30.desc'), 30,
|
('bal_punct_30', '칼퇴 챔프', '정시 퇴근 30회', 30,
|
||||||
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
|
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
|
||||||
('bal_punct_100', tr('achieve.bal_punct_100.name'), tr('achieve.bal_punct_100.desc'), 100,
|
('bal_punct_100', '진정한 자유', '정시 퇴근 100회', 100,
|
||||||
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
|
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
|
||||||
('bal_punct_300', tr('achieve.bal_punct_300.name'), tr('achieve.bal_punct_300.desc'), 300,
|
('bal_punct_300', '워라밸 마스터', '정시 퇴근 300회', 300,
|
||||||
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
|
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 4. 연장근무 적립 ----
|
# ---- 4. 연장근무 적립 ----
|
||||||
_OT_BANK_DEFS = [
|
_OT_BANK_DEFS = [
|
||||||
('ot_first_30m', tr('achieve.ot_first_30m.name'), tr('achieve.ot_first_30m.desc'), 30,
|
('ot_first_30m', '첫 30분', '첫 연장 적립', 30,
|
||||||
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
|
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
|
||||||
('ot_total_60m', tr('achieve.ot_total_60m.name'), tr('achieve.ot_total_60m.desc'), 60,
|
('ot_total_60m', '1시간 적금', '누적 1시간 적립', 60,
|
||||||
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
|
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
|
||||||
('ot_total_5h', tr('achieve.ot_total_5h.name'), tr('achieve.ot_total_5h.desc'), 300,
|
('ot_total_5h', '5시간 적립', '누적 5시간', 300,
|
||||||
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
|
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
|
||||||
('ot_total_10h', tr('achieve.ot_total_10h.name'), tr('achieve.ot_total_10h.desc'), 600,
|
('ot_total_10h', '10시간 적립', '누적 10시간', 600,
|
||||||
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
|
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
|
||||||
('ot_total_25h', tr('achieve.ot_total_25h.name'), tr('achieve.ot_total_25h.desc'), 1500,
|
('ot_total_25h', '25시간 적립', '누적 25시간', 1500,
|
||||||
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
|
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
|
||||||
('ot_total_50h', tr('achieve.ot_total_50h.name'), tr('achieve.ot_total_50h.desc'), 3000,
|
('ot_total_50h', '50시간 적립', '누적 50시간', 3000,
|
||||||
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
|
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
|
||||||
('ot_total_100h', tr('achieve.ot_total_100h.name'), tr('achieve.ot_total_100h.desc'), 6000,
|
('ot_total_100h', '마라토너', '누적 100시간 (걱정 메시지)', 6000,
|
||||||
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
|
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
|
||||||
('ot_total_200h', tr('achieve.ot_total_200h.name'), tr('achieve.ot_total_200h.desc'), 12000,
|
('ot_total_200h', '워크홀릭 경고', '누적 200시간 (경고)', 12000,
|
||||||
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
|
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
|
||||||
('ot_total_300h', tr('achieve.ot_total_300h.name'), tr('achieve.ot_total_300h.desc'), 18000,
|
('ot_total_300h', '위험 신호', '누적 300시간 (강한 경고)', 18000,
|
||||||
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
|
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
|
||||||
('ot_total_500h', tr('achieve.ot_total_500h.name'), tr('achieve.ot_total_500h.desc'), 30000,
|
('ot_total_500h', '응급실 단골', '누적 500시간 (자조)', 30000,
|
||||||
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
|
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 5. 연장근무 사용 ----
|
# ---- 5. 연장근무 사용 ----
|
||||||
_OT_USE_DEFS = [
|
_OT_USE_DEFS = [
|
||||||
('use_first', tr('achieve.use_first.name'), tr('achieve.use_first.desc'), 1,
|
('use_first', '첫 휴식', '적립 첫 사용', 1,
|
||||||
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
|
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
|
||||||
('use_total_5h', tr('achieve.use_total_5h.name'), tr('achieve.use_total_5h.desc'), 300,
|
('use_total_5h', '선물 사용', '누적 5시간 사용', 300,
|
||||||
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
|
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
|
||||||
('use_total_25h', tr('achieve.use_total_25h.name'), tr('achieve.use_total_25h.desc'), 1500,
|
('use_total_25h', '휴식의 가치', '누적 25시간 사용', 1500,
|
||||||
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
|
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
|
||||||
('use_total_50h', tr('achieve.use_total_50h.name'), tr('achieve.use_total_50h.desc'), 3000,
|
('use_total_50h', '회복 마스터', '누적 50시간 사용', 3000,
|
||||||
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
|
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
|
||||||
('use_total_100h', tr('achieve.use_total_100h.name'), tr('achieve.use_total_100h.desc'), 6000,
|
('use_total_100h', '마사지', '누적 100시간 사용', 6000,
|
||||||
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
|
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 6. 연차 ----
|
# ---- 6. 연차 ----
|
||||||
_LEAVE_DEFS = [
|
_LEAVE_DEFS = [
|
||||||
('leave_first', tr('achieve.leave_first.name'), tr('achieve.leave_first.desc'), 1,
|
('leave_first', '첫 연차', '첫 연차 사용', 1,
|
||||||
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
|
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
|
||||||
('leave_half', tr('achieve.leave_half.name'), tr('achieve.leave_half.desc'), 1,
|
('leave_half', '첫 반차', '0.5일 연차 사용', 1,
|
||||||
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
|
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
|
||||||
('leave_quarter', tr('achieve.leave_quarter.name'), tr('achieve.leave_quarter.desc'), 1,
|
('leave_quarter', '시간 연차', '0.25일 연차 사용', 1,
|
||||||
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
|
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
|
||||||
('leave_streak_3', tr('achieve.leave_streak_3.name'), tr('achieve.leave_streak_3.desc'), 3,
|
('leave_streak_3', '미니 휴가', '연속 3일 연차', 3,
|
||||||
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
|
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
|
||||||
('leave_streak_5', tr('achieve.leave_streak_5.name'), tr('achieve.leave_streak_5.desc'), 5,
|
('leave_streak_5', '본격 휴가', '연속 5일 연차', 5,
|
||||||
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
|
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
|
||||||
('leave_streak_7', tr('achieve.leave_streak_7.name'), tr('achieve.leave_streak_7.desc'), 7,
|
('leave_streak_7', '장거리 휴가', '연속 7일 이상 연차', 7,
|
||||||
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
|
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
|
||||||
('leave_total_10', tr('achieve.leave_total_10.name'), tr('achieve.leave_total_10.desc'), 10,
|
('leave_total_10', '연차 10회', '연차 기록 10건', 10,
|
||||||
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
|
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
|
||||||
('leave_sick', tr('achieve.leave_sick.name'), tr('achieve.leave_sick.desc'), 1,
|
('leave_sick', '병가', 'sick 타입 연차 사용', 1,
|
||||||
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
|
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
|
||||||
TIER_BRONZE, '🏥'),
|
TIER_BRONZE, '🏥'),
|
||||||
]
|
]
|
||||||
@ -603,22 +602,22 @@ _LEAVE_DEFS = [
|
|||||||
|
|
||||||
# ---- 7. 식사 (점심/저녁) ----
|
# ---- 7. 식사 (점심/저녁) ----
|
||||||
_MEAL_DEFS = [
|
_MEAL_DEFS = [
|
||||||
('meal_lunch_first', tr('achieve.meal_lunch_first.name'), tr('achieve.meal_lunch_first.desc'), 1,
|
('meal_lunch_first', '첫 점심 등록', '점심 첫 토글', 1,
|
||||||
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
|
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
|
||||||
('meal_lunch_30', tr('achieve.meal_lunch_30.name'), tr('achieve.meal_lunch_30.desc'), 30,
|
('meal_lunch_30', '점심 마스터', '점심 등록 30회', 30,
|
||||||
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
|
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
|
||||||
('meal_lunch_100', tr('achieve.meal_lunch_100.name'), tr('achieve.meal_lunch_100.desc'), 100,
|
('meal_lunch_100', '점심 챔프', '점심 등록 100회', 100,
|
||||||
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
|
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
|
||||||
('meal_dinner_first', tr('achieve.meal_dinner_first.name'), tr('achieve.meal_dinner_first.desc'), 1,
|
('meal_dinner_first', '첫 저녁 등록', '저녁 첫 토글', 1,
|
||||||
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
|
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
|
||||||
('meal_dinner_10', tr('achieve.meal_dinner_10.name'), tr('achieve.meal_dinner_10.desc'), 10,
|
('meal_dinner_10', '저녁 단골', '저녁 등록 10회 (경고)', 10,
|
||||||
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
|
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
|
||||||
('meal_dinner_30', tr('achieve.meal_dinner_30.name'), tr('achieve.meal_dinner_30.desc'), 30,
|
('meal_dinner_30', '야식 단골', '저녁 등록 30회 (경고)', 30,
|
||||||
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
|
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
|
||||||
('meal_lunch_actual', tr('achieve.meal_lunch_actual.name'), tr('achieve.meal_lunch_actual.desc'), 1,
|
('meal_lunch_actual', '실측 점심', '실제 점심 시각 입력', 1,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
|
||||||
TIER_BRONZE, '⏱️'),
|
TIER_BRONZE, '⏱️'),
|
||||||
('meal_dinner_actual', tr('achieve.meal_dinner_actual.name'), tr('achieve.meal_dinner_actual.desc'), 1,
|
('meal_dinner_actual', '실측 저녁', '실제 저녁 시각 입력', 1,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
|
||||||
TIER_BRONZE, '⏰'),
|
TIER_BRONZE, '⏰'),
|
||||||
]
|
]
|
||||||
@ -626,13 +625,13 @@ _MEAL_DEFS = [
|
|||||||
|
|
||||||
# ---- 8. 외출 ----
|
# ---- 8. 외출 ----
|
||||||
_BREAK_DEFS = [
|
_BREAK_DEFS = [
|
||||||
('break_first', tr('achieve.break_first.name'), tr('achieve.break_first.desc'), 1,
|
('break_first', '첫 외출', '첫 외출 시작', 1,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
|
||||||
TIER_BRONZE, '🚶'),
|
TIER_BRONZE, '🚶'),
|
||||||
('break_10', tr('achieve.break_10.name'), tr('achieve.break_10.desc'), 10,
|
('break_10', '외출 챔프', '외출 10회', 10,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
|
||||||
TIER_SILVER, '🚪'),
|
TIER_SILVER, '🚪'),
|
||||||
('break_50', tr('achieve.break_50.name'), tr('achieve.break_50.desc'), 50,
|
('break_50', '산책러', '외출 50회', 50,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
|
||||||
TIER_GOLD, '🚶♂️'),
|
TIER_GOLD, '🚶♂️'),
|
||||||
]
|
]
|
||||||
@ -640,39 +639,39 @@ _BREAK_DEFS = [
|
|||||||
|
|
||||||
# ---- 9. 시간대별 ----
|
# ---- 9. 시간대별 ----
|
||||||
_TIME_SLOT_DEFS = [
|
_TIME_SLOT_DEFS = [
|
||||||
('slot_in_06', tr('achieve.slot_in_06.name'), tr('achieve.slot_in_06.desc'), 1,
|
('slot_in_06', '06시대 출근', '06:00-06:59 출근 1회', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
|
||||||
TIER_BRONZE, '🌅'),
|
TIER_BRONZE, '🌅'),
|
||||||
('slot_in_07', tr('achieve.slot_in_07.name'), tr('achieve.slot_in_07.desc'), 1,
|
('slot_in_07', '07시대 출근', '07:00-07:59 출근 1회', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
|
||||||
TIER_BRONZE, '🌄'),
|
TIER_BRONZE, '🌄'),
|
||||||
('slot_in_08', tr('achieve.slot_in_08.name'), tr('achieve.slot_in_08.desc'), 1,
|
('slot_in_08', '08시대 출근', '08:00-08:59 출근 1회', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
|
||||||
TIER_BRONZE, '☀️'),
|
TIER_BRONZE, '☀️'),
|
||||||
('slot_in_10', tr('achieve.slot_in_10.name'), tr('achieve.slot_in_10.desc'), 1,
|
('slot_in_10', '10시대 출근', '10시대 출근 (지각/유연근무)', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
|
||||||
TIER_BRONZE, '🕙'),
|
TIER_BRONZE, '🕙'),
|
||||||
('slot_in_11', tr('achieve.slot_in_11.name'), tr('achieve.slot_in_11.desc'), 1,
|
('slot_in_11', '11시대 출근', '11시대 출근 (자조)', 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
|
||||||
TIER_SILVER, '🕦'),
|
TIER_SILVER, '🕦'),
|
||||||
('slot_out_19', tr('achieve.slot_out_19.name'), tr('achieve.slot_out_19.desc'), 10,
|
('slot_out_19', '19시대 퇴근', '19시대 퇴근 10회 (경고)', 10,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
|
||||||
TIER_SILVER, '🌆'),
|
TIER_SILVER, '🌆'),
|
||||||
('slot_out_20', tr('achieve.slot_out_20.name'), tr('achieve.slot_out_20.desc'), 10,
|
('slot_out_20', '20시대 퇴근', '20시대 퇴근 10회 (경고)', 10,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
|
||||||
TIER_GOLD, '🌌'),
|
TIER_GOLD, '🌌'),
|
||||||
('slot_out_21', tr('achieve.slot_out_21.name'), tr('achieve.slot_out_21.desc'), 5,
|
('slot_out_21', '21시대 퇴근', '21시대 퇴근 5회 (경고)', 5,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
|
||||||
TIER_GOLD, '🌑'),
|
TIER_GOLD, '🌑'),
|
||||||
('slot_out_22', tr('achieve.slot_out_22.name'), tr('achieve.slot_out_22.desc'), 1,
|
('slot_out_22', '22시대 퇴근', '22시대 퇴근 1회 (경고)', 1,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
|
||||||
TIER_PLATINUM, '🦉'),
|
TIER_PLATINUM, '🦉'),
|
||||||
('slot_out_23', tr('achieve.slot_out_23.name'), tr('achieve.slot_out_23.desc'), 1,
|
('slot_out_23', '23시대 퇴근', '23시대 퇴근 1회 (경고)', 1,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
|
||||||
TIER_PLATINUM, '🦇'),
|
TIER_PLATINUM, '🦇'),
|
||||||
('slot_midnight', tr('achieve.slot_midnight.name'), tr('achieve.slot_midnight.desc'), 1,
|
('slot_midnight', '자정 퇴근', '자정 이후 퇴근 (경고)', 1,
|
||||||
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
|
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
|
||||||
('slot_midnight_3', tr('achieve.slot_midnight_3.name'), tr('achieve.slot_midnight_3.desc'), 3,
|
('slot_midnight_3', '올빼미 트리오', '자정 이후 퇴근 3회 (경고)', 3,
|
||||||
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
|
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -693,50 +692,50 @@ def _count_clockouts_in_hour(db, hour: int) -> int:
|
|||||||
|
|
||||||
# ---- 10. 공휴일·주말 ----
|
# ---- 10. 공휴일·주말 ----
|
||||||
_SPECIAL_DAY_DEFS = [
|
_SPECIAL_DAY_DEFS = [
|
||||||
('weekend_1', tr('achieve.weekend_1.name'), tr('achieve.weekend_1.desc'), 1,
|
('weekend_1', '주말 출근 1회', '토/일 출근 1회', 1,
|
||||||
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
|
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
|
||||||
('weekend_5', tr('achieve.weekend_5.name'), tr('achieve.weekend_5.desc'), 5,
|
('weekend_5', '주말 워커', '주말 출근 5회 (경고)', 5,
|
||||||
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
|
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
|
||||||
('weekend_20', tr('achieve.weekend_20.name'), tr('achieve.weekend_20.desc'), 20,
|
('weekend_20', '진짜 워크홀릭', '주말 출근 20회 (강한 자조)', 20,
|
||||||
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
|
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
|
||||||
('holiday_1', tr('achieve.holiday_1.name'), tr('achieve.holiday_1.desc'), 1,
|
('holiday_1', '공휴일 출근', '한국 공휴일 출근 1회', 1,
|
||||||
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
|
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
|
||||||
('holiday_5', tr('achieve.holiday_5.name'), tr('achieve.holiday_5.desc'), 5,
|
('holiday_5', '공휴일 워커홀릭', '한국 공휴일 출근 5회 (경고)', 5,
|
||||||
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
|
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
|
||||||
('day_christmas', tr('achieve.day_christmas.name'), tr('achieve.day_christmas.desc'), 1,
|
('day_christmas', '크리스마스 출근', '12/25 출근 (자조)', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
|
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
|
||||||
('day_newyear', tr('achieve.day_newyear.name'), tr('achieve.day_newyear.desc'), 1,
|
('day_newyear', '신정 출근', '1/1 출근 (자조)', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
|
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
|
||||||
('day_liberation', tr('achieve.day_liberation.name'), tr('achieve.day_liberation.desc'), 1,
|
('day_liberation', '광복절 출근', '8/15 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
|
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
|
||||||
('day_children', tr('achieve.day_children.name'), tr('achieve.day_children.desc'), 1,
|
('day_children', '어린이날 출근', '5/5 출근 (자조)', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
|
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
|
||||||
('day_hangul', tr('achieve.day_hangul.name'), tr('achieve.day_hangul.desc'), 1,
|
('day_hangul', '한글날 출근', '10/9 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
|
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
|
||||||
('day_valentine', tr('achieve.day_valentine.name'), tr('achieve.day_valentine.desc'), 1,
|
('day_valentine', '발렌타인데이 출근', '2/14 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
|
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
|
||||||
('day_white', tr('achieve.day_white.name'), tr('achieve.day_white.desc'), 1,
|
('day_white', '화이트데이 출근', '3/14 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
|
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
|
||||||
('day_pepero', tr('achieve.day_pepero.name'), tr('achieve.day_pepero.desc'), 1,
|
('day_pepero', '빼빼로데이', '11/11 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
|
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
|
||||||
('day_halloween', tr('achieve.day_halloween.name'), tr('achieve.day_halloween.desc'), 1,
|
('day_halloween', '핼러윈 출근', '10/31 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
|
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
|
||||||
('day_aprilfools', tr('achieve.day_aprilfools.name'), tr('achieve.day_aprilfools.desc'), 1,
|
('day_aprilfools', '만우절 출근', '4/1 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
|
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
|
||||||
('day_77', tr('achieve.day_77.name'), tr('achieve.day_77.desc'), 1,
|
('day_77', '칠월칠석', '7/7 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
|
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
|
||||||
('day_dongji', tr('achieve.day_dongji.name'), tr('achieve.day_dongji.desc'), 1,
|
('day_dongji', '동지 출근', '12/22 출근', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
|
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
|
||||||
('day_parents', tr('achieve.day_parents.name'), tr('achieve.day_parents.desc'), 1,
|
('day_parents', '어버이날 정시 퇴근', '5/8 정시 퇴근', 1,
|
||||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
|
||||||
TIER_SILVER, '🪅'),
|
TIER_SILVER, '🪅'),
|
||||||
('day_teacher', tr('achieve.day_teacher.name'), tr('achieve.day_teacher.desc'), 1,
|
('day_teacher', '스승의 날 정시 퇴근', '5/15 정시 퇴근', 1,
|
||||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
|
||||||
TIER_BRONZE, '🎂'),
|
TIER_BRONZE, '🎂'),
|
||||||
('day_xmas_eve', tr('achieve.day_xmas_eve.name'), tr('achieve.day_xmas_eve.desc'), 1,
|
('day_xmas_eve', '크리스마스이브 정시 퇴근', '12/24 정시 퇴근', 1,
|
||||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
|
||||||
TIER_SILVER, '🎁'),
|
TIER_SILVER, '🎁'),
|
||||||
('day_earth', tr('achieve.day_earth.name'), tr('achieve.day_earth.desc'), 1,
|
('day_earth', '지구의 날', '4/22 출근 (시크릿)', 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
|
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -761,79 +760,79 @@ def _make_month_first_eval(month: int):
|
|||||||
|
|
||||||
|
|
||||||
_SEASON_DEFS = [
|
_SEASON_DEFS = [
|
||||||
('season_jan', tr('achieve.season_jan.name'), tr('achieve.season_jan.desc'), 1,
|
('season_jan', '1월 정착', '1월 한 달 출근', 1,
|
||||||
_make_month_first_eval(1), TIER_BRONZE, '⛄'),
|
_make_month_first_eval(1), TIER_BRONZE, '⛄'),
|
||||||
('season_feb', tr('achieve.season_feb.name'), tr('achieve.season_feb.desc'), 1,
|
('season_feb', '2월 정착', '2월 영업일 모두 출근', 1,
|
||||||
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
|
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
|
||||||
('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1,
|
('season_mar', '봄을 맞이', '3월 첫 출근', 1,
|
||||||
_make_month_first_eval(3), TIER_BRONZE, '🌸'),
|
_make_month_first_eval(3), TIER_BRONZE, '🌸'),
|
||||||
('season_apr', tr('achieve.season_apr.name'), tr('achieve.season_apr.desc'), 1,
|
('season_apr', '4월 정착', '4월 한 달 출근', 1,
|
||||||
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
|
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
|
||||||
('season_may', tr('achieve.season_may.name'), tr('achieve.season_may.desc'), 1,
|
('season_may', '5월 정착', '5월 영업일 모두 출근', 1,
|
||||||
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
|
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
|
||||||
('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1,
|
('season_jun', '여름의 시작', '6월 첫 출근', 1,
|
||||||
_make_month_first_eval(6), TIER_BRONZE, '☀️'),
|
_make_month_first_eval(6), TIER_BRONZE, '☀️'),
|
||||||
('season_jul', tr('achieve.season_jul.name'), tr('achieve.season_jul.desc'), 1,
|
('season_jul', '7월 정착', '7월 한 달 출근', 1,
|
||||||
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
|
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
|
||||||
('season_aug', tr('achieve.season_aug.name'), tr('achieve.season_aug.desc'), 1,
|
('season_aug', '8월 정착', '8월 영업일 모두 출근', 1,
|
||||||
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
|
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
|
||||||
('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1,
|
('season_sep', '가을의 시작', '9월 첫 출근', 1,
|
||||||
_make_month_first_eval(9), TIER_BRONZE, '🍂'),
|
_make_month_first_eval(9), TIER_BRONZE, '🍂'),
|
||||||
('season_oct', tr('achieve.season_oct.name'), tr('achieve.season_oct.desc'), 1,
|
('season_oct', '10월 정착', '10월 한 달 출근', 1,
|
||||||
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
|
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
|
||||||
('season_nov', tr('achieve.season_nov.name'), tr('achieve.season_nov.desc'), 1,
|
('season_nov', '11월 단풍', '11월 영업일 모두 출근', 1,
|
||||||
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
|
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
|
||||||
('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1,
|
('season_dec', '겨울의 시작', '12월 첫 출근', 1,
|
||||||
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 12. 앱 사용 마일스톤 ----
|
# ---- 12. 앱 사용 마일스톤 ----
|
||||||
_MILESTONE_DEFS = [
|
_MILESTONE_DEFS = [
|
||||||
('mile_first', tr('achieve.mile_first.name'), tr('achieve.mile_first.desc'), 1,
|
('mile_first', 'Hello, World!', '앱 첫 실행', 1,
|
||||||
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
|
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
|
||||||
TIER_BRONZE, '👋'),
|
TIER_BRONZE, '👋'),
|
||||||
('mile_7days', tr('achieve.mile_7days.name'), tr('achieve.mile_7days.desc'), 7,
|
('mile_7days', '일주일 사용', '7일 사용', 7,
|
||||||
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
|
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
|
||||||
('mile_30days', tr('achieve.mile_30days.name'), tr('achieve.mile_30days.desc'), 30,
|
('mile_30days', '한 달 사용', '30일 사용', 30,
|
||||||
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
|
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
|
||||||
('mile_365days', tr('achieve.mile_365days.name'), tr('achieve.mile_365days.desc'), 365,
|
('mile_365days', '1주년', '365일 사용', 365,
|
||||||
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
|
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
|
||||||
('mile_730days', tr('achieve.mile_730days.name'), tr('achieve.mile_730days.desc'), 730,
|
('mile_730days', '2주년', '730일 사용', 730,
|
||||||
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
|
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
|
||||||
('mile_1095days', tr('achieve.mile_1095days.name'), tr('achieve.mile_1095days.desc'), 1095,
|
('mile_1095days', '3주년', '3년 사용', 1095,
|
||||||
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
|
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
|
||||||
('mile_5years', tr('achieve.mile_5years.name'), tr('achieve.mile_5years.desc'), 1825,
|
('mile_5years', '5년 사용자', '5년 사용', 1825,
|
||||||
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
|
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
|
||||||
('mile_10years', tr('achieve.mile_10years.name'), tr('achieve.mile_10years.desc'), 3650,
|
('mile_10years', '10년 사용자', '10년 사용', 3650,
|
||||||
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
|
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 13. 통계·분석 (view counter 기반) ----
|
# ---- 13. 통계·분석 (view counter 기반) ----
|
||||||
_STATS_DEFS = [
|
_STATS_DEFS = [
|
||||||
('stat_weekly_10', tr('achieve.stat_weekly_10.name'), tr('achieve.stat_weekly_10.desc'), 10,
|
('stat_weekly_10', '주간 통계러', '주간 탭 10회 조회', 10,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
|
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
|
||||||
TIER_BRONZE, '📊'),
|
TIER_BRONZE, '📊'),
|
||||||
('stat_monthly_10', tr('achieve.stat_monthly_10.name'), tr('achieve.stat_monthly_10.desc'), 10,
|
('stat_monthly_10', '월간 통계러', '월간 탭 10회', 10,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
|
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
|
||||||
TIER_BRONZE, '📈'),
|
TIER_BRONZE, '📈'),
|
||||||
('stat_pattern_10', tr('achieve.stat_pattern_10.name'), tr('achieve.stat_pattern_10.desc'), 10,
|
('stat_pattern_10', '패턴 분석가', '패턴 탭 10회', 10,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
|
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
|
||||||
TIER_SILVER, '🔍'),
|
TIER_SILVER, '🔍'),
|
||||||
('stat_calendar_30', tr('achieve.stat_calendar_30.name'), tr('achieve.stat_calendar_30.desc'), 30,
|
('stat_calendar_30', '캘린더 챔프', '캘린더 30회 조회', 30,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
|
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
|
||||||
TIER_SILVER, '📅'),
|
TIER_SILVER, '📅'),
|
||||||
('stat_report_first', tr('achieve.stat_report_first.name'), tr('achieve.stat_report_first.desc'), 1,
|
('stat_report_first', '일일 보고서 첫 생성', '일일 보고 1회', 1,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
|
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
|
||||||
TIER_BRONZE, '📋'),
|
TIER_BRONZE, '📋'),
|
||||||
('stat_report_30', tr('achieve.stat_report_30.name'), tr('achieve.stat_report_30.desc'), 30,
|
('stat_report_30', '보고서 챔프', '일일 보고 30회', 30,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
|
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
|
||||||
TIER_SILVER, '📰'),
|
TIER_SILVER, '📰'),
|
||||||
('stat_chart_hover', tr('achieve.stat_chart_hover.name'), tr('achieve.stat_chart_hover.desc'), 1,
|
('stat_chart_hover', '차트 호버 발견', '차트 hover 첫 발견', 1,
|
||||||
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
|
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
|
||||||
TIER_BRONZE, '🎨'),
|
TIER_BRONZE, '🎨'),
|
||||||
('stat_achievements_open', tr('achieve.stat_achievements_open.name'), tr('achieve.stat_achievements_open.desc'), 50,
|
('stat_achievements_open', '도전과제 박물관', '도전과제 뷰 50회', 50,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
|
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
|
||||||
TIER_BRONZE, '🦄'),
|
TIER_BRONZE, '🦄'),
|
||||||
]
|
]
|
||||||
@ -958,23 +957,23 @@ def _has_500_anniv_clockin(db) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_SECRET_DEFS = [
|
_SECRET_DEFS = [
|
||||||
('secret_palindrome', tr('achieve.secret_palindrome.name'), tr('achieve.secret_palindrome.desc'), 1,
|
('secret_palindrome', '회문 시각', '출근 시각이 회문', 1,
|
||||||
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
|
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
|
||||||
('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1,
|
('secret_jackpot', '잭팟 시각', '출근 시각 모든 자릿수 동일', 1,
|
||||||
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
|
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
|
||||||
('secret_fri13', tr('achieve.secret_fri13.name'), tr('achieve.secret_fri13.desc'), 1,
|
('secret_fri13', '13일 금요일', '13일 금요일 출근', 1,
|
||||||
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
|
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
|
||||||
('secret_777', tr('achieve.secret_777.name'), tr('achieve.secret_777.desc'), 1,
|
('secret_777', '7-7-7', '7월 7일 7시 7분 출근', 1,
|
||||||
_bool_eval(_has_777), TIER_LEGEND, '🔮'),
|
_bool_eval(_has_777), TIER_LEGEND, '🔮'),
|
||||||
('secret_exact_8h', tr('achieve.secret_exact_8h.name'), tr('achieve.secret_exact_8h.desc'), 1,
|
('secret_exact_8h', '정확 8시간', '정확히 8h 0m 근무', 1,
|
||||||
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
|
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
|
||||||
('secret_pi_day', tr('achieve.secret_pi_day.name'), tr('achieve.secret_pi_day.desc'), 1,
|
('secret_pi_day', '파이 데이', '3/14 01:59 출근', 1,
|
||||||
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
|
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
|
||||||
('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1,
|
('secret_fibonacci', '피보나치', '출근 분이 피보나치 수', 1,
|
||||||
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
|
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
|
||||||
('secret_double_six', tr('achieve.secret_double_six.name'), tr('achieve.secret_double_six.desc'), 1,
|
('secret_double_six', '더블 식스', '6/6 18:06 출근', 1,
|
||||||
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
|
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
|
||||||
('secret_anniversary', tr('achieve.secret_anniversary.name'), tr('achieve.secret_anniversary.desc'), 1,
|
('secret_anniversary', '마법사', '가입 후 정확히 365일 후 출근', 1,
|
||||||
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
|
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -985,30 +984,30 @@ def _setting_changed_from_default(db, key: str, default_value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_SETTINGS_DEFS = [
|
_SETTINGS_DEFS = [
|
||||||
('set_dark', tr('achieve.set_dark.name'), tr('achieve.set_dark.desc'), 1,
|
('set_dark', '다크 사이드', '다크 테마 1회 사용', 1,
|
||||||
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
|
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
|
||||||
TIER_BRONZE, '🌗'),
|
TIER_BRONZE, '🌗'),
|
||||||
('set_lang', tr('achieve.set_lang.name'), tr('achieve.set_lang.desc'), 1,
|
('set_lang', '이중언어', '언어 변경 (en 사용)', 1,
|
||||||
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
|
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
|
||||||
TIER_BRONZE, '🌐'),
|
TIER_BRONZE, '🌐'),
|
||||||
('set_a11y', tr('achieve.set_a11y.name'), tr('achieve.set_a11y.desc'), 1,
|
('set_a11y', '접근성 활용', '글꼴 크기≠100% 또는 고대비 ON', 1,
|
||||||
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
|
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
|
||||||
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
||||||
TIER_BRONZE, '♿'),
|
TIER_BRONZE, '♿'),
|
||||||
('set_overtime_unit', tr('achieve.set_overtime_unit.name'), tr('achieve.set_overtime_unit.desc'), 1,
|
('set_overtime_unit', '단위 변경', 'overtime_unit 변경', 1,
|
||||||
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
|
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
|
||||||
TIER_BRONZE, '⏱️'),
|
TIER_BRONZE, '⏱️'),
|
||||||
('set_goal_full', tr('achieve.set_goal_full.name'), tr('achieve.set_goal_full.desc'), 1,
|
('set_goal_full', '목표 마스터', '월 연장+일평균 둘 다 설정', 1,
|
||||||
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
|
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
|
||||||
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
||||||
TIER_SILVER, '🎯'),
|
TIER_SILVER, '🎯'),
|
||||||
('set_discord_full', tr('achieve.set_discord_full.name'), tr('achieve.set_discord_full.desc'), 1,
|
('set_discord_full', '풀 셋업', 'Discord URL + 모든 알림 ON', 1,
|
||||||
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
|
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
|
||||||
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
||||||
('notification_clock_out', 'notification_lunch',
|
('notification_clock_out', 'notification_lunch',
|
||||||
'notification_overtime', 'notification_health'))),
|
'notification_overtime', 'notification_health'))),
|
||||||
TIER_SILVER, '🔔'),
|
TIER_SILVER, '🔔'),
|
||||||
('set_cloud', tr('achieve.set_cloud.name'), tr('achieve.set_cloud.desc'), 1,
|
('set_cloud', '클라우드 동기화', 'DB 경로 변경', 1,
|
||||||
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
|
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
|
||||||
TIER_SILVER, '☁️'),
|
TIER_SILVER, '☁️'),
|
||||||
]
|
]
|
||||||
@ -1033,21 +1032,21 @@ def _earned_secret_count(db) -> int:
|
|||||||
|
|
||||||
|
|
||||||
_META_DEFS = [
|
_META_DEFS = [
|
||||||
('meta_first', tr('achieve.meta_first.name'), tr('achieve.meta_first.desc'), 1,
|
('meta_first', '첫 도전과제', '첫 도전과제 획득', 1,
|
||||||
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
|
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
|
||||||
('meta_10', tr('achieve.meta_10.name'), tr('achieve.meta_10.desc'), 10,
|
('meta_10', '10개 달성', '10개 보유', 10,
|
||||||
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
|
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
|
||||||
('meta_25', tr('achieve.meta_25.name'), tr('achieve.meta_25.desc'), 25,
|
('meta_25', '25개 달성', '25개 보유', 25,
|
||||||
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
|
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
|
||||||
('meta_50', tr('achieve.meta_50.name'), tr('achieve.meta_50.desc'), 50,
|
('meta_50', '50개 달성', '50개 보유', 50,
|
||||||
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
|
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
|
||||||
('meta_75', tr('achieve.meta_75.name'), tr('achieve.meta_75.desc'), 75,
|
('meta_75', '75개 달성', '75개 보유', 75,
|
||||||
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
|
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
|
||||||
('meta_100', tr('achieve.meta_100.name'), tr('achieve.meta_100.desc'), 100,
|
('meta_100', '100개 달성', '100개 보유', 100,
|
||||||
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
|
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
|
||||||
('meta_secret_1', tr('achieve.meta_secret_1.name'), tr('achieve.meta_secret_1.desc'), 1,
|
('meta_secret_1', '시크릿 발견', '첫 시크릿 발견', 1,
|
||||||
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
|
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
|
||||||
('meta_secret_5', tr('achieve.meta_secret_5.name'), tr('achieve.meta_secret_5.desc'), 5,
|
('meta_secret_5', '시크릿 헌터', '시크릿 5개 발견', 5,
|
||||||
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
|
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ from core.settings_keys import (
|
|||||||
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
|
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
|
||||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||||
)
|
)
|
||||||
from utils.debug_log import dlog
|
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
@ -54,8 +53,8 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
dlog(f"connection close failed: {e}")
|
pass
|
||||||
|
|
||||||
def _enable_concurrency(self):
|
def _enable_concurrency(self):
|
||||||
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
|
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
|
||||||
@ -119,8 +118,7 @@ class Database:
|
|||||||
sentinel = self.get_setting('holidays_synced_date', '')
|
sentinel = self.get_setting('holidays_synced_date', '')
|
||||||
if sentinel == today:
|
if sentinel == today:
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception:
|
||||||
dlog(f"holiday sync precheck failed: {e}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
cur_year = _dt.now().year
|
cur_year = _dt.now().year
|
||||||
@ -133,8 +131,8 @@ class Database:
|
|||||||
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
|
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
|
||||||
if added >= 0:
|
if added >= 0:
|
||||||
db.set_setting('holidays_synced_date', today)
|
db.set_setting('holidays_synced_date', today)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
dlog(f"holiday sync worker failed: {e}")
|
pass
|
||||||
|
|
||||||
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
||||||
t.start()
|
t.start()
|
||||||
@ -1072,8 +1070,7 @@ class Database:
|
|||||||
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
||||||
recs = self.get_recurring_leaves(active_on=date_str)
|
recs = self.get_recurring_leaves(active_on=date_str)
|
||||||
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
dlog(f"recurring leave expansion failed: {e}")
|
|
||||||
recurring_days = 0.0
|
recurring_days = 0.0
|
||||||
|
|
||||||
return concrete_days + recurring_days
|
return concrete_days + recurring_days
|
||||||
@ -1359,14 +1356,10 @@ class Database:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
key = row['key']
|
key = row['key']
|
||||||
value = row['value']
|
value = row['value']
|
||||||
# 타입 변환 (bool 우선, 숫자, 문자열 순)
|
# 타입 변환
|
||||||
lower = (value or '').lower()
|
if value.lower() in ['true', 'false']:
|
||||||
if lower in ('1', 'true', 'yes', 'on'):
|
settings[key] = value.lower() == 'true'
|
||||||
settings[key] = True
|
else:
|
||||||
continue
|
|
||||||
if lower in ('0', 'false', 'no', 'off', ''):
|
|
||||||
settings[key] = False
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
settings[key] = int(value)
|
settings[key] = int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -1395,8 +1388,7 @@ class Database:
|
|||||||
pass
|
pass
|
||||||
elif 'work_hours' in synced and 'work_minutes' not in synced:
|
elif 'work_hours' in synced and 'work_minutes' not in synced:
|
||||||
try:
|
try:
|
||||||
# 은행 반올림 회피: 명시적 반올림
|
synced['work_minutes'] = int(round(float(synced['work_hours']) * 60))
|
||||||
synced['work_minutes'] = int(float(synced['work_hours']) * 60 + 0.5)
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -1682,8 +1674,7 @@ class Database:
|
|||||||
''', (date, leave_type, days, memo))
|
''', (date, leave_type, days, memo))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
|
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
|
||||||
# get_leave_balance()가 leave_records를 실시간으로 계산하므로
|
self.set_leave_balance(current_balance - days)
|
||||||
# 별도로 leave_balance 설정값을 갱신할 필요가 없음.
|
|
||||||
|
|
||||||
# ===== 공휴일 관련 메서드 =====
|
# ===== 공휴일 관련 메서드 =====
|
||||||
|
|
||||||
@ -1847,8 +1838,7 @@ class Database:
|
|||||||
try:
|
try:
|
||||||
import holidays as _holidays
|
import holidays as _holidays
|
||||||
kr = _holidays.country_holidays('KR', years=y)
|
kr = _holidays.country_holidays('KR', years=y)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
dlog(f"holidays package fallback failed for {y}: {e}")
|
|
||||||
continue # 둘 다 실패면 해당 연도만 스킵
|
continue # 둘 다 실패면 해당 연도만 스킵
|
||||||
for d, name in kr.items():
|
for d, name in kr.items():
|
||||||
date_str = d.isoformat()
|
date_str = d.isoformat()
|
||||||
|
|||||||
1830
core/i18n.py
1830
core/i18n.py
File diff suppressed because it is too large
Load Diff
@ -15,8 +15,6 @@ from dataclasses import dataclass
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
|
|
||||||
|
|
||||||
_WEEKDAY_MAP = {
|
_WEEKDAY_MAP = {
|
||||||
'mon': 0, 'monday': 0,
|
'mon': 0, 'monday': 0,
|
||||||
@ -27,7 +25,6 @@ _WEEKDAY_MAP = {
|
|||||||
'sat': 5, 'saturday': 5,
|
'sat': 5, 'saturday': 5,
|
||||||
'sun': 6, 'sunday': 6,
|
'sun': 6, 'sunday': 6,
|
||||||
}
|
}
|
||||||
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -138,16 +135,19 @@ def _parse_date(s: Optional[str]) -> Optional[date]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
|
|
||||||
|
|
||||||
def describe_pattern(pattern: str) -> str:
|
def describe_pattern(pattern: str) -> str:
|
||||||
"""사용자에게 보여줄 패턴 설명."""
|
"""사용자에게 보여줄 패턴 설명. ko."""
|
||||||
parsed = _parse_pattern(pattern)
|
parsed = _parse_pattern(pattern)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return pattern
|
return pattern
|
||||||
kind, info = parsed
|
kind, info = parsed
|
||||||
if kind in ('weekly', 'biweekly'):
|
if kind in ('weekly', 'biweekly'):
|
||||||
names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info]
|
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
||||||
prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly')
|
prefix = '매주' if kind == 'weekly' else '격주'
|
||||||
return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names))
|
return f"{prefix} {','.join(names)}요일"
|
||||||
if kind == 'monthly':
|
if kind == 'monthly':
|
||||||
return tr('recurring.pattern_monthly', day=info)
|
return f"매월 {info}일"
|
||||||
return pattern
|
return pattern
|
||||||
|
|||||||
@ -21,8 +21,7 @@ class TimeCalculator:
|
|||||||
if work_minutes is not None:
|
if work_minutes is not None:
|
||||||
self.work_minutes = int(work_minutes)
|
self.work_minutes = int(work_minutes)
|
||||||
elif work_hours is not None:
|
elif work_hours is not None:
|
||||||
# 은행 반올림(banker's rounding) 회피: 6.5시간 → 390분이 되도록 명시적 반올림
|
self.work_minutes = int(round(float(work_hours) * 60))
|
||||||
self.work_minutes = int(float(work_hours) * 60 + 0.5)
|
|
||||||
else:
|
else:
|
||||||
self.work_minutes = 480
|
self.work_minutes = 480
|
||||||
|
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.12.0'
|
__version__ = '2.11.2'
|
||||||
|
|||||||
@ -86,12 +86,12 @@ OkMsg "All checks passed (Version: $Version)"
|
|||||||
# ====== 1. Bump version.py ======
|
# ====== 1. Bump version.py ======
|
||||||
Step "1/7 Bump core/version.py"
|
Step "1/7 Bump core/version.py"
|
||||||
$verFile = 'core/version.py'
|
$verFile = 'core/version.py'
|
||||||
$verContent = [System.IO.File]::ReadAllText((Resolve-Path $verFile).Path, [System.Text.Encoding]::UTF8)
|
$verContent = Get-Content $verFile -Raw
|
||||||
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
|
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
|
||||||
if ($verContent -eq $newContent) {
|
if ($verContent -eq $newContent) {
|
||||||
Info "Already at $VersionRaw (no change)"
|
Info "Already at $VersionRaw (no change)"
|
||||||
} else {
|
} else {
|
||||||
if (-not $DryRun) { [System.IO.File]::WriteAllText((Resolve-Path $verFile).Path, $newContent, [System.Text.Encoding]::UTF8) }
|
if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline }
|
||||||
OkMsg "$verFile -> $VersionRaw"
|
OkMsg "$verFile -> $VersionRaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,15 +4,13 @@ utils.csv_importer 단위 테스트.
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records
|
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
||||||
from core.database import Database
|
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeTime:
|
class TestNormalizeTime:
|
||||||
@ -127,54 +125,3 @@ class TestParseCsv:
|
|||||||
assert '줄 3' in str(exc.value)
|
assert '줄 3' in str(exc.value)
|
||||||
finally:
|
finally:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
class TestImportRecords:
|
|
||||||
def _db(self):
|
|
||||||
p = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
|
||||||
p.close()
|
|
||||||
db = Database(p.name)
|
|
||||||
db.save_settings({
|
|
||||||
'work_minutes': '480',
|
|
||||||
'lunch_duration_minutes': '60',
|
|
||||||
'dinner_duration_minutes': '60',
|
|
||||||
})
|
|
||||||
return db, p.name
|
|
||||||
|
|
||||||
def test_overwrite_clears_overtime_usage(self):
|
|
||||||
"""CSV 덮어쓰기 시 overtime_usage도 삭제되어 잔액이 일관성을 유지해야 함."""
|
|
||||||
db, path = self._db()
|
|
||||||
try:
|
|
||||||
date_str = '2026-04-01'
|
|
||||||
# 기존 기록 + 연장근무 사용 기록 생성
|
|
||||||
wid = db.add_work_record(date_str, '09:00:00')
|
|
||||||
db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120)
|
|
||||||
db.add_overtime_earned(wid, 120, date_str)
|
|
||||||
db.add_overtime_usage(wid, 30, date_str, '테스트')
|
|
||||||
|
|
||||||
# 덮어쓰기 전 잔액
|
|
||||||
balance_before = db.get_total_overtime_balance()
|
|
||||||
assert balance_before == 90 # 120 적립 - 30 사용
|
|
||||||
|
|
||||||
rows = [{
|
|
||||||
'date': date_str,
|
|
||||||
'clock_in': '09:00:00',
|
|
||||||
'clock_out': '18:00:00',
|
|
||||||
'lunch_minutes': 60,
|
|
||||||
'dinner_minutes': 0,
|
|
||||||
'memo': '',
|
|
||||||
}]
|
|
||||||
import_records(db, rows, on_conflict='overwrite')
|
|
||||||
|
|
||||||
# 덮어쓰기 후 연장근무 사용 기록은 삭제되어야 함
|
|
||||||
with db._conn() as conn:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,))
|
|
||||||
assert cur.fetchone()[0] == 0
|
|
||||||
balance_after = db.get_total_overtime_balance()
|
|
||||||
assert balance_after == 0 # 새 기록은 연장근무 없음
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.remove(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|||||||
@ -162,12 +162,5 @@ class TestFetchNetwork:
|
|||||||
|
|
||||||
|
|
||||||
class TestConfigured:
|
class TestConfigured:
|
||||||
def test_key_set(self, monkeypatch):
|
def test_key_set(self):
|
||||||
import utils.holiday_api as _ha
|
assert is_configured() is True
|
||||||
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,7 +16,6 @@ from PyQt5.QtCore import Qt
|
|||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
|
|
||||||
from core.achievements import get_all_with_status, get_stats
|
from core.achievements import get_all_with_status, get_stats
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||||
|
|
||||||
@ -30,6 +29,7 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#241810',
|
'bg_bot': '#241810',
|
||||||
'text': '#ffd9a8',
|
'text': '#ffd9a8',
|
||||||
'label': '🥉',
|
'label': '🥉',
|
||||||
|
'name': '브론즈',
|
||||||
},
|
},
|
||||||
'silver': {
|
'silver': {
|
||||||
'border': '#a8a8a8',
|
'border': '#a8a8a8',
|
||||||
@ -38,6 +38,7 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#1c1c22',
|
'bg_bot': '#1c1c22',
|
||||||
'text': '#e8e8f0',
|
'text': '#e8e8f0',
|
||||||
'label': '🥈',
|
'label': '🥈',
|
||||||
|
'name': '실버',
|
||||||
},
|
},
|
||||||
'gold': {
|
'gold': {
|
||||||
'border': '#ffb700',
|
'border': '#ffb700',
|
||||||
@ -46,6 +47,7 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#241c08',
|
'bg_bot': '#241c08',
|
||||||
'text': '#ffe9a0',
|
'text': '#ffe9a0',
|
||||||
'label': '🥇',
|
'label': '🥇',
|
||||||
|
'name': '골드',
|
||||||
},
|
},
|
||||||
'platinum': {
|
'platinum': {
|
||||||
'border': '#7fdbff',
|
'border': '#7fdbff',
|
||||||
@ -54,6 +56,7 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#0e1f28',
|
'bg_bot': '#0e1f28',
|
||||||
'text': '#c5ecff',
|
'text': '#c5ecff',
|
||||||
'label': '💎',
|
'label': '💎',
|
||||||
|
'name': '플래티넘',
|
||||||
},
|
},
|
||||||
'legend': {
|
'legend': {
|
||||||
'border': '#ff6b9d',
|
'border': '#ff6b9d',
|
||||||
@ -62,9 +65,20 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#26101a',
|
'bg_bot': '#26101a',
|
||||||
'text': '#ffc0d4',
|
'text': '#ffc0d4',
|
||||||
'label': '🌟',
|
'label': '🌟',
|
||||||
|
'name': '레전드',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
||||||
|
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
||||||
|
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
||||||
|
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
||||||
|
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
||||||
|
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
||||||
|
'ambition': '야망', 'meta': '메타',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AchievementsView(QDialog):
|
class AchievementsView(QDialog):
|
||||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||||
@ -72,7 +86,7 @@ class AchievementsView(QDialog):
|
|||||||
def __init__(self, db, parent=None):
|
def __init__(self, db, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle(tr('achieve.title'))
|
self.setWindowTitle("도전과제")
|
||||||
self.setMinimumSize(960, 720)
|
self.setMinimumSize(960, 720)
|
||||||
self.resize(1100, 800)
|
self.resize(1100, 800)
|
||||||
self._increment_view_count()
|
self._increment_view_count()
|
||||||
@ -107,21 +121,21 @@ class AchievementsView(QDialog):
|
|||||||
if a['earned_date'] is None and not a['is_secret']]
|
if a['earned_date'] is None and not a['is_secret']]
|
||||||
secret_items = [a for a in all_items if a['is_secret']]
|
secret_items = [a for a in all_items if a['is_secret']]
|
||||||
|
|
||||||
self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
|
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
||||||
self.tabs.addTab(self._build_grid_tab(in_progress),
|
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||||
tr('achieve.tab_in_progress', count=len(in_progress)))
|
f"⚡ 진행 중 · {len(in_progress)}")
|
||||||
self.tabs.addTab(self._build_grid_tab(earned_items),
|
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||||
tr('achieve.tab_completed', count=len(earned_items)))
|
f"✓ 완료 · {len(earned_items)}")
|
||||||
self.tabs.addTab(
|
self.tabs.addTab(
|
||||||
self._build_grid_tab(secret_items, secret_mode=True),
|
self._build_grid_tab(secret_items, secret_mode=True),
|
||||||
tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total'])
|
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
|
||||||
)
|
)
|
||||||
layout.addWidget(self.tabs, 1)
|
layout.addWidget(self.tabs, 1)
|
||||||
|
|
||||||
# === 닫기 버튼 ===
|
# === 닫기 버튼 ===
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
close_btn = QPushButton(tr('btn.close'))
|
close_btn = QPushButton("닫기")
|
||||||
close_btn.setMinimumWidth(100)
|
close_btn.setMinimumWidth(100)
|
||||||
close_btn.setStyleSheet(button_qss('default'))
|
close_btn.setStyleSheet(button_qss('default'))
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
@ -169,7 +183,7 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
secret_lbl = QLabel(
|
secret_lbl = QLabel(
|
||||||
f"<div style='line-height: 1.3;'>"
|
f"<div style='line-height: 1.3;'>"
|
||||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
|
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 시크릿</span><br>"
|
||||||
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||||
f"{stats['secret_earned']}</span>"
|
f"{stats['secret_earned']}</span>"
|
||||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||||
@ -182,7 +196,7 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
pct_lbl = QLabel(
|
pct_lbl = QLabel(
|
||||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
|
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>달성률</span><br>"
|
||||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||||
f"{pct:.1f}%</span></div>"
|
f"{pct:.1f}%</span></div>"
|
||||||
)
|
)
|
||||||
@ -228,7 +242,7 @@ class AchievementsView(QDialog):
|
|||||||
grid.setContentsMargins(8, 8, 8, 8)
|
grid.setContentsMargins(8, 8, 8, 8)
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
empty = QLabel(tr('achieve.empty'))
|
empty = QLabel("(아직 없음)")
|
||||||
empty.setAlignment(Qt.AlignCenter)
|
empty.setAlignment(Qt.AlignCenter)
|
||||||
empty.setStyleSheet(
|
empty.setStyleSheet(
|
||||||
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||||
@ -330,9 +344,9 @@ class AchievementsView(QDialog):
|
|||||||
name.setWordWrap(True)
|
name.setWordWrap(True)
|
||||||
name_box.addWidget(name)
|
name_box.addWidget(name)
|
||||||
|
|
||||||
cat_text = tr(f"achieve.cat_{item['category']}")
|
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
|
||||||
if not is_locked_secret:
|
if not is_locked_secret:
|
||||||
cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} · {cat_text} ")
|
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
||||||
cat_label.setStyleSheet(
|
cat_label.setStyleSheet(
|
||||||
f"font-size: 8.5pt; "
|
f"font-size: 8.5pt; "
|
||||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
||||||
@ -354,7 +368,7 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
# 2행: 설명
|
# 2행: 설명
|
||||||
if is_locked_secret:
|
if is_locked_secret:
|
||||||
desc_text = tr('achieve.secret_locked')
|
desc_text = "🔒 달성하면 공개됩니다"
|
||||||
else:
|
else:
|
||||||
desc_text = item['description'] or ''
|
desc_text = item['description'] or ''
|
||||||
desc = QLabel(desc_text)
|
desc = QLabel(desc_text)
|
||||||
@ -367,7 +381,7 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
# 3행: 진행 게이지 또는 획득 일자
|
# 3행: 진행 게이지 또는 획득 일자
|
||||||
if is_earned:
|
if is_earned:
|
||||||
earned = QLabel(tr('achieve.earned_date', date=item['earned_date']))
|
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
||||||
earned.setStyleSheet(
|
earned.setStyleSheet(
|
||||||
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||||
f"font-weight: bold; font-size: 9.5pt; "
|
f"font-weight: bold; font-size: 9.5pt; "
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import os
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from core.database import Database
|
from core.database import Database
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import ThemeColors, apply_dark_titlebar
|
from ui.styles import ThemeColors, apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -38,7 +37,7 @@ class CalendarView(QDialog):
|
|||||||
layout.setContentsMargins(12, 10, 12, 10)
|
layout.setContentsMargins(12, 10, 12, 10)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel(tr('cal.dialog_title'))
|
title = QLabel("월간 근무 기록")
|
||||||
title.setObjectName("dialog_title")
|
title.setObjectName("dialog_title")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignCenter)
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
@ -56,8 +55,8 @@ class CalendarView(QDialog):
|
|||||||
# 범례
|
# 범례
|
||||||
legend_layout = QHBoxLayout()
|
legend_layout = QHBoxLayout()
|
||||||
legend_layout.setSpacing(12)
|
legend_layout.setSpacing(12)
|
||||||
for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')),
|
for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
|
||||||
('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]:
|
('#FAB005', '휴가'), ('#6C6E73', '없음')]:
|
||||||
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||||
_item.setTextFormat(Qt.RichText)
|
_item.setTextFormat(Qt.RichText)
|
||||||
legend_layout.addWidget(_item)
|
legend_layout.addWidget(_item)
|
||||||
@ -65,7 +64,7 @@ class CalendarView(QDialog):
|
|||||||
layout.addLayout(legend_layout)
|
layout.addLayout(legend_layout)
|
||||||
|
|
||||||
# 선택된 날짜 상세 정보
|
# 선택된 날짜 상세 정보
|
||||||
detail_group = QGroupBox(tr('cal.detail_group_title'))
|
detail_group = QGroupBox("선택된 날짜 정보")
|
||||||
detail_layout = QVBoxLayout()
|
detail_layout = QVBoxLayout()
|
||||||
detail_layout.setSpacing(6)
|
detail_layout.setSpacing(6)
|
||||||
detail_layout.setContentsMargins(10, 20, 10, 8)
|
detail_layout.setContentsMargins(10, 20, 10, 8)
|
||||||
@ -79,13 +78,13 @@ class CalendarView(QDialog):
|
|||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setSpacing(6)
|
button_layout.setSpacing(6)
|
||||||
|
|
||||||
self.edit_time_button = QPushButton(tr('cal.edit_time'))
|
self.edit_time_button = QPushButton("시간 수정")
|
||||||
self.edit_time_button.setObjectName("btn_primary")
|
self.edit_time_button.setObjectName("btn_primary")
|
||||||
self.edit_time_button.setEnabled(False)
|
self.edit_time_button.setEnabled(False)
|
||||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||||
button_layout.addWidget(self.edit_time_button)
|
button_layout.addWidget(self.edit_time_button)
|
||||||
|
|
||||||
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
self.delete_record_button = QPushButton("기록 삭제")
|
||||||
self.delete_record_button.setObjectName("btn_danger")
|
self.delete_record_button.setObjectName("btn_danger")
|
||||||
self.delete_record_button.setEnabled(False)
|
self.delete_record_button.setEnabled(False)
|
||||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||||
@ -96,17 +95,17 @@ class CalendarView(QDialog):
|
|||||||
layout.addWidget(detail_group)
|
layout.addWidget(detail_group)
|
||||||
|
|
||||||
# 메모 그룹
|
# 메모 그룹
|
||||||
memo_group = QGroupBox(tr('cal.memo_group'))
|
memo_group = QGroupBox("메모")
|
||||||
memo_layout = QVBoxLayout()
|
memo_layout = QVBoxLayout()
|
||||||
memo_layout.setSpacing(6)
|
memo_layout.setSpacing(6)
|
||||||
memo_layout.setContentsMargins(10, 20, 10, 8)
|
memo_layout.setContentsMargins(10, 20, 10, 8)
|
||||||
|
|
||||||
self.memo_edit = QTextEdit()
|
self.memo_edit = QTextEdit()
|
||||||
self.memo_edit.setMaximumHeight(70)
|
self.memo_edit.setMaximumHeight(70)
|
||||||
self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder'))
|
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||||
memo_layout.addWidget(self.memo_edit)
|
memo_layout.addWidget(self.memo_edit)
|
||||||
|
|
||||||
self.save_memo_button = QPushButton(tr('cal.save_memo'))
|
self.save_memo_button = QPushButton("메모 저장")
|
||||||
self.save_memo_button.setObjectName("btn_primary")
|
self.save_memo_button.setObjectName("btn_primary")
|
||||||
self.save_memo_button.setEnabled(False)
|
self.save_memo_button.setEnabled(False)
|
||||||
self.save_memo_button.clicked.connect(self.save_memo)
|
self.save_memo_button.clicked.connect(self.save_memo)
|
||||||
@ -167,10 +166,10 @@ class CalendarView(QDialog):
|
|||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
edit_action = delete_action = add_action = None
|
edit_action = delete_action = add_action = None
|
||||||
if existing:
|
if existing:
|
||||||
edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
|
edit_action = menu.addAction(f"{date_str} 편집")
|
||||||
delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
|
delete_action = menu.addAction(f"{date_str} 삭제")
|
||||||
else:
|
else:
|
||||||
add_action = menu.addAction(tr('cal.context_add', date=date_str))
|
add_action = menu.addAction(f"{date_str} 기록 추가")
|
||||||
|
|
||||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||||
if action is None:
|
if action is None:
|
||||||
@ -218,9 +217,9 @@ class CalendarView(QDialog):
|
|||||||
if ot_earned > 0:
|
if ot_earned > 0:
|
||||||
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
QMessageBox.information(self, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str))
|
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e))
|
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
|
||||||
|
|
||||||
def _open_edit_dialog(self, date_str: str):
|
def _open_edit_dialog(self, date_str: str):
|
||||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||||
@ -232,8 +231,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
def _delete_record(self, date_str: str):
|
def _delete_record(self, date_str: str):
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
tr('cal.delete_confirm_title'),
|
self, "삭제 확인",
|
||||||
tr('cal.delete_confirm_body', date=date_str),
|
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.Yes:
|
if reply != QMessageBox.Yes:
|
||||||
@ -246,10 +245,10 @@ class CalendarView(QDialog):
|
|||||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
QMessageBox.information(self, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str))
|
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
|
QMessageBox.critical(self, "오류", str(e))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@ -271,33 +270,33 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
if record:
|
if record:
|
||||||
# 상세 정보 표시
|
# 상세 정보 표시
|
||||||
detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
|
detail = f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||||
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
detail += f"출근: {record['clock_in']}\n"
|
||||||
|
|
||||||
if record.get('clock_out'):
|
if record.get('clock_out'):
|
||||||
detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
|
detail += f"퇴근: {record['clock_out']}\n"
|
||||||
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
|
||||||
|
|
||||||
if record.get('lunch_break'):
|
if record.get('lunch_break'):
|
||||||
detail += tr('cal.detail_lunch_used') + '\n'
|
detail += f"점심시간: 사용함\n"
|
||||||
else:
|
else:
|
||||||
detail += tr('cal.detail_lunch_unused') + '\n'
|
detail += f"점심시간: 미사용\n"
|
||||||
|
|
||||||
if record.get('dinner_break'):
|
if record.get('dinner_break'):
|
||||||
detail += tr('cal.detail_dinner_used') + '\n'
|
detail += f"저녁시간: 사용함\n"
|
||||||
else:
|
else:
|
||||||
detail += tr('cal.detail_dinner_unused') + '\n'
|
detail += f"저녁시간: 미사용\n"
|
||||||
|
|
||||||
if record.get('overtime_earned', 0) > 0:
|
if record.get('overtime_earned', 0) > 0:
|
||||||
earned_min = record['overtime_earned']
|
earned_min = record['overtime_earned']
|
||||||
earned_hours = earned_min // 60
|
earned_hours = earned_min // 60
|
||||||
earned_mins = earned_min % 60
|
earned_mins = earned_min % 60
|
||||||
detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n'
|
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n"
|
||||||
else:
|
else:
|
||||||
detail += tr('cal.detail_clock_out_none') + '\n'
|
detail += f"퇴근: 미기록\n"
|
||||||
|
|
||||||
if record.get('memo'):
|
if record.get('memo'):
|
||||||
detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n'
|
detail += f"\n메모: {record['memo']}\n"
|
||||||
|
|
||||||
self.detail_text.setText(detail)
|
self.detail_text.setText(detail)
|
||||||
self.edit_time_button.setEnabled(True)
|
self.edit_time_button.setEnabled(True)
|
||||||
@ -307,7 +306,7 @@ class CalendarView(QDialog):
|
|||||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||||
self.save_memo_button.setEnabled(True)
|
self.save_memo_button.setEnabled(True)
|
||||||
else:
|
else:
|
||||||
self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
|
self.detail_text.setText(f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||||
self.edit_time_button.setEnabled(False)
|
self.edit_time_button.setEnabled(False)
|
||||||
self.delete_record_button.setEnabled(False)
|
self.delete_record_button.setEnabled(False)
|
||||||
self.memo_edit.setPlainText('')
|
self.memo_edit.setPlainText('')
|
||||||
@ -320,8 +319,10 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('cal.delete_selected_title'),
|
"출근 기록 삭제",
|
||||||
tr('cal.delete_selected_body', date=self.selected_date_str),
|
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
|
||||||
|
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
|
||||||
|
f"※ 이 작업은 되돌릴 수 없습니다.",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
QMessageBox.No
|
QMessageBox.No
|
||||||
)
|
)
|
||||||
@ -331,8 +332,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('cal.delete_done_title'),
|
"삭제 완료",
|
||||||
tr('cal.delete_done_body', date=self.selected_date_str)
|
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 캘린더 새로고침
|
# 캘린더 새로고침
|
||||||
@ -352,8 +353,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('cal.save_memo_title'),
|
"메모 저장",
|
||||||
tr('cal.save_memo_body', date=self.selected_date_str)
|
f"{self.selected_date_str}의 메모가 저장되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 상세 정보 새로고침
|
# 상세 정보 새로고침
|
||||||
@ -399,7 +400,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
from PyQt5.QtWidgets import QTimeEdit
|
from PyQt5.QtWidgets import QTimeEdit
|
||||||
from PyQt5.QtCore import QTime
|
from PyQt5.QtCore import QTime
|
||||||
|
|
||||||
self.setWindowTitle(tr('cal.edit_dialog_title'))
|
self.setWindowTitle("출퇴근 시간 수정")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(420)
|
self.setMinimumWidth(420)
|
||||||
|
|
||||||
@ -408,19 +409,19 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
layout.setContentsMargins(12, 10, 12, 10)
|
layout.setContentsMargins(12, 10, 12, 10)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str))
|
title = QLabel(f"{self.date_str} 출퇴근 시간 수정")
|
||||||
title.setObjectName("dialog_subtitle")
|
title.setObjectName("dialog_subtitle")
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
# 출근 시간
|
# 출근 시간
|
||||||
clock_in_layout = QHBoxLayout()
|
clock_in_layout = QHBoxLayout()
|
||||||
clock_in_layout.setSpacing(4)
|
clock_in_layout.setSpacing(4)
|
||||||
clock_in_label = QLabel(tr('cal.label_clock_in'))
|
clock_in_label = QLabel("출근:")
|
||||||
clock_in_label.setObjectName("field_label")
|
clock_in_label.setObjectName("field_label")
|
||||||
clock_in_label.setFixedWidth(40)
|
clock_in_label.setFixedWidth(40)
|
||||||
clock_in_layout.addWidget(clock_in_label)
|
clock_in_layout.addWidget(clock_in_label)
|
||||||
|
|
||||||
clock_in_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
clock_in_minus_btn = QPushButton("-30분")
|
||||||
clock_in_minus_btn.setFixedWidth(55)
|
clock_in_minus_btn.setFixedWidth(55)
|
||||||
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
||||||
clock_in_layout.addWidget(clock_in_minus_btn)
|
clock_in_layout.addWidget(clock_in_minus_btn)
|
||||||
@ -431,7 +432,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
self.clock_in_edit.setTime(clock_in_time)
|
self.clock_in_edit.setTime(clock_in_time)
|
||||||
clock_in_layout.addWidget(self.clock_in_edit)
|
clock_in_layout.addWidget(self.clock_in_edit)
|
||||||
|
|
||||||
clock_in_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
clock_in_plus_btn = QPushButton("+30분")
|
||||||
clock_in_plus_btn.setFixedWidth(55)
|
clock_in_plus_btn.setFixedWidth(55)
|
||||||
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
||||||
clock_in_layout.addWidget(clock_in_plus_btn)
|
clock_in_layout.addWidget(clock_in_plus_btn)
|
||||||
@ -440,12 +441,12 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
# 퇴근 시간
|
# 퇴근 시간
|
||||||
clock_out_layout = QHBoxLayout()
|
clock_out_layout = QHBoxLayout()
|
||||||
clock_out_layout.setSpacing(4)
|
clock_out_layout.setSpacing(4)
|
||||||
clock_out_label = QLabel(tr('cal.label_clock_out'))
|
clock_out_label = QLabel("퇴근:")
|
||||||
clock_out_label.setObjectName("field_label")
|
clock_out_label.setObjectName("field_label")
|
||||||
clock_out_label.setFixedWidth(40)
|
clock_out_label.setFixedWidth(40)
|
||||||
clock_out_layout.addWidget(clock_out_label)
|
clock_out_layout.addWidget(clock_out_label)
|
||||||
|
|
||||||
clock_out_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
clock_out_minus_btn = QPushButton("-30분")
|
||||||
clock_out_minus_btn.setFixedWidth(55)
|
clock_out_minus_btn.setFixedWidth(55)
|
||||||
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
||||||
clock_out_layout.addWidget(clock_out_minus_btn)
|
clock_out_layout.addWidget(clock_out_minus_btn)
|
||||||
@ -457,7 +458,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
self.clock_out_edit.setTime(clock_out_time)
|
self.clock_out_edit.setTime(clock_out_time)
|
||||||
clock_out_layout.addWidget(self.clock_out_edit)
|
clock_out_layout.addWidget(self.clock_out_edit)
|
||||||
|
|
||||||
clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
clock_out_plus_btn = QPushButton("+30분")
|
||||||
clock_out_plus_btn.setFixedWidth(55)
|
clock_out_plus_btn.setFixedWidth(55)
|
||||||
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
||||||
clock_out_layout.addWidget(clock_out_plus_btn)
|
clock_out_layout.addWidget(clock_out_plus_btn)
|
||||||
@ -466,27 +467,27 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
# 점심/저녁 체크박스 - 한 줄에
|
# 점심/저녁 체크박스 - 한 줄에
|
||||||
from PyQt5.QtWidgets import QCheckBox
|
from PyQt5.QtWidgets import QCheckBox
|
||||||
check_layout = QHBoxLayout()
|
check_layout = QHBoxLayout()
|
||||||
self.lunch_check = QCheckBox(tr('cal.check_lunch_1h'))
|
self.lunch_check = QCheckBox("점심 (1시간)")
|
||||||
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
||||||
check_layout.addWidget(self.lunch_check)
|
check_layout.addWidget(self.lunch_check)
|
||||||
|
|
||||||
self.dinner_check = QCheckBox(tr('cal.check_dinner_1h'))
|
self.dinner_check = QCheckBox("저녁 (1시간)")
|
||||||
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
||||||
check_layout.addWidget(self.dinner_check)
|
check_layout.addWidget(self.dinner_check)
|
||||||
layout.addLayout(check_layout)
|
layout.addLayout(check_layout)
|
||||||
|
|
||||||
# 안내 메시지
|
# 안내 메시지
|
||||||
note = QLabel(tr('cal.edit_note'))
|
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
|
||||||
note.setObjectName("note_text")
|
note.setObjectName("note_text")
|
||||||
layout.addWidget(note)
|
layout.addWidget(note)
|
||||||
|
|
||||||
# 버튼
|
# 버튼
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
save_button = QPushButton(tr('btn.save'))
|
save_button = QPushButton("저장")
|
||||||
save_button.setObjectName("btn_success")
|
save_button.setObjectName("btn_success")
|
||||||
save_button.clicked.connect(self.save_changes)
|
save_button.clicked.connect(self.save_changes)
|
||||||
|
|
||||||
cancel_button = QPushButton(tr('btn.cancel'))
|
cancel_button = QPushButton("취소")
|
||||||
cancel_button.clicked.connect(self.reject)
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
|
||||||
button_layout.addWidget(save_button)
|
button_layout.addWidget(save_button)
|
||||||
@ -512,8 +513,8 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
if clock_out <= clock_in:
|
if clock_out <= clock_in:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
tr('cal.time_error_title'),
|
"시간 오류",
|
||||||
tr('cal.time_error_body')
|
"퇴근 시간은 출근 시간보다 늦어야 합니다."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -596,12 +597,15 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('cal.edit_done_title'),
|
"수정 완료",
|
||||||
tr('cal.edit_done_body',
|
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
|
||||||
date=self.date_str, clock_in=clock_in, clock_out=clock_out,
|
f"출근: {clock_in}\n"
|
||||||
lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
|
f"퇴근: {clock_out}\n"
|
||||||
dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
|
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
|
||||||
break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
|
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
|
||||||
|
f"외출시간: {break_minutes}분\n"
|
||||||
|
f"총 근무시간: {total_hours:.1f}시간\n"
|
||||||
|
f"연장근무: {overtime_earned}분 적립"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.accept()
|
self.accept()
|
||||||
@ -609,8 +613,8 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
tr('cal.edit_error_title'),
|
"오류",
|
||||||
tr('cal.edit_error_body', error=str(e))
|
f"수정 중 오류가 발생했습니다:\n{str(e)}"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
|
|||||||
@ -9,8 +9,6 @@ from typing import List, Tuple
|
|||||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import matplotlib
|
import matplotlib
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
@ -91,7 +89,7 @@ class _Fallback(QWidget):
|
|||||||
def make_chart_widget(parent=None) -> QWidget:
|
def make_chart_widget(parent=None) -> QWidget:
|
||||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||||
if not _MPL:
|
if not _MPL:
|
||||||
return _Fallback(tr('chart.need_matplotlib'))
|
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||||
_refresh_chart_colors()
|
_refresh_chart_colors()
|
||||||
widget = QWidget(parent)
|
widget = QWidget(parent)
|
||||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||||
@ -117,7 +115,7 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
if not records:
|
if not records:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
@ -129,10 +127,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
|
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
||||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'),
|
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
||||||
color=_CHART_BAR_OVERTIME)
|
color=_CHART_BAR_OVERTIME)
|
||||||
ax.set_ylabel(tr('chart.ylabel_hours'))
|
ax.set_ylabel('시간')
|
||||||
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||||
@ -159,10 +157,9 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
for i, bar in enumerate(bars):
|
for i, bar in enumerate(bars):
|
||||||
if bar.contains(event)[0]:
|
if bar.contains(event)[0]:
|
||||||
h = hours[i]; ot = overtimes[i]
|
h = hours[i]; ot = overtimes[i]
|
||||||
text = tr('chart.hover_text',
|
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
||||||
date=full_dates[i], hours=f"{h:.1f}")
|
|
||||||
if ot > 0:
|
if ot > 0:
|
||||||
text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}")
|
text += f"\n연장 +{ot:.1f}h"
|
||||||
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
||||||
annot.set_text(text)
|
annot.set_text(text)
|
||||||
annot.set_visible(True)
|
annot.set_visible(True)
|
||||||
@ -194,7 +191,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
if not records:
|
if not records:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
@ -214,7 +211,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
if not minutes_list:
|
if not minutes_list:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
@ -228,13 +225,12 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||||
edgecolor=_CHART_BG, linewidth=1)
|
edgecolor=_CHART_BG, linewidth=1)
|
||||||
avg = sum(minutes_list) / len(minutes_list)
|
avg = sum(minutes_list) / len(minutes_list)
|
||||||
avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}"
|
|
||||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||||
label=tr('chart.avg_line', time=avg_time))
|
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
||||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||||
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||||
rotation=45, fontsize=8)
|
rotation=45, fontsize=8)
|
||||||
ax.set_ylabel(tr('chart.ylabel_days'))
|
ax.set_ylabel('일수')
|
||||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
@ -261,13 +257,11 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
|||||||
weekday_counts[d.weekday()] += 1
|
weekday_counts[d.weekday()] += 1
|
||||||
|
|
||||||
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
||||||
labels = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'),
|
|
||||||
tr('label.weekday_sun')]
|
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||||
ax.bar(labels, avg, color=colors)
|
ax.bar(labels, avg, color=colors)
|
||||||
ax.set_ylabel(tr('chart.ylabel_avg_hours'))
|
ax.set_ylabel('평균 시간')
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
|
|||||||
@ -134,8 +134,8 @@ if __name__ == "__main__":
|
|||||||
dialog = ClockInDialog()
|
dialog = ClockInDialog()
|
||||||
if dialog.exec_() == QDialog.Accepted:
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
selected_time = dialog.get_time()
|
selected_time = dialog.get_time()
|
||||||
print(tr('clock_in_dialog.selected', time=selected_time.strftime('%H:%M:%S')))
|
print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}")
|
||||||
else:
|
else:
|
||||||
print(tr('clock_in_dialog.cancelled'))
|
print("취소됨")
|
||||||
|
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
|
|
||||||
class LockMonitor:
|
class LockMonitor:
|
||||||
@ -62,23 +61,14 @@ class LockMonitor:
|
|||||||
clock_in_str = when.strftime("%H:%M:%S")
|
clock_in_str = when.strftime("%H:%M:%S")
|
||||||
existing = self.db.get_today_record()
|
existing = self.db.get_today_record()
|
||||||
if existing:
|
if existing:
|
||||||
with self.db._conn() as conn:
|
conn = self.db.get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
||||||
(clock_in_str, today),
|
(clock_in_str, today),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
else:
|
else:
|
||||||
try:
|
|
||||||
self.db.add_work_record(today, clock_in_str)
|
self.db.add_work_record(today, clock_in_str)
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
# get_today_record()와 add_work_record() 사이 경쟁 조건
|
|
||||||
with self.db._conn() as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute(
|
|
||||||
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
|
||||||
(clock_in_str, today),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
w.update_display()
|
w.update_display()
|
||||||
|
|||||||
@ -10,7 +10,6 @@ from datetime import datetime
|
|||||||
from core.settings_keys import (
|
from core.settings_keys import (
|
||||||
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||||
)
|
)
|
||||||
from core.i18n import tr
|
|
||||||
from utils.debug_log import dlog
|
from utils.debug_log import dlog
|
||||||
|
|
||||||
|
|
||||||
@ -55,12 +54,12 @@ class NotificationOrchestrator:
|
|||||||
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
||||||
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
||||||
|
|
||||||
title = tr('notif.weekly_report.title')
|
title = "📊 지난주 요약"
|
||||||
body = tr('notif.weekly_report.body',
|
body = (f"기간: {last_mon} ~ {last_sun}\n"
|
||||||
start=last_mon, end=last_sun,
|
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
|
||||||
total_h=total_h, days=len(closed),
|
f"일 평균: {avg_h:.1f}시간\n"
|
||||||
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
f"연장근무: {ot_h}시간 {ot_m}분\n"
|
||||||
longest=longest_str)
|
f"가장 긴 날: {longest_str}")
|
||||||
self.notifier.notification_signal.emit(title, body)
|
self.notifier.notification_signal.emit(title, body)
|
||||||
self.db.log_notification('system', 'weekly_report')
|
self.db.log_notification('system', 'weekly_report')
|
||||||
|
|
||||||
@ -71,17 +70,13 @@ class NotificationOrchestrator:
|
|||||||
try:
|
try:
|
||||||
from utils.discord_webhook import send, COLOR_BLUE
|
from utils.discord_webhook import send, COLOR_BLUE
|
||||||
fields = [
|
fields = [
|
||||||
{"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True},
|
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
|
||||||
{"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True},
|
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
|
||||||
{"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True},
|
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
|
||||||
{"name": tr('field.longest_day'), "value": longest_str, "inline": False},
|
{"name": "가장 긴 날", "value": longest_str, "inline": False},
|
||||||
]
|
]
|
||||||
ok = send(url, tr('notif.weekly_report.title'),
|
ok = send(url, "📊 지난주 요약",
|
||||||
tr('notif.weekly_report.body',
|
f"기간: {last_mon} ~ {last_sun}",
|
||||||
start=last_mon, end=last_sun,
|
|
||||||
total_h=total_h, days=len(closed),
|
|
||||||
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
|
||||||
longest=longest_str),
|
|
||||||
color=COLOR_BLUE, fields=fields)
|
color=COLOR_BLUE, fields=fields)
|
||||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -152,8 +147,8 @@ class NotificationOrchestrator:
|
|||||||
for a in unlocked:
|
for a in unlocked:
|
||||||
self.db.log_notification('system', f'achievement:{a.code}')
|
self.db.log_notification('system', f'achievement:{a.code}')
|
||||||
if notif_on:
|
if notif_on:
|
||||||
title = tr('notif.achievement.title', icon=a.badge_icon)
|
title = f"{a.badge_icon} 도전과제 달성!"
|
||||||
body = tr('notif.achievement.body', name=a.name, description=a.description)
|
body = f"{a.name}\n{a.description}"
|
||||||
self.notifier.notification_signal.emit(title, body)
|
self.notifier.notification_signal.emit(title, body)
|
||||||
# Discord 통합 push (여러 개면 묶어서)
|
# Discord 통합 push (여러 개면 묶어서)
|
||||||
self._discord_achievements(unlocked)
|
self._discord_achievements(unlocked)
|
||||||
@ -172,8 +167,8 @@ class NotificationOrchestrator:
|
|||||||
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||||
ok = discord_webhook.send(
|
ok = discord_webhook.send(
|
||||||
url,
|
url,
|
||||||
tr('discord.achievement.title', count=len(unlocked)),
|
f"🏆 도전과제 {len(unlocked)}개 달성!",
|
||||||
tr('discord.achievement.body', extra=extra),
|
f"새로 잠금 해제된 도전과제 입니다.{extra}",
|
||||||
color=discord_webhook.COLOR_YELLOW,
|
color=discord_webhook.COLOR_YELLOW,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -9,8 +9,6 @@ from datetime import datetime, date
|
|||||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
|
|
||||||
|
|
||||||
class GoalWidget(QWidget):
|
class GoalWidget(QWidget):
|
||||||
"""월간 목표 진행률 표시."""
|
"""월간 목표 진행률 표시."""
|
||||||
@ -22,13 +20,13 @@ class GoalWidget(QWidget):
|
|||||||
layout.setContentsMargins(8, 6, 8, 6)
|
layout.setContentsMargins(8, 6, 8, 6)
|
||||||
layout.setSpacing(4)
|
layout.setSpacing(4)
|
||||||
|
|
||||||
title = QLabel(tr('goal.title'))
|
title = QLabel("이번 달 목표")
|
||||||
title.setStyleSheet("font-weight: bold;")
|
title.setStyleSheet("font-weight: bold;")
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
# 연장근무 상한
|
# 연장근무 상한
|
||||||
ot_row = QHBoxLayout()
|
ot_row = QHBoxLayout()
|
||||||
self.ot_label = QLabel(tr('goal.overtime'))
|
self.ot_label = QLabel("연장근무:")
|
||||||
self.ot_label.setFixedWidth(100)
|
self.ot_label.setFixedWidth(100)
|
||||||
self.ot_bar = QProgressBar()
|
self.ot_bar = QProgressBar()
|
||||||
self.ot_bar.setTextVisible(True)
|
self.ot_bar.setTextVisible(True)
|
||||||
@ -39,7 +37,7 @@ class GoalWidget(QWidget):
|
|||||||
|
|
||||||
# 일평균
|
# 일평균
|
||||||
avg_row = QHBoxLayout()
|
avg_row = QHBoxLayout()
|
||||||
self.avg_label = QLabel(tr('goal.avg_daily'))
|
self.avg_label = QLabel("일평균:")
|
||||||
self.avg_label.setFixedWidth(100)
|
self.avg_label.setFixedWidth(100)
|
||||||
self.avg_bar = QProgressBar()
|
self.avg_bar = QProgressBar()
|
||||||
self.avg_bar.setTextVisible(True)
|
self.avg_bar.setTextVisible(True)
|
||||||
|
|||||||
@ -59,7 +59,7 @@ class HelpView(QDialog):
|
|||||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||||
|
|
||||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||||
onboarding_button = QPushButton(tr('help.onboarding_button'))
|
onboarding_button = QPushButton("온보딩 다시 보기")
|
||||||
onboarding_button.setMinimumHeight(36)
|
onboarding_button.setMinimumHeight(36)
|
||||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||||
|
|||||||
@ -13,7 +13,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
from PyQt5.QtCore import Qt, QDate
|
from PyQt5.QtCore import Qt, QDate
|
||||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -23,7 +22,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle(tr('leave_cal.title'))
|
self.setWindowTitle("연차 캘린더")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumSize(540, 480)
|
self.setMinimumSize(540, 480)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@ -38,7 +37,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||||
used = total - balance
|
used = total - balance
|
||||||
title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
|
title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch()
|
header.addStretch()
|
||||||
@ -46,9 +45,9 @@ class LeaveCalendarView(QDialog):
|
|||||||
|
|
||||||
# 범례 (사용 완료 + 예정 분리)
|
# 범례 (사용 완료 + 예정 분리)
|
||||||
legend = QHBoxLayout()
|
legend = QHBoxLayout()
|
||||||
for _color, _txt in [('#51CF66', tr('leave_cal.legend_full')), ('#FAB005', tr('leave_cal.legend_half')),
|
for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
|
||||||
('#B197FC', tr('leave_cal.legend_quarter')), ('#4DABF7', tr('leave_cal.legend_planned')),
|
('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
|
||||||
('#748FFC', tr('leave_cal.legend_full_planned'))]:
|
('#748FFC', '종일+예정')]:
|
||||||
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||||
l.setStyleSheet("padding: 2px 6px;")
|
l.setStyleSheet("padding: 2px 6px;")
|
||||||
legend.addWidget(l)
|
legend.addWidget(l)
|
||||||
@ -69,7 +68,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
# 닫기 버튼
|
# 닫기 버튼
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
close_btn = QPushButton(tr('btn.close'))
|
close_btn = QPushButton("닫기")
|
||||||
close_btn.clicked.connect(self.close)
|
close_btn.clicked.connect(self.close)
|
||||||
btn_row.addWidget(close_btn)
|
btn_row.addWidget(close_btn)
|
||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
@ -110,12 +109,12 @@ class LeaveCalendarView(QDialog):
|
|||||||
records = self.db.get_all_leave_records(limit=365)
|
records = self.db.get_all_leave_records(limit=365)
|
||||||
match = [r for r in records if r['date'] == date_str]
|
match = [r for r in records if r['date'] == date_str]
|
||||||
if not match:
|
if not match:
|
||||||
self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
|
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
|
||||||
return
|
return
|
||||||
parts = []
|
parts = []
|
||||||
for r in match:
|
for r in match:
|
||||||
t = r.get('leave_type', 'annual')
|
t = r.get('leave_type', 'annual')
|
||||||
d = float(r.get('days') or 0)
|
d = float(r.get('days') or 0)
|
||||||
memo = r.get('memo') or ''
|
memo = r.get('memo') or ''
|
||||||
parts.append(tr('leave_cal.detail_memo', type=t, days=d, memo=memo) if memo else tr('leave_cal.detail_label', type=t, days=d))
|
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
||||||
self.detail_label.setText(f"{date_str}: " + ", ".join(parts))
|
self.detail_label.setText(f"{date_str}: " + ", ".join(parts))
|
||||||
|
|||||||
@ -90,8 +90,8 @@ class LeaveView(QDialog):
|
|||||||
cal_button.clicked.connect(self._show_calendar)
|
cal_button.clicked.connect(self._show_calendar)
|
||||||
button_layout.addWidget(cal_button)
|
button_layout.addWidget(cal_button)
|
||||||
|
|
||||||
schedule_button = QPushButton(tr('view.leave.btn_schedule'))
|
schedule_button = QPushButton("스케줄")
|
||||||
schedule_button.setToolTip(tr('view.leave.schedule_tooltip'))
|
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||||
schedule_button.clicked.connect(self._show_schedule)
|
schedule_button.clicked.connect(self._show_schedule)
|
||||||
button_layout.addWidget(schedule_button)
|
button_layout.addWidget(schedule_button)
|
||||||
|
|
||||||
@ -137,13 +137,13 @@ class LeaveView(QDialog):
|
|||||||
days = record['days']
|
days = record['days']
|
||||||
hours = days * 8
|
hours = days * 8
|
||||||
if days == 1.0:
|
if days == 1.0:
|
||||||
days_str = tr('view.leave.used_1day')
|
days_str = "1일"
|
||||||
elif days == 0.5:
|
elif days == 0.5:
|
||||||
days_str = tr('view.leave.used_half_day')
|
days_str = "0.5일 (4시간)"
|
||||||
elif hours < 8:
|
elif hours < 8:
|
||||||
days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours)
|
days_str = f"{days}일 ({hours}시간)"
|
||||||
else:
|
else:
|
||||||
days_str = tr('view.leave.used_days_fmt', days=days)
|
days_str = f"{days}일"
|
||||||
days_item = QTableWidgetItem(days_str)
|
days_item = QTableWidgetItem(days_str)
|
||||||
days_item.setTextAlignment(Qt.AlignCenter)
|
days_item.setTextAlignment(Qt.AlignCenter)
|
||||||
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||||
@ -365,17 +365,17 @@ class AddLeaveDialog(QDialog):
|
|||||||
if date_dt.weekday() in (5, 6): # 토/일
|
if date_dt.weekday() in (5, 6): # 토/일
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
tr('view.leave.weekend_register_forbidden_title'),
|
"주말 등록 불가",
|
||||||
tr('view.leave.weekend_register_forbidden_body')
|
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if self.db.is_holiday(date):
|
if self.db.is_holiday(date):
|
||||||
holiday = self.db.get_holiday(date)
|
holiday = self.db.get_holiday(date)
|
||||||
name = (holiday or {}).get('name', tr('label.holiday_default'))
|
name = (holiday or {}).get('name', '공휴일')
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
tr('view.leave.holiday_register_forbidden_title'),
|
"공휴일 등록 불가",
|
||||||
tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
|
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -385,8 +385,9 @@ class AddLeaveDialog(QDialog):
|
|||||||
if existing_days + days > 1.0001: # 부동소수점 여유
|
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
tr('view.leave.duplicate_register_title'),
|
"중복 등록 초과",
|
||||||
tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days)
|
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
|
||||||
|
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
QPushButton, QTimeEdit, QMessageBox)
|
QPushButton, QTimeEdit, QMessageBox)
|
||||||
from PyQt5.QtCore import QTime
|
from PyQt5.QtCore import QTime
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -36,8 +35,8 @@ class MealTimeDialog(QDialog):
|
|||||||
self.meal_type = meal_type
|
self.meal_type = meal_type
|
||||||
self._clock_in = clock_in_time
|
self._clock_in = clock_in_time
|
||||||
self._clock_out = clock_out_time
|
self._clock_out = clock_out_time
|
||||||
meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short')
|
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||||
self.setWindowTitle(tr('meal.dialog_title', meal=meal_label))
|
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setFixedSize(380, 260)
|
self.setFixedSize(380, 260)
|
||||||
|
|
||||||
@ -45,9 +44,10 @@ class MealTimeDialog(QDialog):
|
|||||||
layout.setSpacing(10)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
|
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
|
||||||
|
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||||
if clock_in_time is not None:
|
if clock_in_time is not None:
|
||||||
info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M'))
|
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
||||||
info = QLabel(info_text)
|
info = QLabel(info_text)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||||
@ -63,7 +63,7 @@ class MealTimeDialog(QDialog):
|
|||||||
|
|
||||||
# 시작
|
# 시작
|
||||||
start_row = QHBoxLayout()
|
start_row = QHBoxLayout()
|
||||||
start_row.addWidget(QLabel(tr('meal.label_start')))
|
start_row.addWidget(QLabel("시작:"))
|
||||||
self.start_edit = QTimeEdit()
|
self.start_edit = QTimeEdit()
|
||||||
self.start_edit.setDisplayFormat("HH:mm")
|
self.start_edit.setDisplayFormat("HH:mm")
|
||||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||||
@ -73,7 +73,7 @@ class MealTimeDialog(QDialog):
|
|||||||
|
|
||||||
# 종료
|
# 종료
|
||||||
end_row = QHBoxLayout()
|
end_row = QHBoxLayout()
|
||||||
end_row.addWidget(QLabel(tr('meal.label_end')))
|
end_row.addWidget(QLabel("종료:"))
|
||||||
self.end_edit = QTimeEdit()
|
self.end_edit = QTimeEdit()
|
||||||
self.end_edit.setDisplayFormat("HH:mm")
|
self.end_edit.setDisplayFormat("HH:mm")
|
||||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||||
@ -92,10 +92,10 @@ class MealTimeDialog(QDialog):
|
|||||||
# 버튼
|
# 버튼
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
ok_btn = QPushButton(tr('btn.save'))
|
ok_btn = QPushButton("저장")
|
||||||
ok_btn.setObjectName("btn_primary")
|
ok_btn.setObjectName("btn_primary")
|
||||||
ok_btn.clicked.connect(self.accept)
|
ok_btn.clicked.connect(self.accept)
|
||||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
cancel_btn = QPushButton("취소")
|
||||||
cancel_btn.clicked.connect(self.reject)
|
cancel_btn.clicked.connect(self.reject)
|
||||||
btn_row.addWidget(ok_btn)
|
btn_row.addWidget(ok_btn)
|
||||||
btn_row.addWidget(cancel_btn)
|
btn_row.addWidget(cancel_btn)
|
||||||
@ -137,24 +137,24 @@ class MealTimeDialog(QDialog):
|
|||||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
if not ok:
|
if not ok:
|
||||||
self.preview.setText(reason)
|
self.preview.setText(f"{reason}")
|
||||||
self.preview.setStyleSheet("color: #FA5252;")
|
self.preview.setStyleSheet("color: #FA5252;")
|
||||||
else:
|
else:
|
||||||
self.preview.setText(tr('meal.preview_total', minutes=minutes))
|
self.preview.setText(f"총 {minutes}분")
|
||||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||||
|
|
||||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||||
minutes: int) -> tuple[bool, str]:
|
minutes: int) -> tuple[bool, str]:
|
||||||
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||||
if minutes <= 0:
|
if minutes <= 0:
|
||||||
return False, tr('meal.error_start_after_end')
|
return False, "시작이 종료보다 늦습니다"
|
||||||
if minutes > 8 * 60:
|
if minutes > 8 * 60:
|
||||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||||
return False, tr('meal.error_too_long')
|
return False, "식사 시간이 8시간을 초과합니다"
|
||||||
if self._clock_in is not None and start_dt < self._clock_in:
|
if self._clock_in is not None and start_dt < self._clock_in:
|
||||||
return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M'))
|
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다"
|
||||||
if self._clock_out is not None and end_dt > self._clock_out:
|
if self._clock_out is not None and end_dt > self._clock_out:
|
||||||
return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M'))
|
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다"
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
@ -162,7 +162,7 @@ class MealTimeDialog(QDialog):
|
|||||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
if not ok:
|
if not ok:
|
||||||
QMessageBox.warning(self, tr('meal.input_error_title'), reason)
|
QMessageBox.warning(self, "입력 오류", reason)
|
||||||
return
|
return
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
|
|||||||
@ -102,8 +102,8 @@ class MiniWidget(QWidget):
|
|||||||
qss = ''
|
qss = ''
|
||||||
if qss:
|
if qss:
|
||||||
menu.setStyleSheet(qss)
|
menu.setStyleSheet(qss)
|
||||||
open_main = menu.addAction(tr('mini.open_main'))
|
open_main = menu.addAction("메인 창 열기")
|
||||||
close_mini = menu.addAction(tr('mini.close'))
|
close_mini = menu.addAction("미니 위젯 닫기")
|
||||||
action = menu.exec_(event.globalPos())
|
action = menu.exec_(event.globalPos())
|
||||||
if action == open_main and self.parent_window:
|
if action == open_main and self.parent_window:
|
||||||
self.parent_window.show()
|
self.parent_window.show()
|
||||||
|
|||||||
@ -32,10 +32,17 @@ WORK_PRESETS = [
|
|||||||
class WelcomePage(QWizardPage):
|
class WelcomePage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle(tr('onboarding.welcome_title'))
|
self.setTitle("환영합니다!")
|
||||||
self.setSubTitle(tr('onboarding.welcome_subtitle'))
|
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
intro = QLabel(tr('onboarding.welcome_intro'))
|
intro = QLabel(
|
||||||
|
"이 앱은:\n"
|
||||||
|
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
|
||||||
|
"• 30분 단위 연장근무 적립\n"
|
||||||
|
"• 연차·반차·외출 시간 추적\n"
|
||||||
|
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
|
||||||
|
"[다음] 버튼을 눌러 시작하세요."
|
||||||
|
)
|
||||||
intro.setWordWrap(True)
|
intro.setWordWrap(True)
|
||||||
layout.addWidget(intro)
|
layout.addWidget(intro)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
@ -44,8 +51,8 @@ class WelcomePage(QWizardPage):
|
|||||||
class WorkPatternPage(QWizardPage):
|
class WorkPatternPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle(tr('onboarding.work_pattern_title'))
|
self.setTitle("근무 패턴")
|
||||||
self.setSubTitle(tr('onboarding.work_pattern_subtitle'))
|
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.button_group = QButtonGroup(self)
|
self.button_group = QButtonGroup(self)
|
||||||
@ -120,18 +127,20 @@ class WorkPatternPage(QWizardPage):
|
|||||||
class ClockInDetectionPage(QWizardPage):
|
class ClockInDetectionPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle(tr('onboarding.detection_title'))
|
self.setTitle("출근 시간 감지 방식")
|
||||||
self.setSubTitle(tr('onboarding.detection_subtitle'))
|
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.option_boot = QRadioButton(tr('onboarding.detection_boot'))
|
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
|
||||||
self.option_unlock = QRadioButton(tr('onboarding.detection_unlock'))
|
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
|
||||||
self.option_manual = QRadioButton(tr('onboarding.detection_manual'))
|
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
|
||||||
self.option_boot.setChecked(True)
|
self.option_boot.setChecked(True)
|
||||||
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
||||||
layout.addWidget(opt)
|
layout.addWidget(opt)
|
||||||
|
|
||||||
info = QLabel(tr('onboarding.detection_info'))
|
info = QLabel(
|
||||||
|
"\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||||
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #909296; padding: 8px;")
|
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
@ -150,35 +159,35 @@ class ClockInDetectionPage(QWizardPage):
|
|||||||
class LeaveSalaryPage(QWizardPage):
|
class LeaveSalaryPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle(tr('onboarding.leave_salary_title'))
|
self.setTitle("연차 + 급여 (옵션)")
|
||||||
self.setSubTitle(tr('onboarding.leave_salary_subtitle'))
|
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
# 연차
|
# 연차
|
||||||
leave_box = QGroupBox(tr('onboarding.leave_group'))
|
leave_box = QGroupBox("연간 연차")
|
||||||
leave_layout = QHBoxLayout()
|
leave_layout = QHBoxLayout()
|
||||||
self.leave_spin = QSpinBox()
|
self.leave_spin = QSpinBox()
|
||||||
self.leave_spin.setRange(0, 30)
|
self.leave_spin.setRange(0, 30)
|
||||||
self.leave_spin.setValue(15)
|
self.leave_spin.setValue(15)
|
||||||
self.leave_spin.setSuffix(tr('label.unit_day'))
|
self.leave_spin.setSuffix(" 일")
|
||||||
leave_layout.addWidget(QLabel(tr('onboarding.my_leave')))
|
leave_layout.addWidget(QLabel("내 연차:"))
|
||||||
leave_layout.addWidget(self.leave_spin)
|
leave_layout.addWidget(self.leave_spin)
|
||||||
leave_layout.addStretch()
|
leave_layout.addStretch()
|
||||||
leave_box.setLayout(leave_layout)
|
leave_box.setLayout(leave_layout)
|
||||||
layout.addWidget(leave_box)
|
layout.addWidget(leave_box)
|
||||||
|
|
||||||
# 급여 (옵션)
|
# 급여 (옵션)
|
||||||
salary_box = QGroupBox(tr('onboarding.salary_group'))
|
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
|
||||||
salary_layout = QVBoxLayout()
|
salary_layout = QVBoxLayout()
|
||||||
self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled'))
|
self.salary_enabled = QCheckBox("급여 추정 활성화")
|
||||||
salary_layout.addWidget(self.salary_enabled)
|
salary_layout.addWidget(self.salary_enabled)
|
||||||
|
|
||||||
wage_row = QHBoxLayout()
|
wage_row = QHBoxLayout()
|
||||||
wage_row.addWidget(QLabel(tr('onboarding.hourly_wage')))
|
wage_row.addWidget(QLabel("시급:"))
|
||||||
self.wage_spin = QSpinBox()
|
self.wage_spin = QSpinBox()
|
||||||
self.wage_spin.setRange(0, 1000000)
|
self.wage_spin.setRange(0, 1000000)
|
||||||
self.wage_spin.setSingleStep(1000)
|
self.wage_spin.setSingleStep(1000)
|
||||||
self.wage_spin.setSuffix(tr('onboarding.wage_suffix'))
|
self.wage_spin.setSuffix(" 원/시간")
|
||||||
self.wage_spin.setValue(0)
|
self.wage_spin.setValue(0)
|
||||||
self.wage_spin.setEnabled(False)
|
self.wage_spin.setEnabled(False)
|
||||||
wage_row.addWidget(self.wage_spin)
|
wage_row.addWidget(self.wage_spin)
|
||||||
@ -186,11 +195,11 @@ class LeaveSalaryPage(QWizardPage):
|
|||||||
salary_layout.addLayout(wage_row)
|
salary_layout.addLayout(wage_row)
|
||||||
|
|
||||||
rate_row = QHBoxLayout()
|
rate_row = QHBoxLayout()
|
||||||
rate_row.addWidget(QLabel(tr('onboarding.overtime_rate')))
|
rate_row.addWidget(QLabel("연장수당 가산률:"))
|
||||||
self.rate_combo = QComboBox()
|
self.rate_combo = QComboBox()
|
||||||
self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0)
|
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
|
||||||
self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5)
|
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
|
||||||
self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0)
|
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
|
||||||
self.rate_combo.setCurrentIndex(1)
|
self.rate_combo.setCurrentIndex(1)
|
||||||
self.rate_combo.setEnabled(False)
|
self.rate_combo.setEnabled(False)
|
||||||
rate_row.addWidget(self.rate_combo)
|
rate_row.addWidget(self.rate_combo)
|
||||||
@ -209,25 +218,30 @@ class LeaveSalaryPage(QWizardPage):
|
|||||||
class DiscordPage(QWizardPage):
|
class DiscordPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle(tr('onboarding.discord_title'))
|
self.setTitle("Discord 알림 (선택)")
|
||||||
self.setSubTitle(tr('onboarding.discord_subtitle'))
|
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.enable_check = QCheckBox(tr('onboarding.discord_enable'))
|
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
|
||||||
layout.addWidget(self.enable_check)
|
layout.addWidget(self.enable_check)
|
||||||
|
|
||||||
self.url_edit = QLineEdit()
|
self.url_edit = QLineEdit()
|
||||||
self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder'))
|
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
|
||||||
self.url_edit.setEnabled(False)
|
self.url_edit.setEnabled(False)
|
||||||
layout.addWidget(self.url_edit)
|
layout.addWidget(self.url_edit)
|
||||||
|
|
||||||
guide = QLabel(tr('onboarding.discord_guide'))
|
guide = QLabel(
|
||||||
|
"셋업 방법:\n"
|
||||||
|
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
|
||||||
|
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||||
|
"3. 위 입력란에 붙여넣기"
|
||||||
|
)
|
||||||
guide.setStyleSheet("color: #909296; padding: 6px;")
|
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||||
guide.setWordWrap(True)
|
guide.setWordWrap(True)
|
||||||
layout.addWidget(guide)
|
layout.addWidget(guide)
|
||||||
|
|
||||||
test_row = QHBoxLayout()
|
test_row = QHBoxLayout()
|
||||||
self.test_btn = QPushButton(tr('onboarding.discord_test'))
|
self.test_btn = QPushButton("테스트 메시지 보내기")
|
||||||
self.test_btn.setEnabled(False)
|
self.test_btn.setEnabled(False)
|
||||||
self.test_btn.clicked.connect(self._test_webhook)
|
self.test_btn.clicked.connect(self._test_webhook)
|
||||||
test_row.addWidget(self.test_btn)
|
test_row.addWidget(self.test_btn)
|
||||||
@ -243,30 +257,39 @@ class DiscordPage(QWizardPage):
|
|||||||
def _test_webhook(self):
|
def _test_webhook(self):
|
||||||
url = self.url_edit.text().strip()
|
url = self.url_edit.text().strip()
|
||||||
if not url:
|
if not url:
|
||||||
QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body'))
|
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||||
return
|
return
|
||||||
from utils import discord_webhook
|
from utils import discord_webhook
|
||||||
if not discord_webhook.is_valid_webhook_url(url):
|
if not discord_webhook.is_valid_webhook_url(url):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self, tr('onboarding.discord_url_invalid_title'),
|
self, "URL 형식 오류",
|
||||||
tr('onboarding.discord_url_invalid_body')
|
"Discord 웹훅 URL 형식이 아닙니다.\n"
|
||||||
|
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
ok = discord_webhook.send_test(url)
|
ok = discord_webhook.send_test(url)
|
||||||
if ok:
|
if ok:
|
||||||
QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body'))
|
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body'))
|
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
|
||||||
|
|
||||||
|
|
||||||
class FinishPage(QWizardPage):
|
class FinishPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle(tr('onboarding.finish_title'))
|
self.setTitle("준비 완료!")
|
||||||
self.setSubTitle(tr('onboarding.finish_subtitle'))
|
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
msg = QLabel(tr('onboarding.finish_msg'))
|
msg = QLabel(
|
||||||
|
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||||
|
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||||
|
"단축키:\n"
|
||||||
|
" • Ctrl+O — 출퇴근 토글\n"
|
||||||
|
" • F1 — 도움말\n"
|
||||||
|
" • F5 — 업데이트 확인\n"
|
||||||
|
" • Ctrl+, — 설정"
|
||||||
|
)
|
||||||
msg.setWordWrap(True)
|
msg.setWordWrap(True)
|
||||||
layout.addWidget(msg)
|
layout.addWidget(msg)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
@ -278,7 +301,7 @@ class OnboardingWizard(QWizard):
|
|||||||
def __init__(self, db, parent=None):
|
def __init__(self, db, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle(tr('onboarding.window_title'))
|
self.setWindowTitle("Clock-out Calculator — 시작 설정")
|
||||||
self.setMinimumSize(600, 500)
|
self.setMinimumSize(600, 500)
|
||||||
self.setWizardStyle(QWizard.ModernStyle)
|
self.setWizardStyle(QWizard.ModernStyle)
|
||||||
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
||||||
@ -300,7 +323,7 @@ class OnboardingWizard(QWizard):
|
|||||||
# 1. 근무 패턴
|
# 1. 근무 패턴
|
||||||
wm, lm, dm = self.work_page.selected_minutes()
|
wm, lm, dm = self.work_page.selected_minutes()
|
||||||
if wm < 30:
|
if wm < 30:
|
||||||
QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small'))
|
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||||
return
|
return
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
|
|||||||
@ -9,7 +9,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
QMessageBox)
|
QMessageBox)
|
||||||
from PyQt5.QtCore import QTime, Qt
|
from PyQt5.QtCore import QTime, Qt
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -19,7 +18,7 @@ class PastRecordDialog(QDialog):
|
|||||||
def __init__(self, parent=None, date_str: str = ''):
|
def __init__(self, parent=None, date_str: str = ''):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.date_str = date_str
|
self.date_str = date_str
|
||||||
self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
|
self.setWindowTitle(f"기록 추가 — {date_str}")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setFixedSize(380, 320)
|
self.setFixedSize(380, 320)
|
||||||
|
|
||||||
@ -27,13 +26,13 @@ class PastRecordDialog(QDialog):
|
|||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
info = QLabel(tr('past_record.info', date=date_str))
|
info = QLabel(f"{date_str} 근무 기록을 입력하세요.")
|
||||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
# 출근
|
# 출근
|
||||||
ci_row = QHBoxLayout()
|
ci_row = QHBoxLayout()
|
||||||
ci_row.addWidget(QLabel(tr('past_record.label_clock_in')))
|
ci_row.addWidget(QLabel("출근:"))
|
||||||
self.clock_in_edit = QTimeEdit()
|
self.clock_in_edit = QTimeEdit()
|
||||||
self.clock_in_edit.setDisplayFormat("HH:mm")
|
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||||
self.clock_in_edit.setTime(QTime(9, 0))
|
self.clock_in_edit.setTime(QTime(9, 0))
|
||||||
@ -43,8 +42,8 @@ class PastRecordDialog(QDialog):
|
|||||||
|
|
||||||
# 퇴근
|
# 퇴근
|
||||||
co_row = QHBoxLayout()
|
co_row = QHBoxLayout()
|
||||||
co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
|
co_row.addWidget(QLabel("퇴근:"))
|
||||||
self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
|
self.clock_out_check = QCheckBox("입력")
|
||||||
self.clock_out_check.setChecked(True)
|
self.clock_out_check.setChecked(True)
|
||||||
self.clock_out_edit = QTimeEdit()
|
self.clock_out_edit = QTimeEdit()
|
||||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||||
@ -57,18 +56,18 @@ class PastRecordDialog(QDialog):
|
|||||||
|
|
||||||
# 점심/저녁
|
# 점심/저녁
|
||||||
meal_row = QHBoxLayout()
|
meal_row = QHBoxLayout()
|
||||||
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
self.lunch_check = QCheckBox("점심시간 포함")
|
||||||
self.lunch_check.setChecked(True)
|
self.lunch_check.setChecked(True)
|
||||||
self.dinner_check = QCheckBox(tr('past_record.check_dinner'))
|
self.dinner_check = QCheckBox("저녁시간 포함")
|
||||||
meal_row.addWidget(self.lunch_check)
|
meal_row.addWidget(self.lunch_check)
|
||||||
meal_row.addWidget(self.dinner_check)
|
meal_row.addWidget(self.dinner_check)
|
||||||
meal_row.addStretch()
|
meal_row.addStretch()
|
||||||
layout.addLayout(meal_row)
|
layout.addLayout(meal_row)
|
||||||
|
|
||||||
# 메모
|
# 메모
|
||||||
layout.addWidget(QLabel(tr('past_record.label_memo')))
|
layout.addWidget(QLabel("메모 (선택):"))
|
||||||
self.memo_edit = QLineEdit()
|
self.memo_edit = QLineEdit()
|
||||||
self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
|
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
||||||
layout.addWidget(self.memo_edit)
|
layout.addWidget(self.memo_edit)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
@ -76,10 +75,10 @@ class PastRecordDialog(QDialog):
|
|||||||
# 버튼
|
# 버튼
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
ok_btn = QPushButton(tr('btn.save'))
|
ok_btn = QPushButton("저장")
|
||||||
ok_btn.setObjectName("btn_primary")
|
ok_btn.setObjectName("btn_primary")
|
||||||
ok_btn.clicked.connect(self._validate_and_accept)
|
ok_btn.clicked.connect(self._validate_and_accept)
|
||||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
cancel_btn = QPushButton("취소")
|
||||||
cancel_btn.clicked.connect(self.reject)
|
cancel_btn.clicked.connect(self.reject)
|
||||||
btn_row.addWidget(ok_btn)
|
btn_row.addWidget(ok_btn)
|
||||||
btn_row.addWidget(cancel_btn)
|
btn_row.addWidget(cancel_btn)
|
||||||
@ -93,8 +92,8 @@ class PastRecordDialog(QDialog):
|
|||||||
ci = self.clock_in_edit.time()
|
ci = self.clock_in_edit.time()
|
||||||
co = self.clock_out_edit.time()
|
co = self.clock_out_edit.time()
|
||||||
if co <= ci:
|
if co <= ci:
|
||||||
QMessageBox.warning(self, tr('past_record.input_error_title'),
|
QMessageBox.warning(self, "입력 오류",
|
||||||
tr('past_record.input_error_body'))
|
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
||||||
return
|
return
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
from PyQt5.QtCore import QDate, Qt
|
from PyQt5.QtCore import QDate, Qt
|
||||||
|
|
||||||
from core.recurring_leaves import describe_pattern
|
from core.recurring_leaves import describe_pattern
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -28,7 +27,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle(tr('recurring.title'))
|
self.setWindowTitle("반복 연차 관리")
|
||||||
self.setMinimumSize(540, 480)
|
self.setMinimumSize(540, 480)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._reload_list()
|
self._reload_list()
|
||||||
@ -38,29 +37,29 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# 기존 패턴 목록
|
# 기존 패턴 목록
|
||||||
list_group = QGroupBox(tr('recurring.list_group'))
|
list_group = QGroupBox("등록된 반복 패턴")
|
||||||
lg = QVBoxLayout()
|
lg = QVBoxLayout()
|
||||||
self.list_widget = QListWidget()
|
self.list_widget = QListWidget()
|
||||||
self.list_widget.setMinimumHeight(160)
|
self.list_widget.setMinimumHeight(160)
|
||||||
lg.addWidget(self.list_widget)
|
lg.addWidget(self.list_widget)
|
||||||
del_btn = QPushButton(tr('recurring.btn_delete_selected'))
|
del_btn = QPushButton("선택 삭제")
|
||||||
del_btn.clicked.connect(self._delete_selected)
|
del_btn.clicked.connect(self._delete_selected)
|
||||||
lg.addWidget(del_btn)
|
lg.addWidget(del_btn)
|
||||||
list_group.setLayout(lg)
|
list_group.setLayout(lg)
|
||||||
layout.addWidget(list_group)
|
layout.addWidget(list_group)
|
||||||
|
|
||||||
# 신규 등록
|
# 신규 등록
|
||||||
add_group = QGroupBox(tr('recurring.add_group'))
|
add_group = QGroupBox("신규 패턴 추가")
|
||||||
ag = QVBoxLayout()
|
ag = QVBoxLayout()
|
||||||
|
|
||||||
# 패턴 종류
|
# 패턴 종류
|
||||||
kind_row = QHBoxLayout()
|
kind_row = QHBoxLayout()
|
||||||
kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
|
kind_row.addWidget(QLabel("주기:"))
|
||||||
self.kind_group = QButtonGroup(self)
|
self.kind_group = QButtonGroup(self)
|
||||||
self.rb_weekly = QRadioButton(tr('recurring.weekly'))
|
self.rb_weekly = QRadioButton("매주")
|
||||||
self.rb_weekly.setChecked(True)
|
self.rb_weekly.setChecked(True)
|
||||||
self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
|
self.rb_biweekly = QRadioButton("격주")
|
||||||
self.rb_monthly = QRadioButton(tr('recurring.monthly'))
|
self.rb_monthly = QRadioButton("매월 N일")
|
||||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||||
self.kind_group.addButton(rb)
|
self.kind_group.addButton(rb)
|
||||||
kind_row.addWidget(rb)
|
kind_row.addWidget(rb)
|
||||||
@ -69,10 +68,10 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
|
|
||||||
# 요일 체크박스 (weekly/biweekly)
|
# 요일 체크박스 (weekly/biweekly)
|
||||||
wd_row = QHBoxLayout()
|
wd_row = QHBoxLayout()
|
||||||
wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
|
wd_row.addWidget(QLabel("요일:"))
|
||||||
self.weekday_checks = []
|
self.weekday_checks = []
|
||||||
for ko, en in _KO_WEEKDAYS:
|
for ko, en in _KO_WEEKDAYS:
|
||||||
cb = QCheckBox(tr(f'label.weekday_{en}'))
|
cb = QCheckBox(ko)
|
||||||
self.weekday_checks.append((cb, en))
|
self.weekday_checks.append((cb, en))
|
||||||
wd_row.addWidget(cb)
|
wd_row.addWidget(cb)
|
||||||
wd_row.addStretch()
|
wd_row.addStretch()
|
||||||
@ -80,40 +79,40 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
|
|
||||||
# 매월 N일
|
# 매월 N일
|
||||||
month_row = QHBoxLayout()
|
month_row = QHBoxLayout()
|
||||||
month_row.addWidget(QLabel(tr('recurring.label_monthly_day')))
|
month_row.addWidget(QLabel("매월:"))
|
||||||
self.day_of_month = QSpinBox()
|
self.day_of_month = QSpinBox()
|
||||||
self.day_of_month.setRange(1, 31)
|
self.day_of_month.setRange(1, 31)
|
||||||
self.day_of_month.setValue(15)
|
self.day_of_month.setValue(15)
|
||||||
self.day_of_month.setSuffix(tr('recurring.day_suffix'))
|
self.day_of_month.setSuffix("일")
|
||||||
month_row.addWidget(self.day_of_month)
|
month_row.addWidget(self.day_of_month)
|
||||||
month_row.addStretch()
|
month_row.addStretch()
|
||||||
ag.addLayout(month_row)
|
ag.addLayout(month_row)
|
||||||
|
|
||||||
# 차감 일수
|
# 차감 일수
|
||||||
days_row = QHBoxLayout()
|
days_row = QHBoxLayout()
|
||||||
days_row.addWidget(QLabel(tr('recurring.label_deduction')))
|
days_row.addWidget(QLabel("차감:"))
|
||||||
self.days_combo = QComboBox()
|
self.days_combo = QComboBox()
|
||||||
self.days_combo.addItem(tr('recurring.deduction_full'), 1.0)
|
self.days_combo.addItem("1.0일 (종일)", 1.0)
|
||||||
self.days_combo.addItem(tr('recurring.deduction_half'), 0.5)
|
self.days_combo.addItem("0.5일 (반차)", 0.5)
|
||||||
self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25)
|
self.days_combo.addItem("0.25일 (반반차)", 0.25)
|
||||||
days_row.addWidget(self.days_combo)
|
days_row.addWidget(self.days_combo)
|
||||||
days_row.addStretch()
|
days_row.addStretch()
|
||||||
ag.addLayout(days_row)
|
ag.addLayout(days_row)
|
||||||
|
|
||||||
# 시작/종료 날짜
|
# 시작/종료 날짜
|
||||||
date_row = QHBoxLayout()
|
date_row = QHBoxLayout()
|
||||||
date_row.addWidget(QLabel(tr('recurring.label_start')))
|
date_row.addWidget(QLabel("시작:"))
|
||||||
self.start_edit = QDateEdit()
|
self.start_edit = QDateEdit()
|
||||||
self.start_edit.setDate(QDate.currentDate())
|
self.start_edit.setDate(QDate.currentDate())
|
||||||
self.start_edit.setCalendarPopup(True)
|
self.start_edit.setCalendarPopup(True)
|
||||||
date_row.addWidget(self.start_edit)
|
date_row.addWidget(self.start_edit)
|
||||||
|
|
||||||
date_row.addWidget(QLabel(tr('recurring.label_end')))
|
date_row.addWidget(QLabel("종료:"))
|
||||||
self.end_edit = QDateEdit()
|
self.end_edit = QDateEdit()
|
||||||
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||||
self.end_edit.setCalendarPopup(True)
|
self.end_edit.setCalendarPopup(True)
|
||||||
date_row.addWidget(self.end_edit)
|
date_row.addWidget(self.end_edit)
|
||||||
self.no_end_check = QCheckBox(tr('recurring.no_end'))
|
self.no_end_check = QCheckBox("종료 없음 (무기한)")
|
||||||
self.no_end_check.toggled.connect(
|
self.no_end_check.toggled.connect(
|
||||||
lambda v: self.end_edit.setEnabled(not v)
|
lambda v: self.end_edit.setEnabled(not v)
|
||||||
)
|
)
|
||||||
@ -123,14 +122,14 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
|
|
||||||
# 메모
|
# 메모
|
||||||
memo_row = QHBoxLayout()
|
memo_row = QHBoxLayout()
|
||||||
memo_row.addWidget(QLabel(tr('recurring.label_memo')))
|
memo_row.addWidget(QLabel("메모:"))
|
||||||
self.memo_edit = QLineEdit()
|
self.memo_edit = QLineEdit()
|
||||||
self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
|
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
|
||||||
memo_row.addWidget(self.memo_edit)
|
memo_row.addWidget(self.memo_edit)
|
||||||
ag.addLayout(memo_row)
|
ag.addLayout(memo_row)
|
||||||
|
|
||||||
# 추가 버튼
|
# 추가 버튼
|
||||||
add_btn = QPushButton(tr('recurring.btn_add'))
|
add_btn = QPushButton("추가")
|
||||||
add_btn.setObjectName("btn_primary")
|
add_btn.setObjectName("btn_primary")
|
||||||
add_btn.clicked.connect(self._save)
|
add_btn.clicked.connect(self._save)
|
||||||
ag.addWidget(add_btn)
|
ag.addWidget(add_btn)
|
||||||
@ -139,7 +138,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
layout.addWidget(add_group)
|
layout.addWidget(add_group)
|
||||||
|
|
||||||
# 닫기
|
# 닫기
|
||||||
close_btn = QPushButton(tr('btn.close'))
|
close_btn = QPushButton("닫기")
|
||||||
close_btn.clicked.connect(self.close)
|
close_btn.clicked.connect(self.close)
|
||||||
layout.addWidget(close_btn)
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
@ -149,7 +148,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
self.list_widget.clear()
|
self.list_widget.clear()
|
||||||
for r in self.db.get_recurring_leaves():
|
for r in self.db.get_recurring_leaves():
|
||||||
desc = describe_pattern(r['pattern'])
|
desc = describe_pattern(r['pattern'])
|
||||||
end = r.get('end_date') or tr('recurring.no_end')
|
end = r.get('end_date') or '무기한'
|
||||||
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||||
f"· {r['start_date']} ~ {end}")
|
f"· {r['start_date']} ~ {end}")
|
||||||
if r.get('memo'):
|
if r.get('memo'):
|
||||||
@ -164,8 +163,8 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
return
|
return
|
||||||
rec_id = item.data(Qt.UserRole)
|
rec_id = item.data(Qt.UserRole)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, tr('recurring.delete_confirm_title'),
|
self, "삭제 확인",
|
||||||
tr('recurring.delete_confirm_body', item=item.text()),
|
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@ -185,7 +184,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
def _save(self):
|
def _save(self):
|
||||||
pattern = self._build_pattern()
|
pattern = self._build_pattern()
|
||||||
if not pattern:
|
if not pattern:
|
||||||
QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
|
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
|
||||||
return
|
return
|
||||||
days = self.days_combo.currentData()
|
days = self.days_combo.currentData()
|
||||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||||
@ -194,7 +193,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
memo = self.memo_edit.text().strip()
|
memo = self.memo_edit.text().strip()
|
||||||
|
|
||||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||||
QMessageBox.information(self, tr('recurring.add_done_title'),
|
QMessageBox.information(self, "추가 완료",
|
||||||
tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
|
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
|
||||||
self.memo_edit.clear()
|
self.memo_edit.clear()
|
||||||
self._reload_list()
|
self._reload_list()
|
||||||
|
|||||||
@ -18,7 +18,6 @@ from PyQt5.QtCore import Qt, QDate
|
|||||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||||
|
|
||||||
from core.recurring_leaves import expand_for_range, describe_pattern
|
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||||
from core.i18n import tr
|
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -39,7 +38,7 @@ class ScheduleView(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle(tr('schedule.title'))
|
self.setWindowTitle("스케줄")
|
||||||
self.setMinimumSize(820, 560)
|
self.setMinimumSize(820, 560)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._reload()
|
self._reload()
|
||||||
@ -50,16 +49,16 @@ class ScheduleView(QDialog):
|
|||||||
|
|
||||||
# 상단 툴바
|
# 상단 툴바
|
||||||
bar = QHBoxLayout()
|
bar = QHBoxLayout()
|
||||||
title = QLabel(tr('schedule.header'))
|
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
bar.addWidget(title)
|
bar.addWidget(title)
|
||||||
bar.addStretch()
|
bar.addStretch()
|
||||||
|
|
||||||
rec_btn = QPushButton(tr('schedule.btn_recurring'))
|
rec_btn = QPushButton("반복 패턴 관리")
|
||||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||||
bar.addWidget(rec_btn)
|
bar.addWidget(rec_btn)
|
||||||
|
|
||||||
add_btn = QPushButton(tr('schedule.btn_add_leave'))
|
add_btn = QPushButton("연차 등록")
|
||||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||||
bar.addWidget(add_btn)
|
bar.addWidget(add_btn)
|
||||||
|
|
||||||
@ -67,11 +66,11 @@ class ScheduleView(QDialog):
|
|||||||
|
|
||||||
# 범례
|
# 범례
|
||||||
legend = QHBoxLayout()
|
legend = QHBoxLayout()
|
||||||
for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
|
for label, color in [("공휴일", _C_HOLIDAY),
|
||||||
(tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
|
("연차 사용", _C_LEAVE_FULL_PAST),
|
||||||
(tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
|
("연차 예정", _C_LEAVE_FULL_PLAN),
|
||||||
(tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
|
("반차/반반차", _C_LEAVE_HALF_PAST),
|
||||||
(tr('schedule.legend_recurring'), _C_RECURRING)]:
|
("반복 패턴", _C_RECURRING)]:
|
||||||
sw = QLabel(f" {label} ")
|
sw = QLabel(f" {label} ")
|
||||||
sw.setStyleSheet(
|
sw.setStyleSheet(
|
||||||
f"background-color: {color.name()}; color: white; "
|
f"background-color: {color.name()}; color: white; "
|
||||||
@ -95,7 +94,7 @@ class ScheduleView(QDialog):
|
|||||||
right = QWidget()
|
right = QWidget()
|
||||||
right_layout = QVBoxLayout()
|
right_layout = QVBoxLayout()
|
||||||
|
|
||||||
self.detail_title = QLabel(tr('schedule.detail_placeholder'))
|
self.detail_title = QLabel("날짜를 선택하세요")
|
||||||
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||||
right_layout.addWidget(self.detail_title)
|
right_layout.addWidget(self.detail_title)
|
||||||
|
|
||||||
@ -110,7 +109,7 @@ class ScheduleView(QDialog):
|
|||||||
|
|
||||||
layout.addWidget(splitter, 1)
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
close_btn = QPushButton(tr('btn.close'))
|
close_btn = QPushButton("닫기")
|
||||||
close_btn.clicked.connect(self.close)
|
close_btn.clicked.connect(self.close)
|
||||||
layout.addWidget(close_btn)
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
@ -190,19 +189,18 @@ class ScheduleView(QDialog):
|
|||||||
def _on_date_click(self, qd: QDate):
|
def _on_date_click(self, qd: QDate):
|
||||||
d = date(qd.year(), qd.month(), qd.day())
|
d = date(qd.year(), qd.month(), qd.day())
|
||||||
date_str = d.isoformat()
|
date_str = d.isoformat()
|
||||||
weekday_kr = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
|
||||||
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()]}요일)")
|
||||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
|
|
||||||
self.detail_list.clear()
|
self.detail_list.clear()
|
||||||
|
|
||||||
# 휴일
|
# 휴일
|
||||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||||
if holiday:
|
if holiday:
|
||||||
item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
|
item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}")
|
||||||
item.setForeground(QBrush(QColor("#e53935")))
|
item.setForeground(QBrush(QColor("#e53935")))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
elif d.weekday() in (5, 6):
|
elif d.weekday() in (5, 6):
|
||||||
item = QListWidgetItem(tr('schedule.weekend', weekday=weekday_kr[d.weekday()]))
|
item = QListWidgetItem(f"주말 ({weekday_kr[d.weekday()]}요일)")
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
# 연차 (구체)
|
# 연차 (구체)
|
||||||
@ -210,7 +208,7 @@ class ScheduleView(QDialog):
|
|||||||
days = float(r.get('days') or 0)
|
days = float(r.get('days') or 0)
|
||||||
t = r.get('leave_type', '연차')
|
t = r.get('leave_type', '연차')
|
||||||
memo = r.get('memo') or ''
|
memo = r.get('memo') or ''
|
||||||
label = tr('schedule.leave_label', type=t, days=days)
|
label = f"{t} {days}일"
|
||||||
if memo:
|
if memo:
|
||||||
label += f" — {memo}"
|
label += f" — {memo}"
|
||||||
label += f" [id={r['id']}]"
|
label += f" [id={r['id']}]"
|
||||||
@ -223,14 +221,13 @@ class ScheduleView(QDialog):
|
|||||||
from core.recurring_leaves import expand_for_date
|
from core.recurring_leaves import expand_for_date
|
||||||
for occ in expand_for_date(recurring, d):
|
for occ in expand_for_date(recurring, d):
|
||||||
item = QListWidgetItem(
|
item = QListWidgetItem(
|
||||||
tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
|
f"{describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
|
||||||
days=occ.days, type=occ.leave_type)
|
|
||||||
)
|
)
|
||||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
if self.detail_list.count() == 0:
|
if self.detail_list.count() == 0:
|
||||||
self.detail_list.addItem(tr('schedule.no_events'))
|
self.detail_list.addItem("일정 없음")
|
||||||
|
|
||||||
def _on_page_change(self, year: int, month: int):
|
def _on_page_change(self, year: int, month: int):
|
||||||
self._reload()
|
self._reload()
|
||||||
@ -244,7 +241,7 @@ class ScheduleView(QDialog):
|
|||||||
return
|
return
|
||||||
kind, _id = data
|
kind, _id = data
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
del_act = menu.addAction(tr('schedule.delete'))
|
del_act = menu.addAction("삭제")
|
||||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||||
if chosen == del_act:
|
if chosen == del_act:
|
||||||
self._delete_record(kind, _id)
|
self._delete_record(kind, _id)
|
||||||
@ -252,8 +249,8 @@ class ScheduleView(QDialog):
|
|||||||
def _delete_record(self, kind: str, _id: int):
|
def _delete_record(self, kind: str, _id: int):
|
||||||
if kind == 'concrete':
|
if kind == 'concrete':
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, tr('schedule.delete_leave_confirm_title'),
|
self, "삭제 확인",
|
||||||
tr('schedule.delete_leave_confirm_body'),
|
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@ -264,8 +261,8 @@ class ScheduleView(QDialog):
|
|||||||
self._on_date_click(d)
|
self._on_date_click(d)
|
||||||
elif kind == 'recurring':
|
elif kind == 'recurring':
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, tr('schedule.delete_recurring_confirm_title'),
|
self, "삭제 확인",
|
||||||
tr('schedule.delete_recurring_confirm_body'),
|
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
|
|||||||
@ -58,7 +58,7 @@ class SettingsView(QDialog):
|
|||||||
main_layout.setSpacing(0)
|
main_layout.setSpacing(0)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel(tr('settings.title'))
|
title = QLabel("설정")
|
||||||
title.setObjectName("dialog_title")
|
title.setObjectName("dialog_title")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignCenter)
|
||||||
main_layout.addWidget(title)
|
main_layout.addWidget(title)
|
||||||
@ -137,16 +137,16 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 근무 패턴 프리셋
|
# 근무 패턴 프리셋
|
||||||
preset_layout = QHBoxLayout()
|
preset_layout = QHBoxLayout()
|
||||||
preset_label = QLabel(tr('settings.work_pattern'))
|
preset_label = QLabel("근무 패턴:")
|
||||||
preset_label.setFixedWidth(130)
|
preset_label.setFixedWidth(130)
|
||||||
self.work_preset_combo = QComboBox()
|
self.work_preset_combo = QComboBox()
|
||||||
# (label, work_minutes, lunch_minutes)
|
# (label, work_minutes, lunch_minutes)
|
||||||
self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60))
|
self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60))
|
||||||
self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30))
|
self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30))
|
||||||
self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60))
|
self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60))
|
||||||
self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30))
|
self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30))
|
||||||
self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0))
|
self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0))
|
||||||
self.work_preset_combo.addItem(tr('settings.preset.custom'), None)
|
self.work_preset_combo.addItem("사용자 정의", None)
|
||||||
self.work_preset_combo.setFixedWidth(260)
|
self.work_preset_combo.setFixedWidth(260)
|
||||||
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
|
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
|
||||||
preset_layout.addWidget(preset_label)
|
preset_layout.addWidget(preset_label)
|
||||||
@ -156,18 +156,18 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 하루 기본 근무 시간 (시 + 분 분리 입력)
|
# 하루 기본 근무 시간 (시 + 분 분리 입력)
|
||||||
work_hours_layout = QHBoxLayout()
|
work_hours_layout = QHBoxLayout()
|
||||||
work_hours_label = QLabel(tr('settings.daily_work'))
|
work_hours_label = QLabel("하루 기본 근무:")
|
||||||
work_hours_label.setFixedWidth(130)
|
work_hours_label.setFixedWidth(130)
|
||||||
self.work_hours_spin = QSpinBox()
|
self.work_hours_spin = QSpinBox()
|
||||||
self.work_hours_spin.setRange(0, 12)
|
self.work_hours_spin.setRange(0, 12)
|
||||||
self.work_hours_spin.setValue(8)
|
self.work_hours_spin.setValue(8)
|
||||||
self.work_hours_spin.setSuffix(tr('settings.suffix_hour'))
|
self.work_hours_spin.setSuffix(" 시간")
|
||||||
self.work_hours_spin.setFixedWidth(100)
|
self.work_hours_spin.setFixedWidth(100)
|
||||||
self.work_minutes_spin = QSpinBox()
|
self.work_minutes_spin = QSpinBox()
|
||||||
self.work_minutes_spin.setRange(0, 59)
|
self.work_minutes_spin.setRange(0, 59)
|
||||||
self.work_minutes_spin.setValue(0)
|
self.work_minutes_spin.setValue(0)
|
||||||
self.work_minutes_spin.setSingleStep(15)
|
self.work_minutes_spin.setSingleStep(15)
|
||||||
self.work_minutes_spin.setSuffix(tr('settings.suffix_minute'))
|
self.work_minutes_spin.setSuffix(" 분")
|
||||||
self.work_minutes_spin.setFixedWidth(100)
|
self.work_minutes_spin.setFixedWidth(100)
|
||||||
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
|
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
|
||||||
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
|
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||||
@ -180,34 +180,34 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 점심시간 기본값
|
# 점심시간 기본값
|
||||||
lunch_layout = QHBoxLayout()
|
lunch_layout = QHBoxLayout()
|
||||||
lunch_label = QLabel(tr('settings.lunch_default'))
|
lunch_label = QLabel("점심시간 기본:")
|
||||||
lunch_label.setFixedWidth(130)
|
lunch_label.setFixedWidth(130)
|
||||||
self.lunch_spin = QSpinBox()
|
self.lunch_spin = QSpinBox()
|
||||||
self.lunch_spin.setRange(0, 120)
|
self.lunch_spin.setRange(0, 120)
|
||||||
self.lunch_spin.setValue(60)
|
self.lunch_spin.setValue(60)
|
||||||
self.lunch_spin.setSingleStep(5)
|
self.lunch_spin.setSingleStep(5)
|
||||||
self.lunch_spin.setSuffix(tr('settings.suffix_minute'))
|
self.lunch_spin.setSuffix(" 분")
|
||||||
self.lunch_spin.setFixedWidth(110)
|
self.lunch_spin.setFixedWidth(110)
|
||||||
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
|
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||||
lunch_layout.addWidget(lunch_label)
|
lunch_layout.addWidget(lunch_label)
|
||||||
lunch_layout.addWidget(self.lunch_spin)
|
lunch_layout.addWidget(self.lunch_spin)
|
||||||
|
|
||||||
# 점심시간 자동 적용
|
# 점심시간 자동 적용
|
||||||
self.auto_lunch_check = QCheckBox(tr('settings.auto_apply'))
|
self.auto_lunch_check = QCheckBox("자동 적용")
|
||||||
self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip'))
|
self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용")
|
||||||
lunch_layout.addWidget(self.auto_lunch_check)
|
lunch_layout.addWidget(self.auto_lunch_check)
|
||||||
lunch_layout.addStretch()
|
lunch_layout.addStretch()
|
||||||
layout.addLayout(lunch_layout)
|
layout.addLayout(lunch_layout)
|
||||||
|
|
||||||
# 저녁시간 기본값
|
# 저녁시간 기본값
|
||||||
dinner_layout = QHBoxLayout()
|
dinner_layout = QHBoxLayout()
|
||||||
dinner_label = QLabel(tr('settings.dinner_default'))
|
dinner_label = QLabel("저녁시간 기본:")
|
||||||
dinner_label.setFixedWidth(130)
|
dinner_label.setFixedWidth(130)
|
||||||
self.dinner_spin = QSpinBox()
|
self.dinner_spin = QSpinBox()
|
||||||
self.dinner_spin.setRange(0, 120)
|
self.dinner_spin.setRange(0, 120)
|
||||||
self.dinner_spin.setValue(60)
|
self.dinner_spin.setValue(60)
|
||||||
self.dinner_spin.setSingleStep(5)
|
self.dinner_spin.setSingleStep(5)
|
||||||
self.dinner_spin.setSuffix(tr('settings.suffix_minute'))
|
self.dinner_spin.setSuffix(" 분")
|
||||||
self.dinner_spin.setFixedWidth(110)
|
self.dinner_spin.setFixedWidth(110)
|
||||||
dinner_layout.addWidget(dinner_label)
|
dinner_layout.addWidget(dinner_label)
|
||||||
dinner_layout.addWidget(self.dinner_spin)
|
dinner_layout.addWidget(self.dinner_spin)
|
||||||
@ -306,30 +306,30 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||||
check_row1 = QHBoxLayout()
|
check_row1 = QHBoxLayout()
|
||||||
self.clock_out_notification_check = QCheckBox(tr('settings.notif_clock_out'))
|
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
||||||
self.clock_out_notification_check.setChecked(True)
|
self.clock_out_notification_check.setChecked(True)
|
||||||
self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch'))
|
self.lunch_notification_check = QCheckBox("점심시간 등록 알림")
|
||||||
self.lunch_notification_check.setChecked(True)
|
self.lunch_notification_check.setChecked(True)
|
||||||
check_row1.addWidget(self.clock_out_notification_check)
|
check_row1.addWidget(self.clock_out_notification_check)
|
||||||
check_row1.addWidget(self.lunch_notification_check)
|
check_row1.addWidget(self.lunch_notification_check)
|
||||||
layout.addLayout(check_row1)
|
layout.addLayout(check_row1)
|
||||||
|
|
||||||
check_row2 = QHBoxLayout()
|
check_row2 = QHBoxLayout()
|
||||||
self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner'))
|
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
||||||
self.dinner_notification_check.setChecked(True)
|
self.dinner_notification_check.setChecked(True)
|
||||||
self.overtime_notification_check = QCheckBox(tr('settings.notif_overtime'))
|
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
||||||
self.overtime_notification_check.setChecked(True)
|
self.overtime_notification_check.setChecked(True)
|
||||||
check_row2.addWidget(self.dinner_notification_check)
|
check_row2.addWidget(self.dinner_notification_check)
|
||||||
check_row2.addWidget(self.overtime_notification_check)
|
check_row2.addWidget(self.overtime_notification_check)
|
||||||
layout.addLayout(check_row2)
|
layout.addLayout(check_row2)
|
||||||
|
|
||||||
check_row3 = QHBoxLayout()
|
check_row3 = QHBoxLayout()
|
||||||
self.health_notification_check = QCheckBox(tr('settings.notif_health'))
|
self.health_notification_check = QCheckBox("건강 경고 알림")
|
||||||
self.health_notification_check.setChecked(True)
|
self.health_notification_check.setChecked(True)
|
||||||
self.health_break_notification_check = QCheckBox(tr('settings.notif_break'))
|
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
|
||||||
self.health_break_notification_check.setChecked(True)
|
self.health_break_notification_check.setChecked(True)
|
||||||
self.health_break_notification_check.setToolTip(
|
self.health_break_notification_check.setToolTip(
|
||||||
tr('settings.notif_break_tooltip')
|
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
|
||||||
)
|
)
|
||||||
check_row3.addWidget(self.health_notification_check)
|
check_row3.addWidget(self.health_notification_check)
|
||||||
check_row3.addWidget(self.health_break_notification_check)
|
check_row3.addWidget(self.health_break_notification_check)
|
||||||
@ -337,75 +337,75 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 퇴근 N분 전 알림 시점 설정
|
# 퇴근 N분 전 알림 시점 설정
|
||||||
before_row = QHBoxLayout()
|
before_row = QHBoxLayout()
|
||||||
before_label = QLabel(tr('settings.notif_before'))
|
before_label = QLabel("퇴근 알림 시점:")
|
||||||
before_label.setFixedWidth(110)
|
before_label.setFixedWidth(110)
|
||||||
self.notif_before_spin = QSpinBox()
|
self.notif_before_spin = QSpinBox()
|
||||||
self.notif_before_spin.setRange(1, 120)
|
self.notif_before_spin.setRange(1, 120)
|
||||||
self.notif_before_spin.setSingleStep(5)
|
self.notif_before_spin.setSingleStep(5)
|
||||||
self.notif_before_spin.setValue(30)
|
self.notif_before_spin.setValue(30)
|
||||||
self.notif_before_spin.setSuffix(' ' + tr('settings.notif_before_spin_suffix'))
|
self.notif_before_spin.setSuffix(" 분 전")
|
||||||
self.notif_before_spin.setFixedWidth(110)
|
self.notif_before_spin.setFixedWidth(110)
|
||||||
self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip'))
|
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
||||||
before_row.addWidget(before_label)
|
before_row.addWidget(before_label)
|
||||||
before_row.addWidget(self.notif_before_spin)
|
before_row.addWidget(self.notif_before_spin)
|
||||||
before_row.addStretch()
|
before_row.addStretch()
|
||||||
layout.addLayout(before_row)
|
layout.addLayout(before_row)
|
||||||
|
|
||||||
# === 고급 임계값 (접이식 그룹박스) ===
|
# === 고급 임계값 (접이식 그룹박스) ===
|
||||||
adv_box = QGroupBox(tr('settings.advanced_thresholds'))
|
adv_box = QGroupBox("고급 임계값")
|
||||||
adv_box.setCheckable(True)
|
adv_box.setCheckable(True)
|
||||||
adv_box.setChecked(False) # 기본 접힘
|
adv_box.setChecked(False) # 기본 접힘
|
||||||
adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip'))
|
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
|
||||||
adv_layout = QVBoxLayout()
|
adv_layout = QVBoxLayout()
|
||||||
adv_layout.setSpacing(4)
|
adv_layout.setSpacing(4)
|
||||||
|
|
||||||
# 점심 알림 임계 (출근 후 N시간)
|
# 점심 알림 임계 (출근 후 N시간)
|
||||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||||
self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip'))
|
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
|
||||||
adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin))
|
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
|
||||||
|
|
||||||
# 저녁 알림 임계 (출근 후 N시간)
|
# 저녁 알림 임계 (출근 후 N시간)
|
||||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour'))
|
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
|
||||||
self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip'))
|
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
|
||||||
adv_layout.addLayout(self._labeled_row(tr('settings.dinner_alert_after'), self.dinner_reminder_spin))
|
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
|
||||||
|
|
||||||
# 연장근무 누적 임계
|
# 연장근무 누적 임계
|
||||||
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour'))
|
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
|
||||||
self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip'))
|
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
|
||||||
adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin))
|
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
|
||||||
|
|
||||||
# 주 X시간 임계
|
# 주 X시간 임계
|
||||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour'))
|
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
|
||||||
self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip'))
|
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
|
||||||
adv_layout.addLayout(self._labeled_row(tr('settings.weekly_limit'), self.weekly_hours_spin))
|
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
|
||||||
|
|
||||||
# 연속 연장근무 일수
|
# 연속 연장근무 일수
|
||||||
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day'))
|
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, " 일")
|
||||||
self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback=''))
|
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
|
||||||
adv_layout.addLayout(self._labeled_row(tr('settings.consecutive_ot'), self.health_consecutive_spin))
|
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
|
||||||
|
|
||||||
# 휴식 권고 (연속 근무 시간)
|
# 휴식 권고 (연속 근무 시간)
|
||||||
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||||
self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip'))
|
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
|
||||||
adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin))
|
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
|
||||||
|
|
||||||
adv_box.setLayout(adv_layout)
|
adv_box.setLayout(adv_layout)
|
||||||
layout.addWidget(adv_box)
|
layout.addWidget(adv_box)
|
||||||
|
|
||||||
# 시간 형식 + 테마 한 줄에
|
# 시간 형식 + 테마 한 줄에
|
||||||
format_row = QHBoxLayout()
|
format_row = QHBoxLayout()
|
||||||
time_format_label = QLabel(tr('settings.time_format'))
|
time_format_label = QLabel("시간 형식:")
|
||||||
time_format_label.setFixedWidth(70)
|
time_format_label.setFixedWidth(70)
|
||||||
self.time_format_combo = QComboBox()
|
self.time_format_combo = QComboBox()
|
||||||
self.time_format_combo.addItem(tr('settings.time_format_24'), "24")
|
self.time_format_combo.addItem("24시간 (17:30)", "24")
|
||||||
self.time_format_combo.addItem(tr('settings.time_format_12'), "12")
|
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12")
|
||||||
self.time_format_combo.setFixedWidth(180)
|
self.time_format_combo.setFixedWidth(180)
|
||||||
|
|
||||||
theme_label = QLabel(tr('settings.theme'))
|
theme_label = QLabel("테마:")
|
||||||
theme_label.setFixedWidth(40)
|
theme_label.setFixedWidth(40)
|
||||||
self.theme_combo = QComboBox()
|
self.theme_combo = QComboBox()
|
||||||
self.theme_combo.addItem(tr('settings.theme_light'), "light")
|
self.theme_combo.addItem("라이트", "light")
|
||||||
self.theme_combo.addItem(tr('settings.theme_dark'), "dark")
|
self.theme_combo.addItem("다크", "dark")
|
||||||
self.theme_combo.setFixedWidth(90)
|
self.theme_combo.setFixedWidth(90)
|
||||||
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
||||||
|
|
||||||
@ -419,7 +419,7 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 접근성: 글꼴 크기 + 고대비
|
# 접근성: 글꼴 크기 + 고대비
|
||||||
a11y_row = QHBoxLayout()
|
a11y_row = QHBoxLayout()
|
||||||
a11y_row.addWidget(QLabel(tr('settings.font_scale')))
|
a11y_row.addWidget(QLabel("글꼴 크기:"))
|
||||||
self.font_scale_combo = QComboBox()
|
self.font_scale_combo = QComboBox()
|
||||||
self.font_scale_combo.addItem("100%", "1.0")
|
self.font_scale_combo.addItem("100%", "1.0")
|
||||||
self.font_scale_combo.addItem("125%", "1.25")
|
self.font_scale_combo.addItem("125%", "1.25")
|
||||||
@ -427,8 +427,8 @@ class SettingsView(QDialog):
|
|||||||
self.font_scale_combo.setFixedWidth(90)
|
self.font_scale_combo.setFixedWidth(90)
|
||||||
a11y_row.addWidget(self.font_scale_combo)
|
a11y_row.addWidget(self.font_scale_combo)
|
||||||
a11y_row.addSpacing(16)
|
a11y_row.addSpacing(16)
|
||||||
self.high_contrast_check = QCheckBox(tr('settings.high_contrast'))
|
self.high_contrast_check = QCheckBox("고대비 모드")
|
||||||
self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip'))
|
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
|
||||||
a11y_row.addWidget(self.high_contrast_check)
|
a11y_row.addWidget(self.high_contrast_check)
|
||||||
a11y_row.addStretch()
|
a11y_row.addStretch()
|
||||||
layout.addLayout(a11y_row)
|
layout.addLayout(a11y_row)
|
||||||
@ -436,13 +436,13 @@ class SettingsView(QDialog):
|
|||||||
# 언어 선택
|
# 언어 선택
|
||||||
from core.i18n import available_languages, language_label
|
from core.i18n import available_languages, language_label
|
||||||
lang_row = QHBoxLayout()
|
lang_row = QHBoxLayout()
|
||||||
lang_label = QLabel(tr('label.language'))
|
lang_label = QLabel("언어 / Language:")
|
||||||
lang_label.setFixedWidth(120)
|
lang_label.setFixedWidth(120)
|
||||||
self.language_combo = QComboBox()
|
self.language_combo = QComboBox()
|
||||||
for code in available_languages():
|
for code in available_languages():
|
||||||
self.language_combo.addItem(language_label(code), code)
|
self.language_combo.addItem(language_label(code), code)
|
||||||
self.language_combo.setFixedWidth(140)
|
self.language_combo.setFixedWidth(140)
|
||||||
self.language_combo.setToolTip(tr('group.language_restart_tooltip', fallback=''))
|
self.language_combo.setToolTip("언어 변경은 재시작 후 완전히 적용됩니다.")
|
||||||
lang_row.addWidget(lang_label)
|
lang_row.addWidget(lang_label)
|
||||||
lang_row.addWidget(self.language_combo)
|
lang_row.addWidget(self.language_combo)
|
||||||
lang_row.addStretch()
|
lang_row.addStretch()
|
||||||
@ -459,16 +459,16 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 잔액 + 계산 단위 한 줄
|
# 잔액 + 계산 단위 한 줄
|
||||||
top_row = QHBoxLayout()
|
top_row = QHBoxLayout()
|
||||||
self.current_overtime_label = QLabel(tr('settings.current_balance'))
|
self.current_overtime_label = QLabel("현재 잔액: 계산 중...")
|
||||||
self.current_overtime_label.setObjectName("badge_success")
|
self.current_overtime_label.setObjectName("badge_success")
|
||||||
top_row.addWidget(self.current_overtime_label)
|
top_row.addWidget(self.current_overtime_label)
|
||||||
top_row.addStretch()
|
top_row.addStretch()
|
||||||
|
|
||||||
unit_label = QLabel(tr('settings.calc_unit'))
|
unit_label = QLabel("계산 단위:")
|
||||||
self.overtime_unit_combo = QComboBox()
|
self.overtime_unit_combo = QComboBox()
|
||||||
self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30)
|
self.overtime_unit_combo.addItem("30분", 30)
|
||||||
self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60)
|
self.overtime_unit_combo.addItem("1시간", 60)
|
||||||
self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15)
|
self.overtime_unit_combo.addItem("15분", 15)
|
||||||
self.overtime_unit_combo.setFixedWidth(100)
|
self.overtime_unit_combo.setFixedWidth(100)
|
||||||
top_row.addWidget(unit_label)
|
top_row.addWidget(unit_label)
|
||||||
top_row.addWidget(self.overtime_unit_combo)
|
top_row.addWidget(self.overtime_unit_combo)
|
||||||
@ -476,28 +476,28 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 초기 연장근무 설정
|
# 초기 연장근무 설정
|
||||||
initial_overtime_layout = QHBoxLayout()
|
initial_overtime_layout = QHBoxLayout()
|
||||||
initial_overtime_label = QLabel(tr('settings.initial_overtime'))
|
initial_overtime_label = QLabel("기존 연장근무:")
|
||||||
initial_overtime_label.setFixedWidth(100)
|
initial_overtime_label.setFixedWidth(100)
|
||||||
self.initial_overtime_hours = QSpinBox()
|
self.initial_overtime_hours = QSpinBox()
|
||||||
self.initial_overtime_hours.setRange(0, 200)
|
self.initial_overtime_hours.setRange(0, 200)
|
||||||
self.initial_overtime_hours.setValue(0)
|
self.initial_overtime_hours.setValue(0)
|
||||||
self.initial_overtime_hours.setSuffix(tr('settings.suffix_hour'))
|
self.initial_overtime_hours.setSuffix(" 시간")
|
||||||
self.initial_overtime_hours.setFixedWidth(110)
|
self.initial_overtime_hours.setFixedWidth(110)
|
||||||
|
|
||||||
self.initial_overtime_mins = QSpinBox()
|
self.initial_overtime_mins = QSpinBox()
|
||||||
self.initial_overtime_mins.setRange(0, 59)
|
self.initial_overtime_mins.setRange(0, 59)
|
||||||
self.initial_overtime_mins.setValue(0)
|
self.initial_overtime_mins.setValue(0)
|
||||||
self.initial_overtime_mins.setSuffix(tr('settings.suffix_minute'))
|
self.initial_overtime_mins.setSuffix(" 분")
|
||||||
self.initial_overtime_mins.setFixedWidth(100)
|
self.initial_overtime_mins.setFixedWidth(100)
|
||||||
|
|
||||||
apply_overtime_btn = QPushButton(tr('btn.apply'))
|
apply_overtime_btn = QPushButton("적용")
|
||||||
apply_overtime_btn.setObjectName("btn_small")
|
apply_overtime_btn.setObjectName("btn_small")
|
||||||
apply_overtime_btn.setFixedWidth(50)
|
apply_overtime_btn.setFixedWidth(50)
|
||||||
apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
|
apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
|
||||||
|
|
||||||
self.auto_overtime_check = QCheckBox(tr('settings.auto_bank'))
|
self.auto_overtime_check = QCheckBox("자동 적립")
|
||||||
self.auto_overtime_check.setChecked(True)
|
self.auto_overtime_check.setChecked(True)
|
||||||
self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip'))
|
self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립")
|
||||||
|
|
||||||
initial_overtime_layout.addWidget(initial_overtime_label)
|
initial_overtime_layout.addWidget(initial_overtime_label)
|
||||||
initial_overtime_layout.addWidget(self.initial_overtime_hours)
|
initial_overtime_layout.addWidget(self.initial_overtime_hours)
|
||||||
@ -507,7 +507,7 @@ class SettingsView(QDialog):
|
|||||||
initial_overtime_layout.addWidget(self.auto_overtime_check)
|
initial_overtime_layout.addWidget(self.auto_overtime_check)
|
||||||
layout.addLayout(initial_overtime_layout)
|
layout.addLayout(initial_overtime_layout)
|
||||||
|
|
||||||
initial_overtime_note = QLabel(tr('settings.initial_overtime_note', fallback=''))
|
initial_overtime_note = QLabel("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)")
|
||||||
initial_overtime_note.setObjectName("note_text")
|
initial_overtime_note.setObjectName("note_text")
|
||||||
layout.addWidget(initial_overtime_note)
|
layout.addWidget(initial_overtime_note)
|
||||||
|
|
||||||
@ -516,22 +516,22 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
def create_goal_group(self) -> QGroupBox:
|
def create_goal_group(self) -> QGroupBox:
|
||||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||||
group = QGroupBox(tr('settings.goal_group'))
|
group = QGroupBox("월간 목표 (0=비활성)")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(6)
|
||||||
|
|
||||||
# 연장근무 상한
|
# 연장근무 상한
|
||||||
ot_row = QHBoxLayout()
|
ot_row = QHBoxLayout()
|
||||||
ot_label = QLabel(tr('settings.monthly_ot_cap'))
|
ot_label = QLabel("월 연장근무 상한:")
|
||||||
ot_label.setFixedWidth(150)
|
ot_label.setFixedWidth(150)
|
||||||
self.goal_ot_h = QSpinBox()
|
self.goal_ot_h = QSpinBox()
|
||||||
self.goal_ot_h.setRange(0, 100)
|
self.goal_ot_h.setRange(0, 100)
|
||||||
self.goal_ot_h.setSuffix(tr('settings.suffix_hour'))
|
self.goal_ot_h.setSuffix(" 시간")
|
||||||
self.goal_ot_h.setFixedWidth(100)
|
self.goal_ot_h.setFixedWidth(100)
|
||||||
self.goal_ot_m = QSpinBox()
|
self.goal_ot_m = QSpinBox()
|
||||||
self.goal_ot_m.setRange(0, 59)
|
self.goal_ot_m.setRange(0, 59)
|
||||||
self.goal_ot_m.setSingleStep(30)
|
self.goal_ot_m.setSingleStep(30)
|
||||||
self.goal_ot_m.setSuffix(tr('settings.suffix_minute'))
|
self.goal_ot_m.setSuffix(" 분")
|
||||||
self.goal_ot_m.setFixedWidth(90)
|
self.goal_ot_m.setFixedWidth(90)
|
||||||
ot_row.addWidget(ot_label)
|
ot_row.addWidget(ot_label)
|
||||||
ot_row.addWidget(self.goal_ot_h)
|
ot_row.addWidget(self.goal_ot_h)
|
||||||
@ -541,18 +541,18 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 일평균 목표
|
# 일평균 목표
|
||||||
avg_row = QHBoxLayout()
|
avg_row = QHBoxLayout()
|
||||||
avg_label = QLabel(tr('settings.daily_avg_goal'))
|
avg_label = QLabel("일 평균 근무 목표:")
|
||||||
avg_label.setFixedWidth(150)
|
avg_label.setFixedWidth(150)
|
||||||
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
||||||
self.goal_avg.setRange(0, 24)
|
self.goal_avg.setRange(0, 24)
|
||||||
self.goal_avg.setSuffix(tr('settings.suffix_hour'))
|
self.goal_avg.setSuffix(" 시간")
|
||||||
self.goal_avg.setFixedWidth(100)
|
self.goal_avg.setFixedWidth(100)
|
||||||
avg_row.addWidget(avg_label)
|
avg_row.addWidget(avg_label)
|
||||||
avg_row.addWidget(self.goal_avg)
|
avg_row.addWidget(self.goal_avg)
|
||||||
avg_row.addStretch()
|
avg_row.addStretch()
|
||||||
layout.addLayout(avg_row)
|
layout.addLayout(avg_row)
|
||||||
|
|
||||||
note = QLabel(tr('settings.goal_note', fallback=''))
|
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
|
||||||
note.setObjectName("note_text")
|
note.setObjectName("note_text")
|
||||||
layout.addWidget(note)
|
layout.addWidget(note)
|
||||||
|
|
||||||
@ -567,40 +567,40 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 연차 개수 + 남은 연차 한 줄
|
# 연차 개수 + 남은 연차 한 줄
|
||||||
top_row = QHBoxLayout()
|
top_row = QHBoxLayout()
|
||||||
annual_leave_label = QLabel(tr('settings.annual_leave'))
|
annual_leave_label = QLabel("연간 연차:")
|
||||||
annual_leave_label.setFixedWidth(70)
|
annual_leave_label.setFixedWidth(70)
|
||||||
self.annual_leave_days = QSpinBox()
|
self.annual_leave_days = QSpinBox()
|
||||||
self.annual_leave_days.setRange(0, 30)
|
self.annual_leave_days.setRange(0, 30)
|
||||||
self.annual_leave_days.setValue(15)
|
self.annual_leave_days.setValue(15)
|
||||||
self.annual_leave_days.setSuffix(tr('label.unit_day'))
|
self.annual_leave_days.setSuffix(" 일")
|
||||||
self.annual_leave_days.setFixedWidth(100)
|
self.annual_leave_days.setFixedWidth(100)
|
||||||
top_row.addWidget(annual_leave_label)
|
top_row.addWidget(annual_leave_label)
|
||||||
top_row.addWidget(self.annual_leave_days)
|
top_row.addWidget(self.annual_leave_days)
|
||||||
top_row.addStretch()
|
top_row.addStretch()
|
||||||
|
|
||||||
self.remaining_leave_label = QLabel(tr('settings.remaining_leave'))
|
self.remaining_leave_label = QLabel("남은 연차: 계산 중...")
|
||||||
self.remaining_leave_label.setObjectName("badge_leave")
|
self.remaining_leave_label.setObjectName("badge_leave")
|
||||||
top_row.addWidget(self.remaining_leave_label)
|
top_row.addWidget(self.remaining_leave_label)
|
||||||
layout.addLayout(top_row)
|
layout.addLayout(top_row)
|
||||||
|
|
||||||
# 기존 사용 연차 설정
|
# 기존 사용 연차 설정
|
||||||
used_leave_layout = QHBoxLayout()
|
used_leave_layout = QHBoxLayout()
|
||||||
used_leave_label = QLabel(tr('settings.used_leave'))
|
used_leave_label = QLabel("기존 사용:")
|
||||||
used_leave_label.setFixedWidth(70)
|
used_leave_label.setFixedWidth(70)
|
||||||
self.used_leave_hours = QSpinBox()
|
self.used_leave_hours = QSpinBox()
|
||||||
self.used_leave_hours.setRange(0, 200)
|
self.used_leave_hours.setRange(0, 200)
|
||||||
self.used_leave_hours.setValue(0)
|
self.used_leave_hours.setValue(0)
|
||||||
self.used_leave_hours.setSuffix(tr('settings.suffix_hour'))
|
self.used_leave_hours.setSuffix(" 시간")
|
||||||
self.used_leave_hours.setFixedWidth(110)
|
self.used_leave_hours.setFixedWidth(110)
|
||||||
|
|
||||||
self.used_leave_mins = QSpinBox()
|
self.used_leave_mins = QSpinBox()
|
||||||
self.used_leave_mins.setRange(0, 59)
|
self.used_leave_mins.setRange(0, 59)
|
||||||
self.used_leave_mins.setValue(0)
|
self.used_leave_mins.setValue(0)
|
||||||
self.used_leave_mins.setSuffix(tr('settings.suffix_minute'))
|
self.used_leave_mins.setSuffix(" 분")
|
||||||
self.used_leave_mins.setSingleStep(30)
|
self.used_leave_mins.setSingleStep(30)
|
||||||
self.used_leave_mins.setFixedWidth(100)
|
self.used_leave_mins.setFixedWidth(100)
|
||||||
|
|
||||||
apply_used_leave_btn = QPushButton(tr('btn.apply'))
|
apply_used_leave_btn = QPushButton("적용")
|
||||||
apply_used_leave_btn.setObjectName("btn_small")
|
apply_used_leave_btn.setObjectName("btn_small")
|
||||||
apply_used_leave_btn.setFixedWidth(50)
|
apply_used_leave_btn.setFixedWidth(50)
|
||||||
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
|
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
|
||||||
@ -612,7 +612,7 @@ class SettingsView(QDialog):
|
|||||||
used_leave_layout.addStretch()
|
used_leave_layout.addStretch()
|
||||||
layout.addLayout(used_leave_layout)
|
layout.addLayout(used_leave_layout)
|
||||||
|
|
||||||
used_leave_note = QLabel(tr('settings.used_leave_note', fallback=''))
|
used_leave_note = QLabel("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)")
|
||||||
used_leave_note.setObjectName("note_text")
|
used_leave_note.setObjectName("note_text")
|
||||||
layout.addWidget(used_leave_note)
|
layout.addWidget(used_leave_note)
|
||||||
|
|
||||||
@ -627,33 +627,33 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 공휴일 목록 + 버튼 한 줄
|
# 공휴일 목록 + 버튼 한 줄
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
holiday_list_label = QLabel(tr('settings.registered'))
|
holiday_list_label = QLabel("등록:")
|
||||||
button_layout.addWidget(holiday_list_label)
|
button_layout.addWidget(holiday_list_label)
|
||||||
|
|
||||||
self.holiday_count_label = QLabel(tr('settings.holiday_count', count=0, year=datetime.now().year))
|
self.holiday_count_label = QLabel("0개")
|
||||||
self.holiday_count_label.setObjectName("info_text")
|
self.holiday_count_label.setObjectName("info_text")
|
||||||
button_layout.addWidget(self.holiday_count_label)
|
button_layout.addWidget(self.holiday_count_label)
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
|
|
||||||
add_korean_btn = QPushButton(tr('settings.add_korean_holidays'))
|
add_korean_btn = QPushButton("한국 공휴일 (자동)")
|
||||||
add_korean_btn.setObjectName("btn_small")
|
add_korean_btn.setObjectName("btn_small")
|
||||||
add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip'))
|
add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록")
|
||||||
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
|
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
|
||||||
button_layout.addWidget(add_korean_btn)
|
button_layout.addWidget(add_korean_btn)
|
||||||
|
|
||||||
add_custom_btn = QPushButton(tr('btn.add'))
|
add_custom_btn = QPushButton("추가")
|
||||||
add_custom_btn.setObjectName("btn_small")
|
add_custom_btn.setObjectName("btn_small")
|
||||||
add_custom_btn.clicked.connect(self.add_custom_holiday)
|
add_custom_btn.clicked.connect(self.add_custom_holiday)
|
||||||
button_layout.addWidget(add_custom_btn)
|
button_layout.addWidget(add_custom_btn)
|
||||||
|
|
||||||
view_holidays_btn = QPushButton(tr('settings.list'))
|
view_holidays_btn = QPushButton("목록")
|
||||||
view_holidays_btn.setObjectName("btn_small")
|
view_holidays_btn.setObjectName("btn_small")
|
||||||
view_holidays_btn.clicked.connect(self.view_holidays)
|
view_holidays_btn.clicked.connect(self.view_holidays)
|
||||||
button_layout.addWidget(view_holidays_btn)
|
button_layout.addWidget(view_holidays_btn)
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
holiday_note = QLabel(tr('settings.holiday_note', fallback=''))
|
holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다")
|
||||||
holiday_note.setObjectName("note_text")
|
holiday_note.setObjectName("note_text")
|
||||||
layout.addWidget(holiday_note)
|
layout.addWidget(holiday_note)
|
||||||
|
|
||||||
@ -668,7 +668,7 @@ class SettingsView(QDialog):
|
|||||||
"""공휴일 개수 표시 업데이트"""
|
"""공휴일 개수 표시 업데이트"""
|
||||||
current_year = datetime.now().year
|
current_year = datetime.now().year
|
||||||
holidays = self.db.get_holidays_by_year(current_year)
|
holidays = self.db.get_holidays_by_year(current_year)
|
||||||
self.holiday_count_label.setText(tr('settings.holiday_count', count=len(holidays), year=current_year))
|
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
||||||
|
|
||||||
def add_korean_holidays_auto(self):
|
def add_korean_holidays_auto(self):
|
||||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||||
@ -680,13 +680,19 @@ class SettingsView(QDialog):
|
|||||||
current_year = now.year
|
current_year = now.year
|
||||||
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
||||||
include_next = now.month >= 11
|
include_next = now.month >= 11
|
||||||
target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1)
|
target_label = (f"{current_year}년 + {current_year + 1}년"
|
||||||
if include_next else tr('settings.korean_holidays_years_label_single', year=current_year))
|
if include_next else f"{current_year}년")
|
||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('settings.korean_holidays_title'),
|
"한국 공휴일 자동 추가",
|
||||||
tr('settings.korean_holidays_body', years=target_label),
|
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||||
|
"포함:\n"
|
||||||
|
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
||||||
|
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||||
|
"• 정부 지정 대체·임시공휴일\n\n"
|
||||||
|
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
||||||
|
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.Yes:
|
if reply != QMessageBox.Yes:
|
||||||
@ -701,16 +707,18 @@ class SettingsView(QDialog):
|
|||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
tr('settings.package_not_installed'),
|
"패키지 미설치",
|
||||||
tr('settings.package_fallback_body', hint=tr('settings.package_install_hint'))
|
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n"
|
||||||
|
"음력/임시공휴일 자동 등록을 원하시면:\n"
|
||||||
|
" pip install holidays"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.add_done'),
|
"추가 완료",
|
||||||
tr('settings.korean_holidays_added', year=current_year, count=added)
|
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_custom_holiday(self):
|
def add_custom_holiday(self):
|
||||||
@ -721,8 +729,8 @@ class SettingsView(QDialog):
|
|||||||
today = datetime.now().date().isoformat()
|
today = datetime.now().date().isoformat()
|
||||||
date_str, ok = QInputDialog.getText(
|
date_str, ok = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
tr('settings.holiday_add_title'),
|
"공휴일 추가",
|
||||||
tr('settings.holiday_date_prompt'),
|
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):",
|
||||||
QLineEdit.Normal,
|
QLineEdit.Normal,
|
||||||
today
|
today
|
||||||
)
|
)
|
||||||
@ -736,16 +744,16 @@ class SettingsView(QDialog):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
tr('msg.input_error.title'),
|
"입력 오류",
|
||||||
tr('msg.input_error.date_format')
|
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 공휴일 이름 입력
|
# 공휴일 이름 입력
|
||||||
name, ok = QInputDialog.getText(
|
name, ok = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
tr('settings.holiday_add_title'),
|
"공휴일 추가",
|
||||||
tr('settings.holiday_name_prompt'),
|
"공휴일 이름을 입력하세요:",
|
||||||
QLineEdit.Normal,
|
QLineEdit.Normal,
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
@ -759,8 +767,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.add_done'),
|
"추가 완료",
|
||||||
tr('settings.holiday_added', date=date_str, name=name)
|
f"공휴일이 추가되었습니다.\n{date_str}: {name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def view_holidays(self):
|
def view_holidays(self):
|
||||||
@ -771,28 +779,26 @@ class SettingsView(QDialog):
|
|||||||
if not holidays:
|
if not holidays:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.holiday_list_title'),
|
"공휴일 목록",
|
||||||
tr('stats.no_data')
|
f"{current_year}년에 등록된 공휴일이 없습니다."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 목록 생성
|
# 목록 생성
|
||||||
holiday_list = tr('settings.holiday_list_header', year=current_year)
|
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n"
|
||||||
for h in holidays:
|
for h in holidays:
|
||||||
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
||||||
weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}")
|
weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()]
|
||||||
recurring = tr('label.recurring_yearly') if h['is_recurring'] else ""
|
recurring = " (매년)" if h['is_recurring'] else ""
|
||||||
holiday_list += tr('settings.holiday_list_item',
|
holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n"
|
||||||
date=h['date'], weekday=weekday,
|
|
||||||
name=h['name'], recurring=recurring)
|
|
||||||
|
|
||||||
holiday_list += '\n' + tr('settings.holiday_total', count=len(holidays))
|
holiday_list += f"\n총 {len(holidays)}개"
|
||||||
|
|
||||||
# 삭제 옵션 제공
|
# 삭제 옵션 제공
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('settings.holiday_list_title'),
|
"공휴일 목록",
|
||||||
holiday_list + tr('settings.holiday_delete_confirm'),
|
holiday_list + "\n\n공휴일을 삭제하시겠습니까?",
|
||||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -813,8 +819,8 @@ class SettingsView(QDialog):
|
|||||||
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
||||||
item, ok = QInputDialog.getItem(
|
item, ok = QInputDialog.getItem(
|
||||||
self,
|
self,
|
||||||
tr('settings.holiday_delete_title'),
|
"공휴일 삭제",
|
||||||
tr('settings.holiday_delete_prompt'),
|
"삭제할 공휴일을 선택하세요:",
|
||||||
items,
|
items,
|
||||||
0,
|
0,
|
||||||
False
|
False
|
||||||
@ -824,7 +830,7 @@ class SettingsView(QDialog):
|
|||||||
date_str = item.split(":")[0]
|
date_str = item.split(":")[0]
|
||||||
self.db.delete_holiday_by_date(date_str)
|
self.db.delete_holiday_by_date(date_str)
|
||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.information(self, tr('settings.delete_done'), tr('settings.holiday_deleted', item=item))
|
QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.")
|
||||||
|
|
||||||
def create_data_group(self) -> QGroupBox:
|
def create_data_group(self) -> QGroupBox:
|
||||||
"""데이터 관리 그룹"""
|
"""데이터 관리 그룹"""
|
||||||
@ -835,22 +841,22 @@ class SettingsView(QDialog):
|
|||||||
# CSV 내보내기 버튼들 한 줄
|
# CSV 내보내기 버튼들 한 줄
|
||||||
export_layout = QHBoxLayout()
|
export_layout = QHBoxLayout()
|
||||||
|
|
||||||
export_work_btn = QPushButton(tr('settings.export_work'))
|
export_work_btn = QPushButton("근무기록")
|
||||||
export_work_btn.setObjectName("btn_small")
|
export_work_btn.setObjectName("btn_small")
|
||||||
export_work_btn.clicked.connect(self.export_work_records)
|
export_work_btn.clicked.connect(self.export_work_records)
|
||||||
export_layout.addWidget(export_work_btn)
|
export_layout.addWidget(export_work_btn)
|
||||||
|
|
||||||
export_overtime_btn = QPushButton(tr('settings.export_overtime'))
|
export_overtime_btn = QPushButton("연장근무")
|
||||||
export_overtime_btn.setObjectName("btn_small")
|
export_overtime_btn.setObjectName("btn_small")
|
||||||
export_overtime_btn.clicked.connect(self.export_overtime_summary)
|
export_overtime_btn.clicked.connect(self.export_overtime_summary)
|
||||||
export_layout.addWidget(export_overtime_btn)
|
export_layout.addWidget(export_overtime_btn)
|
||||||
|
|
||||||
monthly_btn = QPushButton(tr('settings.export_monthly'))
|
monthly_btn = QPushButton("월간 요약")
|
||||||
monthly_btn.setObjectName("btn_small")
|
monthly_btn.setObjectName("btn_small")
|
||||||
monthly_btn.clicked.connect(self.export_monthly_summary)
|
monthly_btn.clicked.connect(self.export_monthly_summary)
|
||||||
export_layout.addWidget(monthly_btn)
|
export_layout.addWidget(monthly_btn)
|
||||||
|
|
||||||
export_label = QLabel(tr('settings.export_csv'))
|
export_label = QLabel("CSV 내보내기")
|
||||||
export_label.setObjectName("note_text")
|
export_label.setObjectName("note_text")
|
||||||
export_layout.addWidget(export_label)
|
export_layout.addWidget(export_label)
|
||||||
export_layout.addStretch()
|
export_layout.addStretch()
|
||||||
@ -859,12 +865,12 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# CSV 가져오기
|
# CSV 가져오기
|
||||||
import_layout = QHBoxLayout()
|
import_layout = QHBoxLayout()
|
||||||
import_btn = QPushButton(tr('settings.import_csv'))
|
import_btn = QPushButton("CSV 가져오기")
|
||||||
import_btn.setObjectName("btn_small")
|
import_btn.setObjectName("btn_small")
|
||||||
import_btn.setToolTip(tr('settings.import_tooltip'))
|
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
||||||
import_btn.clicked.connect(self._import_csv)
|
import_btn.clicked.connect(self._import_csv)
|
||||||
import_layout.addWidget(import_btn)
|
import_layout.addWidget(import_btn)
|
||||||
import_label = QLabel(tr('settings.import_format'))
|
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
|
||||||
import_label.setObjectName("note_text")
|
import_label.setObjectName("note_text")
|
||||||
import_layout.addWidget(import_label)
|
import_layout.addWidget(import_label)
|
||||||
import_layout.addStretch()
|
import_layout.addStretch()
|
||||||
@ -872,14 +878,14 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# DB 경로 설정 (클라우드 동기화 가능)
|
# DB 경로 설정 (클라우드 동기화 가능)
|
||||||
db_path_layout = QHBoxLayout()
|
db_path_layout = QHBoxLayout()
|
||||||
db_path_label = QLabel(tr('settings.db_path_label'))
|
db_path_label = QLabel("DB 경로:")
|
||||||
db_path_label.setFixedWidth(60)
|
db_path_label.setFixedWidth(60)
|
||||||
self.db_path_edit = QLineEdit()
|
self.db_path_edit = QLineEdit()
|
||||||
self.db_path_edit.setReadOnly(True)
|
self.db_path_edit.setReadOnly(True)
|
||||||
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
|
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
|
||||||
db_path_btn = QPushButton(tr('settings.change'))
|
db_path_btn = QPushButton("변경...")
|
||||||
db_path_btn.setObjectName("btn_small")
|
db_path_btn.setObjectName("btn_small")
|
||||||
db_path_btn.setToolTip(tr('settings.db_path_tooltip'))
|
db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.")
|
||||||
db_path_btn.clicked.connect(self._change_db_path)
|
db_path_btn.clicked.connect(self._change_db_path)
|
||||||
db_path_layout.addWidget(db_path_label)
|
db_path_layout.addWidget(db_path_label)
|
||||||
db_path_layout.addWidget(self.db_path_edit, 1)
|
db_path_layout.addWidget(self.db_path_edit, 1)
|
||||||
@ -887,35 +893,39 @@ class SettingsView(QDialog):
|
|||||||
layout.addLayout(db_path_layout)
|
layout.addLayout(db_path_layout)
|
||||||
|
|
||||||
# 자동 외출 (화면 잠금 시)
|
# 자동 외출 (화면 잠금 시)
|
||||||
self.auto_break_check = QCheckBox(tr('settings.auto_break_lock'))
|
self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀")
|
||||||
self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback=''))
|
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
||||||
layout.addWidget(self.auto_break_check)
|
layout.addWidget(self.auto_break_check)
|
||||||
|
|
||||||
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
||||||
feedback_layout = QHBoxLayout()
|
feedback_layout = QHBoxLayout()
|
||||||
feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback=''))
|
feedback_label = QLabel("Gitea 피드백:")
|
||||||
feedback_label.setFixedWidth(80)
|
feedback_label.setFixedWidth(80)
|
||||||
self.gitea_token_edit = QLineEdit()
|
self.gitea_token_edit = QLineEdit()
|
||||||
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
||||||
self.gitea_token_edit.setPlaceholderText(tr('settings.gitea_token_placeholder', fallback=''))
|
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
|
||||||
feedback_layout.addWidget(feedback_label)
|
feedback_layout.addWidget(feedback_label)
|
||||||
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
||||||
layout.addLayout(feedback_layout)
|
layout.addLayout(feedback_layout)
|
||||||
|
|
||||||
self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback'))
|
self.gitea_feedback_enabled_check = QCheckBox(
|
||||||
|
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
||||||
|
)
|
||||||
layout.addWidget(self.gitea_feedback_enabled_check)
|
layout.addWidget(self.gitea_feedback_enabled_check)
|
||||||
|
|
||||||
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
||||||
self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock'))
|
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
||||||
self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback=''))
|
self.clock_in_unlock_check.setToolTip(
|
||||||
|
"PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다."
|
||||||
|
)
|
||||||
layout.addWidget(self.clock_in_unlock_check)
|
layout.addWidget(self.clock_in_unlock_check)
|
||||||
|
|
||||||
# 업데이트 확인
|
# 업데이트 확인
|
||||||
update_layout = QHBoxLayout()
|
update_layout = QHBoxLayout()
|
||||||
from core.version import __version__
|
from core.version import __version__
|
||||||
version_label = QLabel(tr('settings.version', version=__version__))
|
version_label = QLabel(f"버전: v{__version__}")
|
||||||
version_label.setObjectName("note_text")
|
version_label.setObjectName("note_text")
|
||||||
update_btn = QPushButton(tr('settings.check_update'))
|
update_btn = QPushButton("업데이트 확인 (F5)")
|
||||||
update_btn.setObjectName("btn_small")
|
update_btn.setObjectName("btn_small")
|
||||||
update_btn.clicked.connect(self._check_updates)
|
update_btn.clicked.connect(self._check_updates)
|
||||||
update_layout.addWidget(version_label)
|
update_layout.addWidget(version_label)
|
||||||
@ -931,7 +941,7 @@ class SettingsView(QDialog):
|
|||||||
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
||||||
new_path, _ = QFileDialog.getSaveFileName(
|
new_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
tr('settings.select_db'),
|
"데이터베이스 파일 선택",
|
||||||
current,
|
current,
|
||||||
"SQLite Database (*.db)"
|
"SQLite Database (*.db)"
|
||||||
)
|
)
|
||||||
@ -942,8 +952,10 @@ class SettingsView(QDialog):
|
|||||||
self.db_path_edit.setText(new_path)
|
self.db_path_edit.setText(new_path)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.db_path_label')[:-1],
|
"DB 경로 변경",
|
||||||
tr('settings.db_path_saved', path=new_path)
|
f"새 경로가 저장되었습니다:\n{new_path}\n\n"
|
||||||
|
"기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n"
|
||||||
|
"프로그램을 재시작하세요."
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_settings(self):
|
def load_settings(self):
|
||||||
@ -1093,7 +1105,7 @@ class SettingsView(QDialog):
|
|||||||
def _import_csv(self):
|
def _import_csv(self):
|
||||||
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
self, tr('settings.import_csv'),
|
self, "CSV 가져오기",
|
||||||
os.path.expanduser("~"),
|
os.path.expanduser("~"),
|
||||||
"CSV files (*.csv);;All files (*.*)",
|
"CSV files (*.csv);;All files (*.*)",
|
||||||
)
|
)
|
||||||
@ -1103,17 +1115,19 @@ class SettingsView(QDialog):
|
|||||||
from utils.csv_importer import parse_csv, import_records
|
from utils.csv_importer import parse_csv, import_records
|
||||||
rows = parse_csv(path)
|
rows = parse_csv(path)
|
||||||
except (FileNotFoundError, ValueError) as e:
|
except (FileNotFoundError, ValueError) as e:
|
||||||
QMessageBox.critical(self, tr('settings.parse_failed'), str(e))
|
QMessageBox.critical(self, "파싱 실패", str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body'))
|
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
||||||
return
|
return
|
||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('settings.conflict_title'),
|
"충돌 처리",
|
||||||
tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'),
|
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
||||||
|
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
||||||
|
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
||||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Cancel:
|
if reply == QMessageBox.Cancel:
|
||||||
@ -1123,12 +1137,12 @@ class SettingsView(QDialog):
|
|||||||
try:
|
try:
|
||||||
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, tr('settings.import_failed'), str(e))
|
QMessageBox.critical(self, "가져오기 실패", str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, tr('settings.import_complete'),
|
self, "완료",
|
||||||
tr('settings.import_result', added=added, updated=updated, skipped=skipped)
|
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_updates(self):
|
def _check_updates(self):
|
||||||
@ -1221,8 +1235,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.save_done'),
|
"저장 완료",
|
||||||
tr('settings.save_done_body')
|
"설정이 저장되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 부모 윈도우에 설정 변경 알림
|
# 부모 윈도우에 설정 변경 알림
|
||||||
@ -1238,8 +1252,9 @@ class SettingsView(QDialog):
|
|||||||
set_language_and_retranslate(new_lang)
|
set_language_and_retranslate(new_lang)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('settings.restart_title'),
|
"재시작 / Restart",
|
||||||
tr('settings.restart_body'),
|
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
|
||||||
|
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@ -1271,8 +1286,10 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('settings.initial_overtime_title'),
|
"기존 연장근무 설정",
|
||||||
tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
||||||
|
f"변경할 값: {hours}시간 {mins}분\n\n"
|
||||||
|
f"기존 연장근무 시간을 변경하시겠습니까?",
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1282,8 +1299,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.initial_overtime_done'),
|
"설정 완료",
|
||||||
tr('settings.initial_overtime_done_body', hours=hours, mins=mins)
|
f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 부모 윈도우 잔액 업데이트
|
# 부모 윈도우 잔액 업데이트
|
||||||
@ -1295,8 +1312,8 @@ class SettingsView(QDialog):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
tr('settings.error'),
|
"오류",
|
||||||
tr('settings.initial_overtime_error', error=str(e))
|
f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_overtime_balance_display(self):
|
def update_overtime_balance_display(self):
|
||||||
@ -1304,7 +1321,7 @@ class SettingsView(QDialog):
|
|||||||
balance_minutes = self.db.get_total_overtime_balance()
|
balance_minutes = self.db.get_total_overtime_balance()
|
||||||
hours = balance_minutes // 60
|
hours = balance_minutes // 60
|
||||||
minutes = balance_minutes % 60
|
minutes = balance_minutes % 60
|
||||||
self.current_overtime_label.setText(tr('settings.current_overtime_balance', hours=hours, minutes=minutes, balance=balance_minutes))
|
self.current_overtime_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance_minutes}분)")
|
||||||
|
|
||||||
def update_remaining_leave(self):
|
def update_remaining_leave(self):
|
||||||
"""남은 연차 계산 및 표시"""
|
"""남은 연차 계산 및 표시"""
|
||||||
@ -1330,7 +1347,7 @@ class SettingsView(QDialog):
|
|||||||
remaining = total_annual - total_used
|
remaining = total_annual - total_used
|
||||||
|
|
||||||
self.remaining_leave_label.setText(
|
self.remaining_leave_label.setText(
|
||||||
tr('settings.remaining_leave_fmt', remaining=remaining, total=total_annual, used=total_used)
|
f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_work_records(self):
|
def export_work_records(self):
|
||||||
@ -1341,14 +1358,14 @@ class SettingsView(QDialog):
|
|||||||
records = stats.get('records', [])
|
records = stats.get('records', [])
|
||||||
|
|
||||||
if not records:
|
if not records:
|
||||||
QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records'))
|
QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 파일 경로 선택
|
# 파일 경로 선택
|
||||||
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
|
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
tr('settings.save_work_title'),
|
"근무 기록 저장",
|
||||||
default_filename,
|
default_filename,
|
||||||
"CSV Files (*.csv)"
|
"CSV Files (*.csv)"
|
||||||
)
|
)
|
||||||
@ -1358,17 +1375,17 @@ class SettingsView(QDialog):
|
|||||||
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.export_done'),
|
"내보내기 완료",
|
||||||
tr('settings.work_exported', path=saved_path)
|
f"근무 기록이 저장되었습니다.\n{saved_path}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
||||||
|
|
||||||
def export_overtime_summary(self):
|
def export_overtime_summary(self):
|
||||||
"""연장근무 내역 내보내기"""
|
"""연장근무 내역 내보내기"""
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
tr('settings.save_ot_title'),
|
"연장근무 내역 저장",
|
||||||
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
||||||
"CSV Files (*.csv)"
|
"CSV Files (*.csv)"
|
||||||
)
|
)
|
||||||
@ -1378,18 +1395,18 @@ class SettingsView(QDialog):
|
|||||||
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
|
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.export_done'),
|
"내보내기 완료",
|
||||||
tr('settings.ot_exported', path=saved_path)
|
f"연장근무 내역이 저장되었습니다.\n{saved_path}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
||||||
|
|
||||||
def export_monthly_summary(self):
|
def export_monthly_summary(self):
|
||||||
"""월간 요약 내보내기"""
|
"""월간 요약 내보내기"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
tr('settings.save_monthly_title'),
|
"월간 요약 저장",
|
||||||
f"monthly_summary_{now.year}{now.month:02d}.csv",
|
f"monthly_summary_{now.year}{now.month:02d}.csv",
|
||||||
"CSV Files (*.csv)"
|
"CSV Files (*.csv)"
|
||||||
)
|
)
|
||||||
@ -1399,11 +1416,11 @@ class SettingsView(QDialog):
|
|||||||
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
|
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.export_done'),
|
"내보내기 완료",
|
||||||
tr('settings.monthly_exported', path=saved_path)
|
f"월간 요약이 저장되었습니다.\n{saved_path}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
||||||
|
|
||||||
def apply_used_leave(self):
|
def apply_used_leave(self):
|
||||||
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
|
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
|
||||||
@ -1418,8 +1435,10 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
tr('settings.initial_leave_title'),
|
"기존 사용 연차 설정",
|
||||||
tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
||||||
|
f"변경할 값: {hours}시간 {mins}분\n\n"
|
||||||
|
f"기존 사용 연차를 변경하시겠습니까?",
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1429,8 +1448,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
tr('settings.initial_leave_done'),
|
"설정 완료",
|
||||||
tr('settings.initial_leave_done_body', hours=hours, mins=mins)
|
f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 남은 연차 재계산
|
# 남은 연차 재계산
|
||||||
|
|||||||
@ -93,13 +93,13 @@ class StatsView(QDialog):
|
|||||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||||
cards_row = QHBoxLayout()
|
cards_row = QHBoxLayout()
|
||||||
cards_row.setSpacing(10)
|
cards_row.setSpacing(10)
|
||||||
self.weekly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||||
theme='blue', icon='clock')
|
theme='blue', icon='clock')
|
||||||
self.weekly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_week'),
|
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||||
theme='cyan', icon='calendar')
|
theme='cyan', icon='calendar')
|
||||||
self.weekly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||||
theme='green', icon='chart')
|
theme='green', icon='chart')
|
||||||
self.weekly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_week'),
|
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
||||||
theme='gold', icon='flame')
|
theme='gold', icon='flame')
|
||||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||||
self.weekly_avg_card, self.weekly_ot_card):
|
self.weekly_avg_card, self.weekly_ot_card):
|
||||||
@ -109,7 +109,7 @@ class StatsView(QDialog):
|
|||||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.weekly_chart = make_chart_widget(widget)
|
self.weekly_chart = make_chart_widget(widget)
|
||||||
chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart,
|
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||||
theme='gray', icon='trending-up')
|
theme='gray', icon='trending-up')
|
||||||
layout.addWidget(chart_card, 1)
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
@ -127,13 +127,13 @@ class StatsView(QDialog):
|
|||||||
# 카드 4개
|
# 카드 4개
|
||||||
cards_row = QHBoxLayout()
|
cards_row = QHBoxLayout()
|
||||||
cards_row.setSpacing(10)
|
cards_row.setSpacing(10)
|
||||||
self.monthly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
||||||
theme='blue', icon='clock')
|
theme='blue', icon='clock')
|
||||||
self.monthly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_month'),
|
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||||
theme='cyan', icon='calendar')
|
theme='cyan', icon='calendar')
|
||||||
self.monthly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||||
theme='green', icon='chart')
|
theme='green', icon='chart')
|
||||||
self.monthly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_month'),
|
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
||||||
theme='gold', icon='flame')
|
theme='gold', icon='flame')
|
||||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||||
self.monthly_avg_card, self.monthly_ot_card):
|
self.monthly_avg_card, self.monthly_ot_card):
|
||||||
@ -159,7 +159,7 @@ class StatsView(QDialog):
|
|||||||
# 월간 차트
|
# 월간 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.monthly_chart = make_chart_widget(widget)
|
self.monthly_chart = make_chart_widget(widget)
|
||||||
chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart,
|
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||||
theme='gray', icon='chart')
|
theme='gray', icon='chart')
|
||||||
layout.addWidget(chart_card, 1)
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
@ -182,13 +182,13 @@ class StatsView(QDialog):
|
|||||||
f"font-size: 11pt; color: {tc('text')}; "
|
f"font-size: 11pt; color: {tc('text')}; "
|
||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
)
|
)
|
||||||
layout.addWidget(build_section_card(tr('stats.pattern_insights'), self.pattern_text,
|
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||||
theme='cyan', icon='search'))
|
theme='cyan', icon='search'))
|
||||||
|
|
||||||
# 출근 시각 분포 차트
|
# 출근 시각 분포 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.clock_in_chart = make_chart_widget(widget)
|
self.clock_in_chart = make_chart_widget(widget)
|
||||||
layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
|
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||||
theme='gray', icon='clock'), 1)
|
theme='gray', icon='clock'), 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
@ -225,15 +225,15 @@ class StatsView(QDialog):
|
|||||||
# 주간 통계
|
# 주간 통계
|
||||||
weekly_stats = self.db.get_weekly_stats()
|
weekly_stats = self.db.get_weekly_stats()
|
||||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||||
self._set_card_value(self.weekly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
|
||||||
self._set_card_value(self.weekly_days_card, tr('stats.value_days', days=weekly_stats.get('work_days', 0)))
|
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}일")
|
||||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||||
self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}"))
|
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간")
|
||||||
|
|
||||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||||
overtime_hours = overtime_minutes // 60
|
overtime_hours = overtime_minutes // 60
|
||||||
overtime_mins = overtime_minutes % 60
|
overtime_mins = overtime_minutes % 60
|
||||||
self._set_card_value(self.weekly_ot_card, tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
|
||||||
|
|
||||||
# 주간 차트
|
# 주간 차트
|
||||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||||
@ -251,21 +251,21 @@ class StatsView(QDialog):
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||||
self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
|
||||||
work_days = monthly_stats.get('work_days', 0) or 0
|
work_days = monthly_stats.get('work_days', 0) or 0
|
||||||
self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days))
|
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
||||||
|
|
||||||
if work_days > 0:
|
if work_days > 0:
|
||||||
avg = total_hours / work_days
|
avg = total_hours / work_days
|
||||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}"))
|
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
|
||||||
else:
|
else:
|
||||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0))
|
self._set_card_value(self.monthly_avg_card, "0시간")
|
||||||
|
|
||||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||||
overtime_hours = overtime_minutes // 60
|
overtime_hours = overtime_minutes // 60
|
||||||
overtime_mins = overtime_minutes % 60
|
overtime_mins = overtime_minutes % 60
|
||||||
self._set_card_value(self.monthly_ot_card,
|
self._set_card_value(self.monthly_ot_card,
|
||||||
tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
f"{overtime_hours}시간 {overtime_mins}분")
|
||||||
|
|
||||||
# 월간 차트 (요일별 평균)
|
# 월간 차트 (요일별 평균)
|
||||||
if hasattr(self, 'monthly_chart'):
|
if hasattr(self, 'monthly_chart'):
|
||||||
@ -300,10 +300,8 @@ class StatsView(QDialog):
|
|||||||
from core.salary import estimate_pay, format_won
|
from core.salary import estimate_pay, format_won
|
||||||
result = estimate_pay(records, wage, rate)
|
result = estimate_pay(records, wage, rate)
|
||||||
self.salary_label.setText(
|
self.salary_label.setText(
|
||||||
tr('stats.salary_estimate',
|
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
||||||
total=format_won(result['total']),
|
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
||||||
base=format_won(result['base']),
|
|
||||||
overtime=format_won(result['overtime']))
|
|
||||||
)
|
)
|
||||||
self.salary_label.setVisible(True)
|
self.salary_label.setVisible(True)
|
||||||
|
|
||||||
@ -336,7 +334,7 @@ class StatsView(QDialog):
|
|||||||
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
||||||
avg_hour = int(avg_minutes // 60)
|
avg_hour = int(avg_minutes // 60)
|
||||||
avg_min = int(avg_minutes % 60)
|
avg_min = int(avg_minutes % 60)
|
||||||
insights.append(tr('stats.avg_clock_in', time=f"{avg_hour:02d}:{avg_min:02d}"))
|
insights.append(f"📌 평균 출근시간: {avg_hour:02d}:{avg_min:02d}")
|
||||||
|
|
||||||
# 연장근무 빈도
|
# 연장근무 빈도
|
||||||
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
||||||
@ -344,14 +342,14 @@ class StatsView(QDialog):
|
|||||||
|
|
||||||
if total_days > 0:
|
if total_days > 0:
|
||||||
overtime_rate = (overtime_days / total_days) * 100
|
overtime_rate = (overtime_days / total_days) * 100
|
||||||
insights.append(tr('stats.overtime_frequency', rate=f"{overtime_rate:.0f}", days=overtime_days, total=total_days))
|
insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)")
|
||||||
|
|
||||||
# 가장 긴 근무일
|
# 가장 긴 근무일
|
||||||
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
||||||
if records_with_hours:
|
if records_with_hours:
|
||||||
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
||||||
if longest_work.get('total_hours', 0) > 0:
|
if longest_work.get('total_hours', 0) > 0:
|
||||||
insights.append(tr('stats.longest_work', date=longest_work['date'], hours=f"{longest_work['total_hours']:.1f}"))
|
insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)")
|
||||||
|
|
||||||
# 건강 경고
|
# 건강 경고
|
||||||
recent_records = records[-7:] # 최근 7일
|
recent_records = records[-7:] # 최근 7일
|
||||||
@ -366,15 +364,15 @@ class StatsView(QDialog):
|
|||||||
consecutive_overtime = 0
|
consecutive_overtime = 0
|
||||||
|
|
||||||
if max_consecutive >= 3:
|
if max_consecutive >= 3:
|
||||||
insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive))
|
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!")
|
||||||
|
|
||||||
# 주 52시간 체크
|
# 주 52시간 체크
|
||||||
if len(recent_records) >= 7:
|
if len(recent_records) >= 7:
|
||||||
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
||||||
if week_total > 52:
|
if week_total > 52:
|
||||||
insights.append(tr('stats.weekly_52_exceeded', hours=f"{week_total:.1f}"))
|
insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간")
|
||||||
|
|
||||||
self.pattern_text.setText("\n\n".join(insights) if insights else tr('stats.no_pattern_data'))
|
self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.")
|
||||||
|
|
||||||
|
|
||||||
# 테스트 코드
|
# 테스트 코드
|
||||||
|
|||||||
@ -7,8 +7,6 @@ from __future__ import annotations
|
|||||||
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
from core.i18n import tr
|
|
||||||
|
|
||||||
|
|
||||||
class TodaySummaryCard(QFrame):
|
class TodaySummaryCard(QFrame):
|
||||||
"""퇴근 처리 직후 표시되는 요약 카드."""
|
"""퇴근 처리 직후 표시되는 요약 카드."""
|
||||||
@ -32,7 +30,7 @@ class TodaySummaryCard(QFrame):
|
|||||||
layout.setSpacing(2)
|
layout.setSpacing(2)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel(tr('today.title'))
|
title = QLabel("오늘의 요약")
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch()
|
header.addStretch()
|
||||||
@ -72,17 +70,17 @@ class TodaySummaryCard(QFrame):
|
|||||||
"""
|
"""
|
||||||
h = int(total_hours)
|
h = int(total_hours)
|
||||||
m = int((total_hours - h) * 60)
|
m = int((total_hours - h) * 60)
|
||||||
self.total_label.setText(tr('today.total_work', hours=h, minutes=m))
|
self.total_label.setText(f"총 근무: {h}시간 {m}분")
|
||||||
|
|
||||||
details = []
|
details = []
|
||||||
if lunch_minutes > 0:
|
if lunch_minutes > 0:
|
||||||
details.append(tr('today.detail_lunch', minutes=lunch_minutes))
|
details.append(f"점심 {lunch_minutes}분")
|
||||||
if dinner_minutes > 0:
|
if dinner_minutes > 0:
|
||||||
details.append(tr('today.detail_dinner', minutes=dinner_minutes))
|
details.append(f"저녁 {dinner_minutes}분")
|
||||||
if break_minutes > 0:
|
if break_minutes > 0:
|
||||||
details.append(tr('today.detail_break', minutes=break_minutes))
|
details.append(f"외출 {break_minutes}분")
|
||||||
if overtime_actual > 0:
|
if overtime_actual > 0:
|
||||||
details.append(tr('today.detail_overtime', actual=overtime_actual, earned=overtime_earned))
|
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
|
||||||
self.detail_label.setText(" · ".join(details) if details else "")
|
self.detail_label.setText(" · ".join(details) if details else "")
|
||||||
self.detail_label.setVisible(bool(details))
|
self.detail_label.setVisible(bool(details))
|
||||||
|
|
||||||
|
|||||||
@ -138,7 +138,6 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
|||||||
conn = db.get_connection()
|
conn = db.get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
try:
|
try:
|
||||||
cursor.execute("DELETE FROM overtime_usage WHERE date = ?", (row['date'],))
|
|
||||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
||||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||||
|
|||||||
@ -13,9 +13,7 @@ from typing import Optional, List
|
|||||||
|
|
||||||
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
||||||
# 일반 브라우저 UA로 위장해야 통과.
|
# 일반 브라우저 UA로 위장해야 통과.
|
||||||
# 버전은 core/version.py에서 동기화.
|
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
|
||||||
from core.version import __version__
|
|
||||||
USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/{__version__}'
|
|
||||||
|
|
||||||
# Discord embed 색상 (decimal)
|
# Discord embed 색상 (decimal)
|
||||||
COLOR_GREEN = 0x57F287
|
COLOR_GREEN = 0x57F287
|
||||||
|
|||||||
@ -14,17 +14,15 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
# 공공데이터포털 특일정보 API 서비스 키.
|
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
||||||
# 소스코드/바이너리 노출 방지를 위해 환경변수에서 읽습니다.
|
|
||||||
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||||
_SERVICE_KEY = os.environ.get('CLOCKOUT_HOLIDAY_API_KEY', '')
|
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
||||||
|
|
||||||
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
||||||
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user