Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8c6a9d784 | ||
|
|
71161b2707 | ||
|
|
63b0e324b9 | ||
|
|
f5751460e3 | ||
|
|
e7e85dcf7b | ||
|
|
130c61ea62 | ||
|
|
5fb8655a47 | ||
|
|
da5f91984b |
610
AGENTS.md
610
AGENTS.md
@ -1,176 +1,476 @@
|
|||||||
# Project Conventions and Operational Gotchas
|
# Clock-out Time Calculator — Agent Guide
|
||||||
|
|
||||||
## 🛠️ Setup & Execution
|
> Last verified against the working tree at version **2.11.2** (`core/version.py`).
|
||||||
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
|
> This file is written for AI coding agents who need to understand, modify, build, or release the project. When in doubt, prefer the facts in this file over older documentation; this guide was produced by exploring the actual codebase, running the tests, and reading the build scripts.
|
||||||
- **Run:** `python main.py`
|
|
||||||
- **Module-level smoke:**
|
|
||||||
- Event monitoring: `python core/event_monitor.py`
|
|
||||||
- Time calculation: `python core/time_calculator.py`
|
|
||||||
- **Integration tests** (all should be green before release):
|
|
||||||
- `python _integration_test.py` — business-logic scenarios (35+ for v2.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)
|
---
|
||||||
|
|
||||||
### Database (10+ tables in `database.db`)
|
## 1. Project Overview
|
||||||
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
|
|
||||||
|
|
||||||
### Invariants
|
**Clock-out Time Calculator** (Korean: 퇴근시간 계산기) is a Windows desktop productivity application written in Python with PyQt5. It tracks a user's workday, automatically detects clock-in time from Windows Event Log / boot time, counts down to the expected clock-out time in real time, banks overtime in configurable units, manages annual leave, and provides statistics, notifications, Discord integration, and automatic self-updates.
|
||||||
- **`work_records.date` UNIQUE** — one row per workday.
|
|
||||||
- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
|
||||||
- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
|
|
||||||
- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
|
|
||||||
- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`.
|
|
||||||
- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox).
|
|
||||||
|
|
||||||
### Settings system
|
- **Primary language:** Python 3.9+
|
||||||
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
- **GUI framework:** PyQt5
|
||||||
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
- **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout
|
||||||
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
- **Packaging:** PyInstaller (`main.exe` + `updater.exe`)
|
||||||
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
- **Distribution:** Gitea Releases on a self-hosted instance
|
||||||
|
- **Current version:** `2.11.2` (single source of truth: `core/version.py`)
|
||||||
|
- **Repository:** `kindnick/Clock_out_Time_Calculator`
|
||||||
|
|
||||||
### i18n
|
The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
|
||||||
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
|
|
||||||
- Sentence formatting via Python `str.format(**kwargs)`.
|
|
||||||
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
|
|
||||||
|
|
||||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
---
|
||||||
|
|
||||||
### 1. Time-off subtraction order in `update_display()`
|
## 2. Technology Stack
|
||||||
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
|
|
||||||
|
|
||||||
### 2. Hot-path caching
|
| Layer | Technology |
|
||||||
`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`.
|
|-------|------------|
|
||||||
|
| Language | Python 3.9+ |
|
||||||
|
| GUI | PyQt5 ≥ 5.15 |
|
||||||
|
| Charts | matplotlib (QtAgg backend) |
|
||||||
|
| Windows integration | pywin32 (event log), ctypes (screen-lock detection) |
|
||||||
|
| Date / recurrence | python-dateutil |
|
||||||
|
| Notifications | plyer (system toast) + PyQt signals |
|
||||||
|
| Holidays | optional `holidays` package; government API + fixed-date fallback |
|
||||||
|
| Packaging | PyInstaller 2-step build |
|
||||||
|
| Testing | pytest + standalone integration/GUI smoke scripts |
|
||||||
|
| Fonts | Bundled NanumSquare TTF/OTF files; Malgun Gothic fallback |
|
||||||
|
|
||||||
### 3. Time format separation
|
Dependencies are declared in `requirements.txt`:
|
||||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
|
||||||
|
|
||||||
### 4. Workday boundary
|
```text
|
||||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic.
|
PyQt5>=5.15.0
|
||||||
|
pywin32>=305
|
||||||
|
python-dateutil>=2.8.0
|
||||||
|
matplotlib>=3.4.0
|
||||||
|
plyer>=2.0.0
|
||||||
|
holidays>=0.40
|
||||||
|
```
|
||||||
|
|
||||||
### 5. Migration idempotency
|
Install with:
|
||||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.
|
|
||||||
|
|
||||||
### 6. Single-file deployment
|
|
||||||
`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build.
|
|
||||||
|
|
||||||
### 7. Updater handoff
|
|
||||||
`updater.py` is standalone (no PyQt). Args: `--pid <main_pid> --new <new_main.exe> --target <current_main.exe>`. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater.
|
|
||||||
|
|
||||||
## 🧩 Module Map
|
|
||||||
|
|
||||||
### `core/`
|
|
||||||
- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`).
|
|
||||||
- `time_calculator.py` — `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit).
|
|
||||||
- `event_monitor.py` — Win Event IDs 6005/4624/6006.
|
|
||||||
- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable.
|
|
||||||
- `i18n.py` — `_DICT` ko/en + `_HELP_HTML` (6 tabs).
|
|
||||||
- `salary.py` — `estimate_pay(records, hourly_wage, overtime_rate=1.5)`.
|
|
||||||
- `settings_keys.py` — All setting keys as constants.
|
|
||||||
- `version.py` — `__version__` single source of truth.
|
|
||||||
|
|
||||||
### `ui/`
|
|
||||||
- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts.
|
|
||||||
- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
|
|
||||||
- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
|
|
||||||
- `mini_widget.py` — always-on-top frameless.
|
|
||||||
- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button.
|
|
||||||
- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel).
|
|
||||||
- `today_summary.py` — post-clockout card.
|
|
||||||
- `goal_widget.py` — monthly progress bars (overtime cap, daily avg).
|
|
||||||
- `meal_time_dialog.py` — lunch/dinner real start-end input.
|
|
||||||
- `past_record_dialog.py` — calendar right-click "add past record".
|
|
||||||
- `leave_calendar_view.py` — color-coded leave (green/yellow/purple).
|
|
||||||
- `accessibility.py` — `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`.
|
|
||||||
- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing.
|
|
||||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
|
|
||||||
|
|
||||||
### `ui/controllers/`
|
|
||||||
- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
|
|
||||||
- `auto_lunch.py` — toggles lunch after 4 hours since clock-in.
|
|
||||||
- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays.
|
|
||||||
|
|
||||||
### `utils/`
|
|
||||||
- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API.
|
|
||||||
- `lock_detector.py` — `OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
|
|
||||||
- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally.
|
|
||||||
- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`.
|
|
||||||
- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
|
|
||||||
- `csv_exporter.py` — same format.
|
|
||||||
- `crash_handler.py` — `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report.
|
|
||||||
- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`.
|
|
||||||
- `system_tray.py` — tray menu with i18n labels.
|
|
||||||
- `time_format.py` — `format_hours_minutes(minutes)`.
|
|
||||||
- `debug_log.py` — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`.
|
|
||||||
- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver.
|
|
||||||
|
|
||||||
### Top-level
|
|
||||||
- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow.
|
|
||||||
- `updater.py` — Standalone PID wait + file replace + relaunch.
|
|
||||||
- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`.
|
|
||||||
- `updater.spec` — Standalone updater build.
|
|
||||||
- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.
|
|
||||||
|
|
||||||
## ⚙️ Build Process
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Manual two-step
|
pip install -r requirements.txt
|
||||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
```
|
||||||
|
|
||||||
|
`pywin32` is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Directory Structure and Module Map
|
||||||
|
|
||||||
|
```text
|
||||||
|
Clock-out Time Calculator/
|
||||||
|
├── main.py # Application entry point / bootstrap
|
||||||
|
├── updater.py # Standalone update helper process (stdlib only)
|
||||||
|
├── main.spec # PyInstaller spec for main.exe
|
||||||
|
├── updater.spec # PyInstaller spec for updater.exe
|
||||||
|
├── release.ps1 # One-shot release script (PowerShell)
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── pytest.ini # pytest configuration
|
||||||
|
├── run_as_admin.bat # Convenience launcher
|
||||||
|
├── core/ # Business logic and data access
|
||||||
|
│ ├── database.py # SQLite schema, migrations, CRUD
|
||||||
|
│ ├── time_calculator.py # Pure time-math engine
|
||||||
|
│ ├── event_monitor.py # Windows Event Log clock-in detection
|
||||||
|
│ ├── notifier.py # Notification rule engine
|
||||||
|
│ ├── salary.py # Optional pay estimation
|
||||||
|
│ ├── i18n.py # Korean/English translation dictionaries
|
||||||
|
│ ├── settings_keys.py # Setting key constants
|
||||||
|
│ ├── achievements.py # 357 achievement definitions + evaluator
|
||||||
|
│ ├── recurring_leaves.py # Recurring leave pattern expansion
|
||||||
|
│ └── version.py # __version__ single source of truth
|
||||||
|
├── ui/ # PyQt5 views and widgets
|
||||||
|
│ ├── main_window.py # Central 1 Hz main window
|
||||||
|
│ ├── styles.py # Theme colors and QSS
|
||||||
|
│ ├── dark_components.py # Reusable dark-styled widgets
|
||||||
|
│ ├── icons.py # Icon resource helpers
|
||||||
|
│ ├── i18n_runtime.py # Runtime retranslation registry
|
||||||
|
│ ├── settings_view.py # Settings dialog
|
||||||
|
│ ├── stats_view.py # Weekly/monthly/pattern statistics
|
||||||
|
│ ├── chart_widget.py # matplotlib QtAgg wrapper + fallback
|
||||||
|
│ ├── calendar_view.py # Work-record calendar
|
||||||
|
│ ├── leave_calendar_view.py # Color-coded leave calendar
|
||||||
|
│ ├── break_view.py # Break history dialog
|
||||||
|
│ ├── overtime_view.py # Overtime bank/usage dialog
|
||||||
|
│ ├── leave_view.py # Leave management dialog
|
||||||
|
│ ├── recurring_leave_dialog.py
|
||||||
|
│ ├── schedule_view.py # Schedule view
|
||||||
|
│ ├── clock_in_dialog.py # Manual clock-in dialog
|
||||||
|
│ ├── meal_time_dialog.py # Lunch/dinner start-end input
|
||||||
|
│ ├── past_record_dialog.py # Add past record dialog
|
||||||
|
│ ├── achievements_view.py # Achievements browser
|
||||||
|
│ ├── onboarding_view.py # 5-step first-run wizard
|
||||||
|
│ ├── today_summary.py # Post-clock-out card
|
||||||
|
│ ├── goal_widget.py # Monthly goal progress bars
|
||||||
|
│ ├── mini_widget.py # Always-on-top frameless widget
|
||||||
|
│ ├── help_view.py # 6-tab help dialog
|
||||||
|
│ ├── accessibility.py # Font scale / high-contrast helpers
|
||||||
|
│ └── controllers/ # Thin controllers split from MainWindow
|
||||||
|
│ ├── lock_monitor.py # Screen-lock auto-break / unlock clock-in
|
||||||
|
│ ├── auto_lunch.py # Auto-lunch after 4 hours
|
||||||
|
│ ├── notification_orchestrator.py # 5-min-tick orchestrator
|
||||||
|
│ └── meal_controller.py # Lunch/dinner toggle handling
|
||||||
|
├── utils/ # Helpers and integrations
|
||||||
|
│ ├── backup.py # Daily SQLite backup with 7-rotation
|
||||||
|
│ ├── lock_detector.py # Win32 screen-lock detection
|
||||||
|
│ ├── discord_webhook.py # Discord embed pushes
|
||||||
|
│ ├── csv_importer.py # CSV import (standard format)
|
||||||
|
│ ├── csv_exporter.py # CSV export
|
||||||
|
│ ├── crash_handler.py # Global excepthook + optional Gitea report
|
||||||
|
│ ├── updater_client.py # Gitea Releases API client
|
||||||
|
│ ├── system_tray.py # System tray menu
|
||||||
|
│ ├── time_format.py # format_hours_minutes helper
|
||||||
|
│ ├── font_loader.py # NanumSquare font loading
|
||||||
|
│ ├── resource_manager.py # PyInstaller _MEIPASS path resolver
|
||||||
|
│ ├── debug_log.py # CLOCKOUT_DEBUG gated logging
|
||||||
|
│ └── holiday_api.py # Korean holiday API client
|
||||||
|
├── tests/ # pytest unit tests (13 files)
|
||||||
|
├── font/ # Bundled NanumSquare fonts
|
||||||
|
├── resources/ # Icons and resource links
|
||||||
|
├── analysis/ # Currently empty (only __init__.py)
|
||||||
|
├── build/ # Build staging directory
|
||||||
|
└── dist/ # Built EXEs and release ZIPs
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `utils/http_api.py` is referenced in older documentation but is **not present** in the current working tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How to Build, Run, and Smoke-Test
|
||||||
|
|
||||||
|
### Development run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`main.py` inserts the project root into `sys.path`, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches `MainWindow`.
|
||||||
|
|
||||||
|
A convenience batch file is provided:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run_as_admin.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
|
||||||
|
|
||||||
|
### Module-level smoke tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python core/event_monitor.py
|
||||||
|
python core/time_calculator.py
|
||||||
|
```
|
||||||
|
|
||||||
|
These run lightweight self-tests when invoked as scripts.
|
||||||
|
|
||||||
|
### Production build (manual two-step)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m PyInstaller --clean updater.spec
|
||||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
python -m PyInstaller --clean main.spec
|
||||||
|
|
||||||
# Or one-shot
|
|
||||||
.\release.ps1 v2.7.0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
|
||||||
- `holidays` package only baked in if installed in build env.
|
- `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`).
|
||||||
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
- The staging copy is critical: `main.spec --clean` wipes `dist/`, so without `build/staging/updater.exe` the updater would be deleted mid-build.
|
||||||
|
|
||||||
## 🚦 External Integrations
|
### Production build (one-shot)
|
||||||
|
|
||||||
- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<version>`. Repo must be public.
|
```bash
|
||||||
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
|
.\release.ps1 v2.11.2
|
||||||
- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
|
||||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only.
|
|
||||||
- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
|
|
||||||
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
|
|
||||||
|
|
||||||
## 🐞 Past Incidents (do NOT re-introduce)
|
|
||||||
|
|
||||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
|
|
||||||
- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
|
|
||||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`.
|
|
||||||
- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor).
|
|
||||||
- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
|
|
||||||
- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
|
|
||||||
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
|
|
||||||
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`.
|
|
||||||
- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
|
|
||||||
- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
|
|
||||||
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
|
|
||||||
|
|
||||||
## 🌐 i18n Coverage Status
|
|
||||||
|
|
||||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
|
|
||||||
- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
|
|
||||||
- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).
|
|
||||||
|
|
||||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`.
|
|
||||||
|
|
||||||
## 🚢 Release Flow ([release.ps1](release.ps1))
|
|
||||||
|
|
||||||
```
|
|
||||||
0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
|
|
||||||
1. Bump core/version.py
|
|
||||||
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
|
|
||||||
3. PyInstaller (updater.spec → staging copy → main.spec)
|
|
||||||
4. ZIP packaging (main.exe + updater.exe)
|
|
||||||
5. Git commit (version.py + CHANGELOG.md) + tag + push
|
|
||||||
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
|
|
||||||
7. Asset upload (main.exe, updater.exe, ZIP)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--DryRun` previews without git push or API calls.
|
The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`.
|
||||||
|
|
||||||
|
### Build gotchas
|
||||||
|
|
||||||
|
- Kill any running `main.exe` before building or PyInstaller will fail with `PermissionError`.
|
||||||
|
- The `holidays` package is only baked into the EXE if it is installed in the build environment.
|
||||||
|
- Optional code signing is supported via `$env:CODE_SIGN_CERT` (.pfx path) and `$env:CODE_SIGN_PASS`.
|
||||||
|
- `main.spec` lists several `hiddenimports` that are easy to break: `holidays`, `holidays.countries.south_korea`, `win32evtlog`, `win32evtlogutil`, `matplotlib.backends.backend_qtagg`, `matplotlib.backends.backend_qt5agg`, `PyQt5.QtSvg`, `PyQt5.sip`, and `numpy.core._multiarray_tests`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing Strategy
|
||||||
|
|
||||||
|
The project uses three layers of tests.
|
||||||
|
|
||||||
|
### 5.1 Unit tests (pytest)
|
||||||
|
|
||||||
|
Configuration: `pytest.ini`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest tests/ -v --tb=short
|
||||||
|
```
|
||||||
|
|
||||||
|
There are **13 test files** under `tests/`:
|
||||||
|
|
||||||
|
- `test_time_calculator.py` — clock-out, overtime truncation, holiday overtime, day-type detection
|
||||||
|
- `test_database.py` — settings, migrations, leave calculations, consecutive OT days
|
||||||
|
- `test_csv_importer.py` — CSV parsing, validation, conflict handling
|
||||||
|
- `test_salary.py` — pay estimation and won formatting
|
||||||
|
- `test_recurring_leaves.py` — pattern parsing and expansion
|
||||||
|
- `test_crash_handler.py` — crash log insertion and Gitea reporting (mocked)
|
||||||
|
- `test_discord_webhook.py` — URL validation, payload shape, network errors
|
||||||
|
- `test_holiday_api.py` — API response parsing and error handling
|
||||||
|
- `test_i18n.py` — language switching, missing-key fallback, interpolation
|
||||||
|
- `test_i18n_runtime.py` — runtime retranslation of Qt widgets
|
||||||
|
- `test_overtime_accrual_guard.py` — auto-overtime rollover guard
|
||||||
|
- `test_updater.py` — version parsing, Gitea API URL, update logic
|
||||||
|
|
||||||
|
`tests/conftest.py` sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` so the background holiday-sync thread does not hold open temporary DB files during test cleanup.
|
||||||
|
|
||||||
|
**Current status:** `194 passed`.
|
||||||
|
|
||||||
|
### 5.2 Integration scenarios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python _integration_test.py
|
||||||
|
```
|
||||||
|
|
||||||
|
A standalone script with a custom `@case` decorator. It runs **53 scenarios** (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.
|
||||||
|
|||||||
79
CHANGELOG.md
79
CHANGELOG.md
@ -4,6 +4,85 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [2.12.0] — 2026-06-16
|
||||||
|
|
||||||
|
### Added — 전체 i18n 키화 완료
|
||||||
|
- **UI 전체 사용자 대면 문자열 i18n 키화** — `ui/` 디렉터리 22개 파일의 버튼/라벨/메시지박스/차트/온볼딩/설정/통계 등을 `tr()` 기반으로 전환, `core/i18n.py`에 ko/en 번역 추가.
|
||||||
|
- **도전과제 메타데이터 i18n** — `core/achievements.py`의 모든 도전과제 이름/설명을 `achieve.{code}.name/desc` 키로 분리하고 영문 번역 추가.
|
||||||
|
- **차트 위젯 라벨 키화** — matplotlib 차트의 축/툴팁/범례/빈 기록 메시지 등을 언어별로 표시.
|
||||||
|
- **반복 연차 패턴 설명 키화** — `core/recurring_leaves.describe_pattern()`이 요일/주기 접두사를 i18n 키로 조합.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `ui/help_view.py`의 "온보팅 다시 보기" 버튼이 `tr()` 호출이 아닌 리터럴 문자열로 잘못 들어가던 버그 수정.
|
||||||
|
- `core/i18n.py` 영문 achievement 번역 중 `Children's`/`Teachers'` 작은따옴표로 인한 SyntaxError 수정.
|
||||||
|
- `_i18n_gui_test.py`에 `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 추가하여 백그라운드 휴일 동기화 스레드로 인한 세그멘테이션 폴트 방지.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **공공데이터포털 공휴일 API 키 외부화** — `utils/holiday_api.py`가 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수를 사용하도록 변경.
|
||||||
|
- `ui/main_window.py` 1Hz 핫패스 DB 호출 캐싱 추가.
|
||||||
|
- `utils/csv_importer.py` overwrite 시 `overtime_usage`도 함께 삭제.
|
||||||
|
- `ui/controllers/lock_monitor.py` 컨텍스트 매니저 적용 및 race condition 처리 개선.
|
||||||
|
|
||||||
|
## [2.11.2] — 2026-06-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
|
||||||
|
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
|
||||||
|
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
|
||||||
|
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
|
||||||
|
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
|
||||||
|
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
|
||||||
|
|
||||||
|
## [2.11.1] — 2026-06-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
|
||||||
|
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget`이 `backend_qt5agg`를
|
||||||
|
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
|
||||||
|
폴백) + 실패 원인 로깅, `main.spec`에 `backend_qtagg`/`PyQt5.sip` 명시.
|
||||||
|
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제** — `dark_components`와
|
||||||
|
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
|
||||||
|
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
|
||||||
|
|
||||||
|
## [2.11.0] — 2026-06-04
|
||||||
|
|
||||||
|
### Changed — UI 전면 다크 리디자인
|
||||||
|
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
|
||||||
|
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
|
||||||
|
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
|
||||||
|
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
|
||||||
|
`main.spec`에 동봉
|
||||||
|
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
|
||||||
|
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
|
||||||
|
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
|
||||||
|
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함)
|
||||||
|
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
|
||||||
|
(`Database.delete_overtime_earned`)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
|
||||||
|
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
|
||||||
|
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
|
||||||
|
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
|
||||||
|
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
|
||||||
|
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
|
||||||
|
|
||||||
|
## [2.10.2] — 2026-05-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
|
||||||
|
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
|
||||||
|
그대로 표시에 사용 → 초 정보 소실
|
||||||
|
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
|
||||||
|
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
|
||||||
|
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
|
||||||
|
|
||||||
## [2.10.1] — 2026-05-01
|
## [2.10.1] — 2026-05-01
|
||||||
|
|
||||||
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
|
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
|
||||||
|
|||||||
@ -11,6 +11,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
||||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||||
|
|
||||||
|
# 백그라운드 휴일 동기화 스레드 비활성화 (DB lock / segfault 방지)
|
||||||
|
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
app = QApplication.instance() or QApplication(sys.argv)
|
app = QApplication.instance() or QApplication(sys.argv)
|
||||||
|
|
||||||
@ -87,7 +90,7 @@ def test_stats_view():
|
|||||||
from ui.stats_view import StatsView
|
from ui.stats_view import StatsView
|
||||||
dlg = StatsView(db=db)
|
dlg = StatsView(db=db)
|
||||||
# 데이터 없어도 정상 로드
|
# 데이터 없어도 정상 로드
|
||||||
assert dlg.weekly_total_hours.text() is not None
|
assert dlg.weekly_total_card is not None
|
||||||
dlg.deleteLater()
|
dlg.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
@ -101,12 +104,25 @@ def test_main_window_init():
|
|||||||
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
||||||
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
||||||
from ui.main_window import MainWindow
|
from ui.main_window import MainWindow
|
||||||
w = MainWindow()
|
from datetime import date as _date
|
||||||
|
# MainWindow load_today_data에서 QMessageBox를 띄우지 않도록 오늘 출근 기록을 미리 삽입
|
||||||
|
today = _date.today().isoformat()
|
||||||
|
conn = db.get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM work_records WHERE date = ?", (today,))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO work_records (date, clock_in, clock_out, lunch_break, dinner_break) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(today, '09:00:00', '18:00:00', 0, 0)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
w = MainWindow(db=db)
|
||||||
# 기본 상태
|
# 기본 상태
|
||||||
assert w.is_clocked_in == False
|
assert w.is_clocked_in == False # 퇴근 완료 기록이므로 False
|
||||||
assert w.lunch_break_enabled == False
|
assert w.lunch_break_enabled == False
|
||||||
# auto_lunch 캐시 초기 None
|
# auto_lunch 캐시 초기 None (AutoLunchManager 낶)
|
||||||
assert w._auto_lunch_enabled_cache is None
|
assert w._auto_lunch._enabled_cache is None
|
||||||
# 단축키 7개 등록되었는지
|
# 단축키 7개 등록되었는지
|
||||||
from PyQt5.QtWidgets import QShortcut
|
from PyQt5.QtWidgets import QShortcut
|
||||||
shortcuts = w.findChildren(QShortcut)
|
shortcuts = w.findChildren(QShortcut)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||||
|
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
||||||
|
|||||||
@ -15,6 +15,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
|
||||||
|
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
|
||||||
|
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
|
||||||
|
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
|
||||||
|
|
||||||
PASS = []
|
PASS = []
|
||||||
FAIL = []
|
FAIL = []
|
||||||
WARN = []
|
WARN = []
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
- notification_log: 휴식 권고 카운트
|
- notification_log: 휴식 권고 카운트
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from core.i18n import tr
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import Callable, Optional, List, Tuple
|
from typing import Callable, Optional, List, Tuple
|
||||||
@ -426,40 +427,40 @@ def _bool_eval(condition_fn):
|
|||||||
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
||||||
_STREAK_DEFS = [
|
_STREAK_DEFS = [
|
||||||
# (code, name, desc, target, evaluator, tier, icon)
|
# (code, name, desc, target, evaluator, tier, icon)
|
||||||
('streak_first', '첫걸음', '첫 출근 기록', 1,
|
('streak_first', tr('achieve.streak_first.name'), tr('achieve.streak_first.desc'), 1,
|
||||||
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
|
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
|
||||||
('streak_3', '뿌리내림', '3일 연속 영업일 출근', 3,
|
('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3,
|
||||||
_make_streak_eval(3), TIER_BRONZE, '🌱'),
|
_make_streak_eval(3), TIER_BRONZE, '🌱'),
|
||||||
('streak_5', '첫 주 완주', '5 영업일 연속 출근', 5,
|
('streak_5', tr('achieve.streak_5.name'), tr('achieve.streak_5.desc'), 5,
|
||||||
_make_streak_eval(5), TIER_SILVER, '📅'),
|
_make_streak_eval(5), TIER_SILVER, '📅'),
|
||||||
('streak_7_cal', '7일 연속', '주말 포함 7일 연속 출근', 7,
|
('streak_7_cal', tr('achieve.streak_7_cal.name'), tr('achieve.streak_7_cal.desc'), 7,
|
||||||
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
|
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
|
||||||
('streak_10', '2주 연속', '10 영업일 연속 출근', 10,
|
('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10,
|
||||||
_make_streak_eval(10), TIER_SILVER, '💪'),
|
_make_streak_eval(10), TIER_SILVER, '💪'),
|
||||||
('streak_22', '한 달 개근', '한 달 영업일 100% 출근 (22일)', 22,
|
('streak_22', tr('achieve.streak_22.name'), tr('achieve.streak_22.desc'), 22,
|
||||||
_make_streak_eval(22), TIER_GOLD, '🏔️'),
|
_make_streak_eval(22), TIER_GOLD, '🏔️'),
|
||||||
('streak_50', '50일 연속', '50 영업일 연속 출근', 50,
|
('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50,
|
||||||
_make_streak_eval(50), TIER_GOLD, '🎯'),
|
_make_streak_eval(50), TIER_GOLD, '🎯'),
|
||||||
('streak_100', '100일 연속', '100 영업일 연속 출근', 100,
|
('streak_100', tr('achieve.streak_100.name'), tr('achieve.streak_100.desc'), 100,
|
||||||
_make_streak_eval(100), TIER_PLATINUM, '💎'),
|
_make_streak_eval(100), TIER_PLATINUM, '💎'),
|
||||||
('streak_quarter', '분기 완주', '약 65 영업일 (3개월)', 65,
|
('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65,
|
||||||
_make_streak_eval(65), TIER_PLATINUM, '🏆'),
|
_make_streak_eval(65), TIER_PLATINUM, '🏆'),
|
||||||
('streak_half_year', '반년 마라톤', '약 130 영업일 (6개월)', 130,
|
('streak_half_year', tr('achieve.streak_half_year.name'), tr('achieve.streak_half_year.desc'), 130,
|
||||||
_make_streak_eval(130), TIER_PLATINUM, '👑'),
|
_make_streak_eval(130), TIER_PLATINUM, '👑'),
|
||||||
('streak_year', '1년 풀 시즌', '약 260 영업일 (1년)', 260,
|
('streak_year', tr('achieve.streak_year.name'), tr('achieve.streak_year.desc'), 260,
|
||||||
_make_streak_eval(260), TIER_LEGEND, '🌟'),
|
_make_streak_eval(260), TIER_LEGEND, '🌟'),
|
||||||
('streak_200', '사이언스', '200 영업일 연속', 200,
|
('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200,
|
||||||
_make_streak_eval(200), TIER_LEGEND, '🌌'),
|
_make_streak_eval(200), TIER_LEGEND, '🌌'),
|
||||||
('streak_365_cal', '불사신', '365일 달력 연속', 365,
|
('streak_365_cal', tr('achieve.streak_365_cal.name'), tr('achieve.streak_365_cal.desc'), 365,
|
||||||
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
|
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
|
||||||
('streak_resilience', '회복력', '결근 후 다음날 즉시 출근 (자동: 달력 streak 깨진 후 재시작)', 1,
|
('streak_resilience', tr('achieve.streak_resilience.name'), tr('achieve.streak_resilience.desc'), 1,
|
||||||
_bool_eval(lambda db: _consecutive_workdays(db) >= 1
|
_bool_eval(lambda db: _consecutive_workdays(db) >= 1
|
||||||
and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'),
|
and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'),
|
||||||
('streak_total_100', '누적 100회', '누적 출근 100회', 100,
|
('streak_total_100', tr('achieve.streak_total_100.name'), tr('achieve.streak_total_100.desc'), 100,
|
||||||
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
|
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
|
||||||
('streak_total_500', '누적 500회', '누적 출근 500회', 500,
|
('streak_total_500', tr('achieve.streak_total_500.name'), tr('achieve.streak_total_500.desc'), 500,
|
||||||
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
|
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
|
||||||
('streak_total_1000', '누적 1000회', '누적 출근 1000회', 1000,
|
('streak_total_1000', tr('achieve.streak_total_1000.name'), tr('achieve.streak_total_1000.desc'), 1000,
|
||||||
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
|
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -476,37 +477,37 @@ def _count_weekday_clockins(db, weekday: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
_STREAK_DEFS.extend([
|
_STREAK_DEFS.extend([
|
||||||
('streak_monday_10', '월요일 정복', '월요일 10주 연속 출근', 10,
|
('streak_monday_10', tr('achieve.streak_monday_10.name'), tr('achieve.streak_monday_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
|
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
|
||||||
('streak_friday_10', '금요일 무결', '금요일 10주 연속 출근', 10,
|
('streak_friday_10', tr('achieve.streak_friday_10.name'), tr('achieve.streak_friday_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
|
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
||||||
_PUNCTUAL_DEFS = [
|
_PUNCTUAL_DEFS = [
|
||||||
('punc_before_8_1', '얼리버드', '08:00 이전 출근 1회', 1,
|
('punc_before_8_1', tr('achieve.punc_before_8_1.name'), tr('achieve.punc_before_8_1.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
|
||||||
('punc_before_8_10', '참새족', '08:00 이전 10회', 10,
|
('punc_before_8_10', tr('achieve.punc_before_8_10.name'), tr('achieve.punc_before_8_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
|
||||||
('punc_before_8_30', '일찍 자고 일찍', '08:00 이전 30회', 30,
|
('punc_before_8_30', tr('achieve.punc_before_8_30.name'), tr('achieve.punc_before_8_30.desc'), 30,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
|
||||||
('punc_before_6_1', '새벽잠 없음', '06:00 이전 1회', 1,
|
('punc_before_6_1', tr('achieve.punc_before_6_1.name'), tr('achieve.punc_before_6_1.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
|
||||||
('punc_before_6_10', '어둠을 가르는 자', '06:00 이전 10회', 10,
|
('punc_before_6_10', tr('achieve.punc_before_6_10.name'), tr('achieve.punc_before_6_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
|
||||||
('punc_before_5', '새벽 챔피언', '05:00 이전 출근', 1,
|
('punc_before_5', tr('achieve.punc_before_5.name'), tr('achieve.punc_before_5.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
|
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
|
||||||
('punc_at_9', '9시 정각', '09:00 정각(±1분) 출근 1회', 1,
|
('punc_at_9', tr('achieve.punc_at_9.name'), tr('achieve.punc_at_9.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
|
||||||
TIER_BRONZE, '🎯'),
|
TIER_BRONZE, '🎯'),
|
||||||
('punc_at_9_5', '완벽한 9시', '09:00 정각(±1분) 5회', 5,
|
('punc_at_9_5', tr('achieve.punc_at_9_5.name'), tr('achieve.punc_at_9_5.desc'), 5,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
|
||||||
TIER_SILVER, '🏹'),
|
TIER_SILVER, '🏹'),
|
||||||
('punc_late_5min', '5분 늦음', '09:00~09:05 출근 1회 (자조)', 1,
|
('punc_late_5min', tr('achieve.punc_late_5min.name'), tr('achieve.punc_late_5min.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
|
||||||
TIER_BRONZE, '🛌'),
|
TIER_BRONZE, '🛌'),
|
||||||
('punc_at_909', '운명의 시각', '09:09 출근 (시크릿)', 1,
|
('punc_at_909', tr('achieve.punc_at_909.name'), tr('achieve.punc_at_909.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
|
||||||
TIER_GOLD, '🎰'),
|
TIER_GOLD, '🎰'),
|
||||||
]
|
]
|
||||||
@ -525,76 +526,76 @@ def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> i
|
|||||||
|
|
||||||
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
|
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
|
||||||
_BALANCE_DEFS = [
|
_BALANCE_DEFS = [
|
||||||
('bal_first_punct', '첫 칼퇴', '정시 퇴근 첫 달성', 1,
|
('bal_first_punct', tr('achieve.bal_first_punct.name'), tr('achieve.bal_first_punct.desc'), 1,
|
||||||
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
|
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
|
||||||
('bal_punct_10', '칼퇴러', '정시 퇴근 10회', 10,
|
('bal_punct_10', tr('achieve.bal_punct_10.name'), tr('achieve.bal_punct_10.desc'), 10,
|
||||||
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
|
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
|
||||||
('bal_punct_30', '칼퇴 챔프', '정시 퇴근 30회', 30,
|
('bal_punct_30', tr('achieve.bal_punct_30.name'), tr('achieve.bal_punct_30.desc'), 30,
|
||||||
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
|
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
|
||||||
('bal_punct_100', '진정한 자유', '정시 퇴근 100회', 100,
|
('bal_punct_100', tr('achieve.bal_punct_100.name'), tr('achieve.bal_punct_100.desc'), 100,
|
||||||
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
|
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
|
||||||
('bal_punct_300', '워라밸 마스터', '정시 퇴근 300회', 300,
|
('bal_punct_300', tr('achieve.bal_punct_300.name'), tr('achieve.bal_punct_300.desc'), 300,
|
||||||
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
|
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 4. 연장근무 적립 ----
|
# ---- 4. 연장근무 적립 ----
|
||||||
_OT_BANK_DEFS = [
|
_OT_BANK_DEFS = [
|
||||||
('ot_first_30m', '첫 30분', '첫 연장 적립', 30,
|
('ot_first_30m', tr('achieve.ot_first_30m.name'), tr('achieve.ot_first_30m.desc'), 30,
|
||||||
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
|
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
|
||||||
('ot_total_60m', '1시간 적금', '누적 1시간 적립', 60,
|
('ot_total_60m', tr('achieve.ot_total_60m.name'), tr('achieve.ot_total_60m.desc'), 60,
|
||||||
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
|
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
|
||||||
('ot_total_5h', '5시간 적립', '누적 5시간', 300,
|
('ot_total_5h', tr('achieve.ot_total_5h.name'), tr('achieve.ot_total_5h.desc'), 300,
|
||||||
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
|
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
|
||||||
('ot_total_10h', '10시간 적립', '누적 10시간', 600,
|
('ot_total_10h', tr('achieve.ot_total_10h.name'), tr('achieve.ot_total_10h.desc'), 600,
|
||||||
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
|
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
|
||||||
('ot_total_25h', '25시간 적립', '누적 25시간', 1500,
|
('ot_total_25h', tr('achieve.ot_total_25h.name'), tr('achieve.ot_total_25h.desc'), 1500,
|
||||||
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
|
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
|
||||||
('ot_total_50h', '50시간 적립', '누적 50시간', 3000,
|
('ot_total_50h', tr('achieve.ot_total_50h.name'), tr('achieve.ot_total_50h.desc'), 3000,
|
||||||
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
|
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
|
||||||
('ot_total_100h', '마라토너', '누적 100시간 (걱정 메시지)', 6000,
|
('ot_total_100h', tr('achieve.ot_total_100h.name'), tr('achieve.ot_total_100h.desc'), 6000,
|
||||||
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
|
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
|
||||||
('ot_total_200h', '워크홀릭 경고', '누적 200시간 (경고)', 12000,
|
('ot_total_200h', tr('achieve.ot_total_200h.name'), tr('achieve.ot_total_200h.desc'), 12000,
|
||||||
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
|
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
|
||||||
('ot_total_300h', '위험 신호', '누적 300시간 (강한 경고)', 18000,
|
('ot_total_300h', tr('achieve.ot_total_300h.name'), tr('achieve.ot_total_300h.desc'), 18000,
|
||||||
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
|
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
|
||||||
('ot_total_500h', '응급실 단골', '누적 500시간 (자조)', 30000,
|
('ot_total_500h', tr('achieve.ot_total_500h.name'), tr('achieve.ot_total_500h.desc'), 30000,
|
||||||
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
|
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 5. 연장근무 사용 ----
|
# ---- 5. 연장근무 사용 ----
|
||||||
_OT_USE_DEFS = [
|
_OT_USE_DEFS = [
|
||||||
('use_first', '첫 휴식', '적립 첫 사용', 1,
|
('use_first', tr('achieve.use_first.name'), tr('achieve.use_first.desc'), 1,
|
||||||
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
|
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
|
||||||
('use_total_5h', '선물 사용', '누적 5시간 사용', 300,
|
('use_total_5h', tr('achieve.use_total_5h.name'), tr('achieve.use_total_5h.desc'), 300,
|
||||||
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
|
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
|
||||||
('use_total_25h', '휴식의 가치', '누적 25시간 사용', 1500,
|
('use_total_25h', tr('achieve.use_total_25h.name'), tr('achieve.use_total_25h.desc'), 1500,
|
||||||
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
|
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
|
||||||
('use_total_50h', '회복 마스터', '누적 50시간 사용', 3000,
|
('use_total_50h', tr('achieve.use_total_50h.name'), tr('achieve.use_total_50h.desc'), 3000,
|
||||||
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
|
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
|
||||||
('use_total_100h', '마사지', '누적 100시간 사용', 6000,
|
('use_total_100h', tr('achieve.use_total_100h.name'), tr('achieve.use_total_100h.desc'), 6000,
|
||||||
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
|
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 6. 연차 ----
|
# ---- 6. 연차 ----
|
||||||
_LEAVE_DEFS = [
|
_LEAVE_DEFS = [
|
||||||
('leave_first', '첫 연차', '첫 연차 사용', 1,
|
('leave_first', tr('achieve.leave_first.name'), tr('achieve.leave_first.desc'), 1,
|
||||||
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
|
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
|
||||||
('leave_half', '첫 반차', '0.5일 연차 사용', 1,
|
('leave_half', tr('achieve.leave_half.name'), tr('achieve.leave_half.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
|
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
|
||||||
('leave_quarter', '시간 연차', '0.25일 연차 사용', 1,
|
('leave_quarter', tr('achieve.leave_quarter.name'), tr('achieve.leave_quarter.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
|
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
|
||||||
('leave_streak_3', '미니 휴가', '연속 3일 연차', 3,
|
('leave_streak_3', tr('achieve.leave_streak_3.name'), tr('achieve.leave_streak_3.desc'), 3,
|
||||||
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
|
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
|
||||||
('leave_streak_5', '본격 휴가', '연속 5일 연차', 5,
|
('leave_streak_5', tr('achieve.leave_streak_5.name'), tr('achieve.leave_streak_5.desc'), 5,
|
||||||
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
|
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
|
||||||
('leave_streak_7', '장거리 휴가', '연속 7일 이상 연차', 7,
|
('leave_streak_7', tr('achieve.leave_streak_7.name'), tr('achieve.leave_streak_7.desc'), 7,
|
||||||
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
|
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
|
||||||
('leave_total_10', '연차 10회', '연차 기록 10건', 10,
|
('leave_total_10', tr('achieve.leave_total_10.name'), tr('achieve.leave_total_10.desc'), 10,
|
||||||
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
|
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
|
||||||
('leave_sick', '병가', 'sick 타입 연차 사용', 1,
|
('leave_sick', tr('achieve.leave_sick.name'), tr('achieve.leave_sick.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
|
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
|
||||||
TIER_BRONZE, '🏥'),
|
TIER_BRONZE, '🏥'),
|
||||||
]
|
]
|
||||||
@ -602,22 +603,22 @@ _LEAVE_DEFS = [
|
|||||||
|
|
||||||
# ---- 7. 식사 (점심/저녁) ----
|
# ---- 7. 식사 (점심/저녁) ----
|
||||||
_MEAL_DEFS = [
|
_MEAL_DEFS = [
|
||||||
('meal_lunch_first', '첫 점심 등록', '점심 첫 토글', 1,
|
('meal_lunch_first', tr('achieve.meal_lunch_first.name'), tr('achieve.meal_lunch_first.desc'), 1,
|
||||||
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
|
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
|
||||||
('meal_lunch_30', '점심 마스터', '점심 등록 30회', 30,
|
('meal_lunch_30', tr('achieve.meal_lunch_30.name'), tr('achieve.meal_lunch_30.desc'), 30,
|
||||||
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
|
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
|
||||||
('meal_lunch_100', '점심 챔프', '점심 등록 100회', 100,
|
('meal_lunch_100', tr('achieve.meal_lunch_100.name'), tr('achieve.meal_lunch_100.desc'), 100,
|
||||||
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
|
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
|
||||||
('meal_dinner_first', '첫 저녁 등록', '저녁 첫 토글', 1,
|
('meal_dinner_first', tr('achieve.meal_dinner_first.name'), tr('achieve.meal_dinner_first.desc'), 1,
|
||||||
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
|
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
|
||||||
('meal_dinner_10', '저녁 단골', '저녁 등록 10회 (경고)', 10,
|
('meal_dinner_10', tr('achieve.meal_dinner_10.name'), tr('achieve.meal_dinner_10.desc'), 10,
|
||||||
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
|
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
|
||||||
('meal_dinner_30', '야식 단골', '저녁 등록 30회 (경고)', 30,
|
('meal_dinner_30', tr('achieve.meal_dinner_30.name'), tr('achieve.meal_dinner_30.desc'), 30,
|
||||||
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
|
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
|
||||||
('meal_lunch_actual', '실측 점심', '실제 점심 시각 입력', 1,
|
('meal_lunch_actual', tr('achieve.meal_lunch_actual.name'), tr('achieve.meal_lunch_actual.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
|
||||||
TIER_BRONZE, '⏱️'),
|
TIER_BRONZE, '⏱️'),
|
||||||
('meal_dinner_actual', '실측 저녁', '실제 저녁 시각 입력', 1,
|
('meal_dinner_actual', tr('achieve.meal_dinner_actual.name'), tr('achieve.meal_dinner_actual.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
|
||||||
TIER_BRONZE, '⏰'),
|
TIER_BRONZE, '⏰'),
|
||||||
]
|
]
|
||||||
@ -625,13 +626,13 @@ _MEAL_DEFS = [
|
|||||||
|
|
||||||
# ---- 8. 외출 ----
|
# ---- 8. 외출 ----
|
||||||
_BREAK_DEFS = [
|
_BREAK_DEFS = [
|
||||||
('break_first', '첫 외출', '첫 외출 시작', 1,
|
('break_first', tr('achieve.break_first.name'), tr('achieve.break_first.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
|
||||||
TIER_BRONZE, '🚶'),
|
TIER_BRONZE, '🚶'),
|
||||||
('break_10', '외출 챔프', '외출 10회', 10,
|
('break_10', tr('achieve.break_10.name'), tr('achieve.break_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
|
||||||
TIER_SILVER, '🚪'),
|
TIER_SILVER, '🚪'),
|
||||||
('break_50', '산책러', '외출 50회', 50,
|
('break_50', tr('achieve.break_50.name'), tr('achieve.break_50.desc'), 50,
|
||||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
|
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
|
||||||
TIER_GOLD, '🚶♂️'),
|
TIER_GOLD, '🚶♂️'),
|
||||||
]
|
]
|
||||||
@ -639,39 +640,39 @@ _BREAK_DEFS = [
|
|||||||
|
|
||||||
# ---- 9. 시간대별 ----
|
# ---- 9. 시간대별 ----
|
||||||
_TIME_SLOT_DEFS = [
|
_TIME_SLOT_DEFS = [
|
||||||
('slot_in_06', '06시대 출근', '06:00-06:59 출근 1회', 1,
|
('slot_in_06', tr('achieve.slot_in_06.name'), tr('achieve.slot_in_06.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
|
||||||
TIER_BRONZE, '🌅'),
|
TIER_BRONZE, '🌅'),
|
||||||
('slot_in_07', '07시대 출근', '07:00-07:59 출근 1회', 1,
|
('slot_in_07', tr('achieve.slot_in_07.name'), tr('achieve.slot_in_07.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
|
||||||
TIER_BRONZE, '🌄'),
|
TIER_BRONZE, '🌄'),
|
||||||
('slot_in_08', '08시대 출근', '08:00-08:59 출근 1회', 1,
|
('slot_in_08', tr('achieve.slot_in_08.name'), tr('achieve.slot_in_08.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
|
||||||
TIER_BRONZE, '☀️'),
|
TIER_BRONZE, '☀️'),
|
||||||
('slot_in_10', '10시대 출근', '10시대 출근 (지각/유연근무)', 1,
|
('slot_in_10', tr('achieve.slot_in_10.name'), tr('achieve.slot_in_10.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
|
||||||
TIER_BRONZE, '🕙'),
|
TIER_BRONZE, '🕙'),
|
||||||
('slot_in_11', '11시대 출근', '11시대 출근 (자조)', 1,
|
('slot_in_11', tr('achieve.slot_in_11.name'), tr('achieve.slot_in_11.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
|
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
|
||||||
TIER_SILVER, '🕦'),
|
TIER_SILVER, '🕦'),
|
||||||
('slot_out_19', '19시대 퇴근', '19시대 퇴근 10회 (경고)', 10,
|
('slot_out_19', tr('achieve.slot_out_19.name'), tr('achieve.slot_out_19.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
|
||||||
TIER_SILVER, '🌆'),
|
TIER_SILVER, '🌆'),
|
||||||
('slot_out_20', '20시대 퇴근', '20시대 퇴근 10회 (경고)', 10,
|
('slot_out_20', tr('achieve.slot_out_20.name'), tr('achieve.slot_out_20.desc'), 10,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
|
||||||
TIER_GOLD, '🌌'),
|
TIER_GOLD, '🌌'),
|
||||||
('slot_out_21', '21시대 퇴근', '21시대 퇴근 5회 (경고)', 5,
|
('slot_out_21', tr('achieve.slot_out_21.name'), tr('achieve.slot_out_21.desc'), 5,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
|
||||||
TIER_GOLD, '🌑'),
|
TIER_GOLD, '🌑'),
|
||||||
('slot_out_22', '22시대 퇴근', '22시대 퇴근 1회 (경고)', 1,
|
('slot_out_22', tr('achieve.slot_out_22.name'), tr('achieve.slot_out_22.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
|
||||||
TIER_PLATINUM, '🦉'),
|
TIER_PLATINUM, '🦉'),
|
||||||
('slot_out_23', '23시대 퇴근', '23시대 퇴근 1회 (경고)', 1,
|
('slot_out_23', tr('achieve.slot_out_23.name'), tr('achieve.slot_out_23.desc'), 1,
|
||||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
|
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
|
||||||
TIER_PLATINUM, '🦇'),
|
TIER_PLATINUM, '🦇'),
|
||||||
('slot_midnight', '자정 퇴근', '자정 이후 퇴근 (경고)', 1,
|
('slot_midnight', tr('achieve.slot_midnight.name'), tr('achieve.slot_midnight.desc'), 1,
|
||||||
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
|
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
|
||||||
('slot_midnight_3', '올빼미 트리오', '자정 이후 퇴근 3회 (경고)', 3,
|
('slot_midnight_3', tr('achieve.slot_midnight_3.name'), tr('achieve.slot_midnight_3.desc'), 3,
|
||||||
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
|
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -692,50 +693,50 @@ def _count_clockouts_in_hour(db, hour: int) -> int:
|
|||||||
|
|
||||||
# ---- 10. 공휴일·주말 ----
|
# ---- 10. 공휴일·주말 ----
|
||||||
_SPECIAL_DAY_DEFS = [
|
_SPECIAL_DAY_DEFS = [
|
||||||
('weekend_1', '주말 출근 1회', '토/일 출근 1회', 1,
|
('weekend_1', tr('achieve.weekend_1.name'), tr('achieve.weekend_1.desc'), 1,
|
||||||
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
|
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
|
||||||
('weekend_5', '주말 워커', '주말 출근 5회 (경고)', 5,
|
('weekend_5', tr('achieve.weekend_5.name'), tr('achieve.weekend_5.desc'), 5,
|
||||||
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
|
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
|
||||||
('weekend_20', '진짜 워크홀릭', '주말 출근 20회 (강한 자조)', 20,
|
('weekend_20', tr('achieve.weekend_20.name'), tr('achieve.weekend_20.desc'), 20,
|
||||||
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
|
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
|
||||||
('holiday_1', '공휴일 출근', '한국 공휴일 출근 1회', 1,
|
('holiday_1', tr('achieve.holiday_1.name'), tr('achieve.holiday_1.desc'), 1,
|
||||||
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
|
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
|
||||||
('holiday_5', '공휴일 워커홀릭', '한국 공휴일 출근 5회 (경고)', 5,
|
('holiday_5', tr('achieve.holiday_5.name'), tr('achieve.holiday_5.desc'), 5,
|
||||||
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
|
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
|
||||||
('day_christmas', '크리스마스 출근', '12/25 출근 (자조)', 1,
|
('day_christmas', tr('achieve.day_christmas.name'), tr('achieve.day_christmas.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
|
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
|
||||||
('day_newyear', '신정 출근', '1/1 출근 (자조)', 1,
|
('day_newyear', tr('achieve.day_newyear.name'), tr('achieve.day_newyear.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
|
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
|
||||||
('day_liberation', '광복절 출근', '8/15 출근', 1,
|
('day_liberation', tr('achieve.day_liberation.name'), tr('achieve.day_liberation.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
|
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
|
||||||
('day_children', '어린이날 출근', '5/5 출근 (자조)', 1,
|
('day_children', tr('achieve.day_children.name'), tr('achieve.day_children.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
|
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
|
||||||
('day_hangul', '한글날 출근', '10/9 출근', 1,
|
('day_hangul', tr('achieve.day_hangul.name'), tr('achieve.day_hangul.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
|
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
|
||||||
('day_valentine', '발렌타인데이 출근', '2/14 출근', 1,
|
('day_valentine', tr('achieve.day_valentine.name'), tr('achieve.day_valentine.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
|
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
|
||||||
('day_white', '화이트데이 출근', '3/14 출근', 1,
|
('day_white', tr('achieve.day_white.name'), tr('achieve.day_white.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
|
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
|
||||||
('day_pepero', '빼빼로데이', '11/11 출근', 1,
|
('day_pepero', tr('achieve.day_pepero.name'), tr('achieve.day_pepero.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
|
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
|
||||||
('day_halloween', '핼러윈 출근', '10/31 출근', 1,
|
('day_halloween', tr('achieve.day_halloween.name'), tr('achieve.day_halloween.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
|
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
|
||||||
('day_aprilfools', '만우절 출근', '4/1 출근', 1,
|
('day_aprilfools', tr('achieve.day_aprilfools.name'), tr('achieve.day_aprilfools.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
|
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
|
||||||
('day_77', '칠월칠석', '7/7 출근', 1,
|
('day_77', tr('achieve.day_77.name'), tr('achieve.day_77.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
|
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
|
||||||
('day_dongji', '동지 출근', '12/22 출근', 1,
|
('day_dongji', tr('achieve.day_dongji.name'), tr('achieve.day_dongji.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
|
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
|
||||||
('day_parents', '어버이날 정시 퇴근', '5/8 정시 퇴근', 1,
|
('day_parents', tr('achieve.day_parents.name'), tr('achieve.day_parents.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
|
||||||
TIER_SILVER, '🪅'),
|
TIER_SILVER, '🪅'),
|
||||||
('day_teacher', '스승의 날 정시 퇴근', '5/15 정시 퇴근', 1,
|
('day_teacher', tr('achieve.day_teacher.name'), tr('achieve.day_teacher.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
|
||||||
TIER_BRONZE, '🎂'),
|
TIER_BRONZE, '🎂'),
|
||||||
('day_xmas_eve', '크리스마스이브 정시 퇴근', '12/24 정시 퇴근', 1,
|
('day_xmas_eve', tr('achieve.day_xmas_eve.name'), tr('achieve.day_xmas_eve.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
|
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
|
||||||
TIER_SILVER, '🎁'),
|
TIER_SILVER, '🎁'),
|
||||||
('day_earth', '지구의 날', '4/22 출근 (시크릿)', 1,
|
('day_earth', tr('achieve.day_earth.name'), tr('achieve.day_earth.desc'), 1,
|
||||||
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
|
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -760,79 +761,79 @@ def _make_month_first_eval(month: int):
|
|||||||
|
|
||||||
|
|
||||||
_SEASON_DEFS = [
|
_SEASON_DEFS = [
|
||||||
('season_jan', '1월 정착', '1월 한 달 출근', 1,
|
('season_jan', tr('achieve.season_jan.name'), tr('achieve.season_jan.desc'), 1,
|
||||||
_make_month_first_eval(1), TIER_BRONZE, '⛄'),
|
_make_month_first_eval(1), TIER_BRONZE, '⛄'),
|
||||||
('season_feb', '2월 정착', '2월 영업일 모두 출근', 1,
|
('season_feb', tr('achieve.season_feb.name'), tr('achieve.season_feb.desc'), 1,
|
||||||
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
|
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
|
||||||
('season_mar', '봄을 맞이', '3월 첫 출근', 1,
|
('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1,
|
||||||
_make_month_first_eval(3), TIER_BRONZE, '🌸'),
|
_make_month_first_eval(3), TIER_BRONZE, '🌸'),
|
||||||
('season_apr', '4월 정착', '4월 한 달 출근', 1,
|
('season_apr', tr('achieve.season_apr.name'), tr('achieve.season_apr.desc'), 1,
|
||||||
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
|
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
|
||||||
('season_may', '5월 정착', '5월 영업일 모두 출근', 1,
|
('season_may', tr('achieve.season_may.name'), tr('achieve.season_may.desc'), 1,
|
||||||
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
|
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
|
||||||
('season_jun', '여름의 시작', '6월 첫 출근', 1,
|
('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1,
|
||||||
_make_month_first_eval(6), TIER_BRONZE, '☀️'),
|
_make_month_first_eval(6), TIER_BRONZE, '☀️'),
|
||||||
('season_jul', '7월 정착', '7월 한 달 출근', 1,
|
('season_jul', tr('achieve.season_jul.name'), tr('achieve.season_jul.desc'), 1,
|
||||||
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
|
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
|
||||||
('season_aug', '8월 정착', '8월 영업일 모두 출근', 1,
|
('season_aug', tr('achieve.season_aug.name'), tr('achieve.season_aug.desc'), 1,
|
||||||
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
|
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
|
||||||
('season_sep', '가을의 시작', '9월 첫 출근', 1,
|
('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1,
|
||||||
_make_month_first_eval(9), TIER_BRONZE, '🍂'),
|
_make_month_first_eval(9), TIER_BRONZE, '🍂'),
|
||||||
('season_oct', '10월 정착', '10월 한 달 출근', 1,
|
('season_oct', tr('achieve.season_oct.name'), tr('achieve.season_oct.desc'), 1,
|
||||||
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
|
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
|
||||||
('season_nov', '11월 단풍', '11월 영업일 모두 출근', 1,
|
('season_nov', tr('achieve.season_nov.name'), tr('achieve.season_nov.desc'), 1,
|
||||||
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
|
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
|
||||||
('season_dec', '겨울의 시작', '12월 첫 출근', 1,
|
('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1,
|
||||||
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 12. 앱 사용 마일스톤 ----
|
# ---- 12. 앱 사용 마일스톤 ----
|
||||||
_MILESTONE_DEFS = [
|
_MILESTONE_DEFS = [
|
||||||
('mile_first', 'Hello, World!', '앱 첫 실행', 1,
|
('mile_first', tr('achieve.mile_first.name'), tr('achieve.mile_first.desc'), 1,
|
||||||
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
|
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
|
||||||
TIER_BRONZE, '👋'),
|
TIER_BRONZE, '👋'),
|
||||||
('mile_7days', '일주일 사용', '7일 사용', 7,
|
('mile_7days', tr('achieve.mile_7days.name'), tr('achieve.mile_7days.desc'), 7,
|
||||||
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
|
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
|
||||||
('mile_30days', '한 달 사용', '30일 사용', 30,
|
('mile_30days', tr('achieve.mile_30days.name'), tr('achieve.mile_30days.desc'), 30,
|
||||||
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
|
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
|
||||||
('mile_365days', '1주년', '365일 사용', 365,
|
('mile_365days', tr('achieve.mile_365days.name'), tr('achieve.mile_365days.desc'), 365,
|
||||||
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
|
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
|
||||||
('mile_730days', '2주년', '730일 사용', 730,
|
('mile_730days', tr('achieve.mile_730days.name'), tr('achieve.mile_730days.desc'), 730,
|
||||||
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
|
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
|
||||||
('mile_1095days', '3주년', '3년 사용', 1095,
|
('mile_1095days', tr('achieve.mile_1095days.name'), tr('achieve.mile_1095days.desc'), 1095,
|
||||||
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
|
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
|
||||||
('mile_5years', '5년 사용자', '5년 사용', 1825,
|
('mile_5years', tr('achieve.mile_5years.name'), tr('achieve.mile_5years.desc'), 1825,
|
||||||
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
|
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
|
||||||
('mile_10years', '10년 사용자', '10년 사용', 3650,
|
('mile_10years', tr('achieve.mile_10years.name'), tr('achieve.mile_10years.desc'), 3650,
|
||||||
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
|
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---- 13. 통계·분석 (view counter 기반) ----
|
# ---- 13. 통계·분석 (view counter 기반) ----
|
||||||
_STATS_DEFS = [
|
_STATS_DEFS = [
|
||||||
('stat_weekly_10', '주간 통계러', '주간 탭 10회 조회', 10,
|
('stat_weekly_10', tr('achieve.stat_weekly_10.name'), tr('achieve.stat_weekly_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
|
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
|
||||||
TIER_BRONZE, '📊'),
|
TIER_BRONZE, '📊'),
|
||||||
('stat_monthly_10', '월간 통계러', '월간 탭 10회', 10,
|
('stat_monthly_10', tr('achieve.stat_monthly_10.name'), tr('achieve.stat_monthly_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
|
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
|
||||||
TIER_BRONZE, '📈'),
|
TIER_BRONZE, '📈'),
|
||||||
('stat_pattern_10', '패턴 분석가', '패턴 탭 10회', 10,
|
('stat_pattern_10', tr('achieve.stat_pattern_10.name'), tr('achieve.stat_pattern_10.desc'), 10,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
|
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
|
||||||
TIER_SILVER, '🔍'),
|
TIER_SILVER, '🔍'),
|
||||||
('stat_calendar_30', '캘린더 챔프', '캘린더 30회 조회', 30,
|
('stat_calendar_30', tr('achieve.stat_calendar_30.name'), tr('achieve.stat_calendar_30.desc'), 30,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
|
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
|
||||||
TIER_SILVER, '📅'),
|
TIER_SILVER, '📅'),
|
||||||
('stat_report_first', '일일 보고서 첫 생성', '일일 보고 1회', 1,
|
('stat_report_first', tr('achieve.stat_report_first.name'), tr('achieve.stat_report_first.desc'), 1,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
|
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
|
||||||
TIER_BRONZE, '📋'),
|
TIER_BRONZE, '📋'),
|
||||||
('stat_report_30', '보고서 챔프', '일일 보고 30회', 30,
|
('stat_report_30', tr('achieve.stat_report_30.name'), tr('achieve.stat_report_30.desc'), 30,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
|
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
|
||||||
TIER_SILVER, '📰'),
|
TIER_SILVER, '📰'),
|
||||||
('stat_chart_hover', '차트 호버 발견', '차트 hover 첫 발견', 1,
|
('stat_chart_hover', tr('achieve.stat_chart_hover.name'), tr('achieve.stat_chart_hover.desc'), 1,
|
||||||
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
|
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
|
||||||
TIER_BRONZE, '🎨'),
|
TIER_BRONZE, '🎨'),
|
||||||
('stat_achievements_open', '도전과제 박물관', '도전과제 뷰 50회', 50,
|
('stat_achievements_open', tr('achieve.stat_achievements_open.name'), tr('achieve.stat_achievements_open.desc'), 50,
|
||||||
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
|
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
|
||||||
TIER_BRONZE, '🦄'),
|
TIER_BRONZE, '🦄'),
|
||||||
]
|
]
|
||||||
@ -957,23 +958,23 @@ def _has_500_anniv_clockin(db) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_SECRET_DEFS = [
|
_SECRET_DEFS = [
|
||||||
('secret_palindrome', '회문 시각', '출근 시각이 회문', 1,
|
('secret_palindrome', tr('achieve.secret_palindrome.name'), tr('achieve.secret_palindrome.desc'), 1,
|
||||||
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
|
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
|
||||||
('secret_jackpot', '잭팟 시각', '출근 시각 모든 자릿수 동일', 1,
|
('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1,
|
||||||
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
|
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
|
||||||
('secret_fri13', '13일 금요일', '13일 금요일 출근', 1,
|
('secret_fri13', tr('achieve.secret_fri13.name'), tr('achieve.secret_fri13.desc'), 1,
|
||||||
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
|
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
|
||||||
('secret_777', '7-7-7', '7월 7일 7시 7분 출근', 1,
|
('secret_777', tr('achieve.secret_777.name'), tr('achieve.secret_777.desc'), 1,
|
||||||
_bool_eval(_has_777), TIER_LEGEND, '🔮'),
|
_bool_eval(_has_777), TIER_LEGEND, '🔮'),
|
||||||
('secret_exact_8h', '정확 8시간', '정확히 8h 0m 근무', 1,
|
('secret_exact_8h', tr('achieve.secret_exact_8h.name'), tr('achieve.secret_exact_8h.desc'), 1,
|
||||||
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
|
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
|
||||||
('secret_pi_day', '파이 데이', '3/14 01:59 출근', 1,
|
('secret_pi_day', tr('achieve.secret_pi_day.name'), tr('achieve.secret_pi_day.desc'), 1,
|
||||||
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
|
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
|
||||||
('secret_fibonacci', '피보나치', '출근 분이 피보나치 수', 1,
|
('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1,
|
||||||
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
|
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
|
||||||
('secret_double_six', '더블 식스', '6/6 18:06 출근', 1,
|
('secret_double_six', tr('achieve.secret_double_six.name'), tr('achieve.secret_double_six.desc'), 1,
|
||||||
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
|
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
|
||||||
('secret_anniversary', '마법사', '가입 후 정확히 365일 후 출근', 1,
|
('secret_anniversary', tr('achieve.secret_anniversary.name'), tr('achieve.secret_anniversary.desc'), 1,
|
||||||
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
|
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -984,30 +985,30 @@ def _setting_changed_from_default(db, key: str, default_value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
_SETTINGS_DEFS = [
|
_SETTINGS_DEFS = [
|
||||||
('set_dark', '다크 사이드', '다크 테마 1회 사용', 1,
|
('set_dark', tr('achieve.set_dark.name'), tr('achieve.set_dark.desc'), 1,
|
||||||
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
|
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
|
||||||
TIER_BRONZE, '🌗'),
|
TIER_BRONZE, '🌗'),
|
||||||
('set_lang', '이중언어', '언어 변경 (en 사용)', 1,
|
('set_lang', tr('achieve.set_lang.name'), tr('achieve.set_lang.desc'), 1,
|
||||||
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
|
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
|
||||||
TIER_BRONZE, '🌐'),
|
TIER_BRONZE, '🌐'),
|
||||||
('set_a11y', '접근성 활용', '글꼴 크기≠100% 또는 고대비 ON', 1,
|
('set_a11y', tr('achieve.set_a11y.name'), tr('achieve.set_a11y.desc'), 1,
|
||||||
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
|
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
|
||||||
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
||||||
TIER_BRONZE, '♿'),
|
TIER_BRONZE, '♿'),
|
||||||
('set_overtime_unit', '단위 변경', 'overtime_unit 변경', 1,
|
('set_overtime_unit', tr('achieve.set_overtime_unit.name'), tr('achieve.set_overtime_unit.desc'), 1,
|
||||||
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
|
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
|
||||||
TIER_BRONZE, '⏱️'),
|
TIER_BRONZE, '⏱️'),
|
||||||
('set_goal_full', '목표 마스터', '월 연장+일평균 둘 다 설정', 1,
|
('set_goal_full', tr('achieve.set_goal_full.name'), tr('achieve.set_goal_full.desc'), 1,
|
||||||
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
|
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
|
||||||
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
||||||
TIER_SILVER, '🎯'),
|
TIER_SILVER, '🎯'),
|
||||||
('set_discord_full', '풀 셋업', 'Discord URL + 모든 알림 ON', 1,
|
('set_discord_full', tr('achieve.set_discord_full.name'), tr('achieve.set_discord_full.desc'), 1,
|
||||||
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
|
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
|
||||||
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
||||||
('notification_clock_out', 'notification_lunch',
|
('notification_clock_out', 'notification_lunch',
|
||||||
'notification_overtime', 'notification_health'))),
|
'notification_overtime', 'notification_health'))),
|
||||||
TIER_SILVER, '🔔'),
|
TIER_SILVER, '🔔'),
|
||||||
('set_cloud', '클라우드 동기화', 'DB 경로 변경', 1,
|
('set_cloud', tr('achieve.set_cloud.name'), tr('achieve.set_cloud.desc'), 1,
|
||||||
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
|
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
|
||||||
TIER_SILVER, '☁️'),
|
TIER_SILVER, '☁️'),
|
||||||
]
|
]
|
||||||
@ -1032,21 +1033,21 @@ def _earned_secret_count(db) -> int:
|
|||||||
|
|
||||||
|
|
||||||
_META_DEFS = [
|
_META_DEFS = [
|
||||||
('meta_first', '첫 도전과제', '첫 도전과제 획득', 1,
|
('meta_first', tr('achieve.meta_first.name'), tr('achieve.meta_first.desc'), 1,
|
||||||
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
|
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
|
||||||
('meta_10', '10개 달성', '10개 보유', 10,
|
('meta_10', tr('achieve.meta_10.name'), tr('achieve.meta_10.desc'), 10,
|
||||||
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
|
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
|
||||||
('meta_25', '25개 달성', '25개 보유', 25,
|
('meta_25', tr('achieve.meta_25.name'), tr('achieve.meta_25.desc'), 25,
|
||||||
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
|
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
|
||||||
('meta_50', '50개 달성', '50개 보유', 50,
|
('meta_50', tr('achieve.meta_50.name'), tr('achieve.meta_50.desc'), 50,
|
||||||
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
|
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
|
||||||
('meta_75', '75개 달성', '75개 보유', 75,
|
('meta_75', tr('achieve.meta_75.name'), tr('achieve.meta_75.desc'), 75,
|
||||||
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
|
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
|
||||||
('meta_100', '100개 달성', '100개 보유', 100,
|
('meta_100', tr('achieve.meta_100.name'), tr('achieve.meta_100.desc'), 100,
|
||||||
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
|
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
|
||||||
('meta_secret_1', '시크릿 발견', '첫 시크릿 발견', 1,
|
('meta_secret_1', tr('achieve.meta_secret_1.name'), tr('achieve.meta_secret_1.desc'), 1,
|
||||||
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
|
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
|
||||||
('meta_secret_5', '시크릿 헌터', '시크릿 5개 발견', 5,
|
('meta_secret_5', tr('achieve.meta_secret_5.name'), tr('achieve.meta_secret_5.desc'), 5,
|
||||||
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
|
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from core.settings_keys import (
|
|||||||
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
|
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
|
||||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||||
)
|
)
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
@ -53,8 +54,8 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
dlog(f"connection close failed: {e}")
|
||||||
|
|
||||||
def _enable_concurrency(self):
|
def _enable_concurrency(self):
|
||||||
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
|
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
|
||||||
@ -118,7 +119,8 @@ 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:
|
except Exception as e:
|
||||||
|
dlog(f"holiday sync precheck failed: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
cur_year = _dt.now().year
|
cur_year = _dt.now().year
|
||||||
@ -131,8 +133,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:
|
except Exception as e:
|
||||||
pass
|
dlog(f"holiday sync worker failed: {e}")
|
||||||
|
|
||||||
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
||||||
t.start()
|
t.start()
|
||||||
@ -770,7 +772,7 @@ class Database:
|
|||||||
'dinner_duration_minutes': '60',
|
'dinner_duration_minutes': '60',
|
||||||
'auto_lunch': 'false',
|
'auto_lunch': 'false',
|
||||||
'auto_overtime': 'true',
|
'auto_overtime': 'true',
|
||||||
'theme': 'light',
|
'theme': 'dark',
|
||||||
'notification_before_minutes': '30',
|
'notification_before_minutes': '30',
|
||||||
'notification_clock_out': 'true',
|
'notification_clock_out': 'true',
|
||||||
'notification_lunch': 'true',
|
'notification_lunch': 'true',
|
||||||
@ -977,6 +979,19 @@ class Database:
|
|||||||
''', (work_record_id, earned_minutes, date))
|
''', (work_record_id, earned_minutes, date))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def delete_overtime_earned(self, bank_id: int) -> bool:
|
||||||
|
"""연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 실제로 삭제된 행이 있으면 True.
|
||||||
|
"""
|
||||||
|
with self._conn() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,))
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return deleted > 0
|
||||||
|
|
||||||
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
|
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
|
||||||
date: str, reason: str = None):
|
date: str, reason: str = None):
|
||||||
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
||||||
@ -1057,7 +1072,8 @@ class Database:
|
|||||||
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
||||||
recs = self.get_recurring_leaves(active_on=date_str)
|
recs = self.get_recurring_leaves(active_on=date_str)
|
||||||
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
dlog(f"recurring leave expansion failed: {e}")
|
||||||
recurring_days = 0.0
|
recurring_days = 0.0
|
||||||
|
|
||||||
return concrete_days + recurring_days
|
return concrete_days + recurring_days
|
||||||
@ -1343,10 +1359,14 @@ class Database:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
key = row['key']
|
key = row['key']
|
||||||
value = row['value']
|
value = row['value']
|
||||||
# 타입 변환
|
# 타입 변환 (bool 우선, 숫자, 문자열 순)
|
||||||
if value.lower() in ['true', 'false']:
|
lower = (value or '').lower()
|
||||||
settings[key] = value.lower() == 'true'
|
if lower in ('1', 'true', 'yes', 'on'):
|
||||||
else:
|
settings[key] = True
|
||||||
|
continue
|
||||||
|
if lower in ('0', 'false', 'no', 'off', ''):
|
||||||
|
settings[key] = False
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
settings[key] = int(value)
|
settings[key] = int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -1375,7 +1395,8 @@ class Database:
|
|||||||
pass
|
pass
|
||||||
elif 'work_hours' in synced and 'work_minutes' not in synced:
|
elif 'work_hours' in synced and 'work_minutes' not in synced:
|
||||||
try:
|
try:
|
||||||
synced['work_minutes'] = int(round(float(synced['work_hours']) * 60))
|
# 은행 반올림 회피: 명시적 반올림
|
||||||
|
synced['work_minutes'] = int(float(synced['work_hours']) * 60 + 0.5)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -1661,7 +1682,8 @@ class Database:
|
|||||||
''', (date, leave_type, days, memo))
|
''', (date, leave_type, days, memo))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
|
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
|
||||||
self.set_leave_balance(current_balance - days)
|
# get_leave_balance()가 leave_records를 실시간으로 계산하므로
|
||||||
|
# 별도로 leave_balance 설정값을 갱신할 필요가 없음.
|
||||||
|
|
||||||
# ===== 공휴일 관련 메서드 =====
|
# ===== 공휴일 관련 메서드 =====
|
||||||
|
|
||||||
@ -1825,7 +1847,8 @@ 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:
|
except Exception as e:
|
||||||
|
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()
|
||||||
|
|||||||
1928
core/i18n.py
1928
core/i18n.py
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,8 @@ from dataclasses import dataclass
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
|
|
||||||
|
|
||||||
_WEEKDAY_MAP = {
|
_WEEKDAY_MAP = {
|
||||||
'mon': 0, 'monday': 0,
|
'mon': 0, 'monday': 0,
|
||||||
@ -25,6 +27,7 @@ _WEEKDAY_MAP = {
|
|||||||
'sat': 5, 'saturday': 5,
|
'sat': 5, 'saturday': 5,
|
||||||
'sun': 6, 'sunday': 6,
|
'sun': 6, 'sunday': 6,
|
||||||
}
|
}
|
||||||
|
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -135,19 +138,16 @@ def _parse_date(s: Optional[str]) -> Optional[date]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
|
||||||
|
|
||||||
|
|
||||||
def describe_pattern(pattern: str) -> str:
|
def describe_pattern(pattern: str) -> str:
|
||||||
"""사용자에게 보여줄 패턴 설명. ko."""
|
"""사용자에게 보여줄 패턴 설명."""
|
||||||
parsed = _parse_pattern(pattern)
|
parsed = _parse_pattern(pattern)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return pattern
|
return pattern
|
||||||
kind, info = parsed
|
kind, info = parsed
|
||||||
if kind in ('weekly', 'biweekly'):
|
if kind in ('weekly', 'biweekly'):
|
||||||
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info]
|
||||||
prefix = '매주' if kind == 'weekly' else '격주'
|
prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly')
|
||||||
return f"{prefix} {','.join(names)}요일"
|
return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names))
|
||||||
if kind == 'monthly':
|
if kind == 'monthly':
|
||||||
return f"매월 {info}일"
|
return tr('recurring.pattern_monthly', day=info)
|
||||||
return pattern
|
return pattern
|
||||||
|
|||||||
@ -21,7 +21,8 @@ class TimeCalculator:
|
|||||||
if work_minutes is not None:
|
if work_minutes is not None:
|
||||||
self.work_minutes = int(work_minutes)
|
self.work_minutes = int(work_minutes)
|
||||||
elif work_hours is not None:
|
elif work_hours is not None:
|
||||||
self.work_minutes = int(round(float(work_hours) * 60))
|
# 은행 반올림(banker's rounding) 회피: 6.5시간 → 390분이 되도록 명시적 반올림
|
||||||
|
self.work_minutes = int(float(work_hours) * 60 + 0.5)
|
||||||
else:
|
else:
|
||||||
self.work_minutes = 480
|
self.work_minutes = 480
|
||||||
|
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.10.1'
|
__version__ = '2.12.0'
|
||||||
|
|||||||
BIN
font/NanumSquareB.otf
Normal file
BIN
font/NanumSquareB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareB.ttf
Normal file
BIN
font/NanumSquareB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.otf
Normal file
BIN
font/NanumSquareEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.ttf
Normal file
BIN
font/NanumSquareEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.otf
Normal file
BIN
font/NanumSquareL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.ttf
Normal file
BIN
font/NanumSquareL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acB.otf
Normal file
BIN
font/NanumSquareOTF_acB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acEB.otf
Normal file
BIN
font/NanumSquareOTF_acEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acL.otf
Normal file
BIN
font/NanumSquareOTF_acL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acR.otf
Normal file
BIN
font/NanumSquareOTF_acR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.otf
Normal file
BIN
font/NanumSquareR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.ttf
Normal file
BIN
font/NanumSquareR.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acB.ttf
Normal file
BIN
font/NanumSquare_acB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acEB.ttf
Normal file
BIN
font/NanumSquare_acEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acL.ttf
Normal file
BIN
font/NanumSquare_acL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acR.ttf
Normal file
BIN
font/NanumSquare_acR.ttf
Normal file
Binary file not shown.
5
main.py
5
main.py
@ -96,8 +96,9 @@ def main():
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# 폰트 설정
|
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
|
||||||
app.setFont(QFont("Segoe UI", 9))
|
from utils.font_loader import apply_app_font
|
||||||
|
apply_app_font(app, 9)
|
||||||
|
|
||||||
# 필수 패키지 확인
|
# 필수 패키지 확인
|
||||||
if not check_requirements():
|
if not check_requirements():
|
||||||
|
|||||||
19
main.spec
19
main.spec
@ -14,20 +14,35 @@ if os.path.exists(_staged):
|
|||||||
elif os.path.exists(_fallback):
|
elif os.path.exists(_fallback):
|
||||||
_extra_datas.append((_fallback, '.'))
|
_extra_datas.append((_fallback, '.'))
|
||||||
|
|
||||||
|
# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
|
||||||
|
_font_files = [
|
||||||
|
'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
|
||||||
|
'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
|
||||||
|
]
|
||||||
|
_font_datas = [
|
||||||
|
(os.path.join('font', f), 'font')
|
||||||
|
for f in _font_files if os.path.exists(os.path.join('font', f))
|
||||||
|
]
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[('3d-alarm.png', '.')] + _extra_datas,
|
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'holidays', 'holidays.countries.south_korea',
|
'holidays', 'holidays.countries.south_korea',
|
||||||
'win32evtlog', 'win32evtlogutil',
|
'win32evtlog', 'win32evtlogutil',
|
||||||
|
'matplotlib.backends.backend_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
|
||||||
'matplotlib.backends.backend_qt5agg',
|
'matplotlib.backends.backend_qt5agg',
|
||||||
|
'PyQt5.QtSvg',
|
||||||
|
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
|
||||||
|
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
|
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
|
||||||
|
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -86,12 +86,12 @@ OkMsg "All checks passed (Version: $Version)"
|
|||||||
# ====== 1. Bump version.py ======
|
# ====== 1. Bump version.py ======
|
||||||
Step "1/7 Bump core/version.py"
|
Step "1/7 Bump core/version.py"
|
||||||
$verFile = 'core/version.py'
|
$verFile = 'core/version.py'
|
||||||
$verContent = Get-Content $verFile -Raw
|
$verContent = [System.IO.File]::ReadAllText((Resolve-Path $verFile).Path, [System.Text.Encoding]::UTF8)
|
||||||
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
|
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
|
||||||
if ($verContent -eq $newContent) {
|
if ($verContent -eq $newContent) {
|
||||||
Info "Already at $VersionRaw (no change)"
|
Info "Already at $VersionRaw (no change)"
|
||||||
} else {
|
} else {
|
||||||
if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline }
|
if (-not $DryRun) { [System.IO.File]::WriteAllText((Resolve-Path $verFile).Path, $newContent, [System.Text.Encoding]::UTF8) }
|
||||||
OkMsg "$verFile -> $VersionRaw"
|
OkMsg "$verFile -> $VersionRaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,15 @@ utils.csv_importer 단위 테스트.
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records
|
||||||
|
from core.database import Database
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeTime:
|
class TestNormalizeTime:
|
||||||
@ -125,3 +127,54 @@ class TestParseCsv:
|
|||||||
assert '줄 3' in str(exc.value)
|
assert '줄 3' in str(exc.value)
|
||||||
finally:
|
finally:
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportRecords:
|
||||||
|
def _db(self):
|
||||||
|
p = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||||
|
p.close()
|
||||||
|
db = Database(p.name)
|
||||||
|
db.save_settings({
|
||||||
|
'work_minutes': '480',
|
||||||
|
'lunch_duration_minutes': '60',
|
||||||
|
'dinner_duration_minutes': '60',
|
||||||
|
})
|
||||||
|
return db, p.name
|
||||||
|
|
||||||
|
def test_overwrite_clears_overtime_usage(self):
|
||||||
|
"""CSV 덮어쓰기 시 overtime_usage도 삭제되어 잔액이 일관성을 유지해야 함."""
|
||||||
|
db, path = self._db()
|
||||||
|
try:
|
||||||
|
date_str = '2026-04-01'
|
||||||
|
# 기존 기록 + 연장근무 사용 기록 생성
|
||||||
|
wid = db.add_work_record(date_str, '09:00:00')
|
||||||
|
db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120)
|
||||||
|
db.add_overtime_earned(wid, 120, date_str)
|
||||||
|
db.add_overtime_usage(wid, 30, date_str, '테스트')
|
||||||
|
|
||||||
|
# 덮어쓰기 전 잔액
|
||||||
|
balance_before = db.get_total_overtime_balance()
|
||||||
|
assert balance_before == 90 # 120 적립 - 30 사용
|
||||||
|
|
||||||
|
rows = [{
|
||||||
|
'date': date_str,
|
||||||
|
'clock_in': '09:00:00',
|
||||||
|
'clock_out': '18:00:00',
|
||||||
|
'lunch_minutes': 60,
|
||||||
|
'dinner_minutes': 0,
|
||||||
|
'memo': '',
|
||||||
|
}]
|
||||||
|
import_records(db, rows, on_conflict='overwrite')
|
||||||
|
|
||||||
|
# 덮어쓰기 후 연장근무 사용 기록은 삭제되어야 함
|
||||||
|
with db._conn() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,))
|
||||||
|
assert cur.fetchone()[0] == 0
|
||||||
|
balance_after = db.get_total_overtime_balance()
|
||||||
|
assert balance_after == 0 # 새 기록은 연장근무 없음
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|||||||
@ -162,5 +162,12 @@ class TestFetchNetwork:
|
|||||||
|
|
||||||
|
|
||||||
class TestConfigured:
|
class TestConfigured:
|
||||||
def test_key_set(self):
|
def test_key_set(self, monkeypatch):
|
||||||
assert is_configured() is True
|
import utils.holiday_api as _ha
|
||||||
|
monkeypatch.setattr(_ha, '_SERVICE_KEY', 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93')
|
||||||
|
assert _ha.is_configured() is True
|
||||||
|
|
||||||
|
def test_key_empty(self, monkeypatch):
|
||||||
|
import utils.holiday_api as _ha
|
||||||
|
monkeypatch.setattr(_ha, '_SERVICE_KEY', '')
|
||||||
|
assert _ha.is_configured() is False
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
|
|||||||
set_language('ko')
|
set_language('ko')
|
||||||
label = QLabel()
|
label = QLabel()
|
||||||
i18n.register(label, 'btn.save')
|
i18n.register(label, 'btn.save')
|
||||||
assert label.text() == '💾 저장'
|
assert label.text() == '저장'
|
||||||
|
|
||||||
|
|
||||||
def test_retranslate_after_language_change(qapp, i18n):
|
def test_retranslate_after_language_change(qapp, i18n):
|
||||||
@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
|
|||||||
set_language('ko')
|
set_language('ko')
|
||||||
dlg = QDialog()
|
dlg = QDialog()
|
||||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||||
assert dlg.windowTitle() == '⚙️ 설정'
|
assert dlg.windowTitle() == '설정'
|
||||||
|
|
||||||
i18n.set_language_and_retranslate('en')
|
i18n.set_language_and_retranslate('en')
|
||||||
assert dlg.windowTitle() == '⚙️ Settings'
|
assert dlg.windowTitle() == 'Settings'
|
||||||
|
|
||||||
|
|
||||||
def test_post_callback_applied(qapp, i18n):
|
def test_post_callback_applied(qapp, i18n):
|
||||||
@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
|
|||||||
set_language('ko')
|
set_language('ko')
|
||||||
label = QLabel()
|
label = QLabel()
|
||||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||||
assert label.text() == '[💾 저장]'
|
assert label.text() == '[저장]'
|
||||||
|
|
||||||
i18n.set_language_and_retranslate('en')
|
i18n.set_language_and_retranslate('en')
|
||||||
assert label.text() == '[💾 Save]'
|
assert label.text() == '[Save]'
|
||||||
|
|
||||||
|
|
||||||
def test_dead_widget_pruned(qapp, i18n):
|
def test_dead_widget_pruned(qapp, i18n):
|
||||||
|
|||||||
72
tests/test_overtime_accrual_guard.py
Normal file
72
tests/test_overtime_accrual_guard.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""연장근무 자동 적립 가드 테스트.
|
||||||
|
|
||||||
|
auto_overtime(자동 적립)가 OFF면, 자동 퇴근 경로(근무일 경계 롤오버 등)에서도
|
||||||
|
은행 적립을 하지 않아야 한다 — clock_out() 대화상자에서 '아니오'를 고른 것과 동일한 의미.
|
||||||
|
|
||||||
|
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
|
||||||
|
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, time as dtime
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from core.database import Database
|
||||||
|
from core.time_calculator import TimeCalculator
|
||||||
|
from ui.main_window import MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def _rollover_balance(db, monkeypatch):
|
||||||
|
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
|
||||||
|
from PyQt5.QtWidgets import QMessageBox
|
||||||
|
monkeypatch.setattr(QMessageBox, 'information',
|
||||||
|
staticmethod(lambda *a, **k: QMessageBox.Ok))
|
||||||
|
|
||||||
|
today = datetime.now().date()
|
||||||
|
y = today - timedelta(days=1)
|
||||||
|
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
|
||||||
|
|
||||||
|
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
|
||||||
|
w.db = db
|
||||||
|
w.time_calc = TimeCalculator(work_minutes=480)
|
||||||
|
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
|
||||||
|
w.is_clocked_in = True
|
||||||
|
w.midnight_rollover_handled = False
|
||||||
|
w.is_on_break = False
|
||||||
|
w.lunch_break_enabled = False
|
||||||
|
w.dinner_break_enabled = False
|
||||||
|
w.load_today_data = lambda: None # tail UI refresh stub
|
||||||
|
w.update_overtime_balance = lambda: None # tail UI refresh stub
|
||||||
|
|
||||||
|
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
|
||||||
|
return db.get_total_overtime_balance()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
|
||||||
|
db = Database(str(tmp_path / 'off.db'))
|
||||||
|
db.set_setting('auto_overtime', 'false')
|
||||||
|
assert _rollover_balance(db, monkeypatch) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
|
||||||
|
db = Database(str(tmp_path / 'on.db'))
|
||||||
|
db.set_setting('auto_overtime', 'true')
|
||||||
|
assert _rollover_balance(db, monkeypatch) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_overtime_earned_reduces_balance(tmp_path):
|
||||||
|
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
|
||||||
|
from datetime import date
|
||||||
|
db = Database(str(tmp_path / 'del.db'))
|
||||||
|
today = date.today().isoformat()
|
||||||
|
db.add_overtime_earned(None, 90, today)
|
||||||
|
assert db.get_total_overtime_balance() == 90
|
||||||
|
|
||||||
|
bank_id = db.get_connection().execute(
|
||||||
|
'SELECT id FROM overtime_bank').fetchone()[0]
|
||||||
|
assert db.delete_overtime_earned(bank_id) is True
|
||||||
|
assert db.get_total_overtime_balance() == 0
|
||||||
|
|
||||||
|
# 없는 id 삭제는 False
|
||||||
|
assert db.delete_overtime_earned(999999) is False
|
||||||
@ -16,7 +16,9 @@ from PyQt5.QtCore import Qt
|
|||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
|
|
||||||
from core.achievements import get_all_with_status, get_stats
|
from core.achievements import get_all_with_status, get_stats
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||||
|
|
||||||
|
|
||||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||||
@ -28,7 +30,6 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#241810',
|
'bg_bot': '#241810',
|
||||||
'text': '#ffd9a8',
|
'text': '#ffd9a8',
|
||||||
'label': '🥉',
|
'label': '🥉',
|
||||||
'name': '브론즈',
|
|
||||||
},
|
},
|
||||||
'silver': {
|
'silver': {
|
||||||
'border': '#a8a8a8',
|
'border': '#a8a8a8',
|
||||||
@ -37,7 +38,6 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#1c1c22',
|
'bg_bot': '#1c1c22',
|
||||||
'text': '#e8e8f0',
|
'text': '#e8e8f0',
|
||||||
'label': '🥈',
|
'label': '🥈',
|
||||||
'name': '실버',
|
|
||||||
},
|
},
|
||||||
'gold': {
|
'gold': {
|
||||||
'border': '#ffb700',
|
'border': '#ffb700',
|
||||||
@ -46,7 +46,6 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#241c08',
|
'bg_bot': '#241c08',
|
||||||
'text': '#ffe9a0',
|
'text': '#ffe9a0',
|
||||||
'label': '🥇',
|
'label': '🥇',
|
||||||
'name': '골드',
|
|
||||||
},
|
},
|
||||||
'platinum': {
|
'platinum': {
|
||||||
'border': '#7fdbff',
|
'border': '#7fdbff',
|
||||||
@ -55,7 +54,6 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#0e1f28',
|
'bg_bot': '#0e1f28',
|
||||||
'text': '#c5ecff',
|
'text': '#c5ecff',
|
||||||
'label': '💎',
|
'label': '💎',
|
||||||
'name': '플래티넘',
|
|
||||||
},
|
},
|
||||||
'legend': {
|
'legend': {
|
||||||
'border': '#ff6b9d',
|
'border': '#ff6b9d',
|
||||||
@ -64,20 +62,9 @@ TIER_THEMES = {
|
|||||||
'bg_bot': '#26101a',
|
'bg_bot': '#26101a',
|
||||||
'text': '#ffc0d4',
|
'text': '#ffc0d4',
|
||||||
'label': '🌟',
|
'label': '🌟',
|
||||||
'name': '레전드',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
CATEGORY_LABELS = {
|
|
||||||
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
|
||||||
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
|
||||||
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
|
||||||
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
|
||||||
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
|
||||||
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
|
||||||
'ambition': '야망', 'meta': '메타',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AchievementsView(QDialog):
|
class AchievementsView(QDialog):
|
||||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||||
@ -85,13 +72,13 @@ class AchievementsView(QDialog):
|
|||||||
def __init__(self, db, parent=None):
|
def __init__(self, db, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("🏆 도전과제")
|
self.setWindowTitle(tr('achieve.title'))
|
||||||
self.setMinimumSize(960, 720)
|
self.setMinimumSize(960, 720)
|
||||||
self.resize(1100, 800)
|
self.resize(1100, 800)
|
||||||
self._increment_view_count()
|
self._increment_view_count()
|
||||||
self.setStyleSheet("QDialog { background: #0e0e14; }")
|
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
apply_dark_titlebar(self, dark=True)
|
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||||
|
|
||||||
def _increment_view_count(self) -> None:
|
def _increment_view_count(self) -> None:
|
||||||
try:
|
try:
|
||||||
@ -120,30 +107,23 @@ class AchievementsView(QDialog):
|
|||||||
if a['earned_date'] is None and not a['is_secret']]
|
if a['earned_date'] is None and not a['is_secret']]
|
||||||
secret_items = [a for a in all_items if a['is_secret']]
|
secret_items = [a for a in all_items if a['is_secret']]
|
||||||
|
|
||||||
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
|
||||||
self.tabs.addTab(self._build_grid_tab(in_progress),
|
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||||
f"⚡ 진행 중 · {len(in_progress)}")
|
tr('achieve.tab_in_progress', count=len(in_progress)))
|
||||||
self.tabs.addTab(self._build_grid_tab(earned_items),
|
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||||
f"✓ 완료 · {len(earned_items)}")
|
tr('achieve.tab_completed', count=len(earned_items)))
|
||||||
self.tabs.addTab(
|
self.tabs.addTab(
|
||||||
self._build_grid_tab(secret_items, secret_mode=True),
|
self._build_grid_tab(secret_items, secret_mode=True),
|
||||||
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
|
tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total'])
|
||||||
)
|
)
|
||||||
layout.addWidget(self.tabs, 1)
|
layout.addWidget(self.tabs, 1)
|
||||||
|
|
||||||
# === 닫기 버튼 ===
|
# === 닫기 버튼 ===
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
close_btn = QPushButton("닫기")
|
close_btn = QPushButton(tr('btn.close'))
|
||||||
close_btn.setMinimumWidth(100)
|
close_btn.setMinimumWidth(100)
|
||||||
close_btn.setStyleSheet("""
|
close_btn.setStyleSheet(button_qss('default'))
|
||||||
QPushButton {
|
|
||||||
background: #2a2a36; color: #e0e0e8;
|
|
||||||
border: 1px solid #44446a; border-radius: 6px;
|
|
||||||
padding: 8px 20px; font-size: 10pt;
|
|
||||||
}
|
|
||||||
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
|
|
||||||
""")
|
|
||||||
close_btn.clicked.connect(self.accept)
|
close_btn.clicked.connect(self.accept)
|
||||||
btn_row.addWidget(close_btn)
|
btn_row.addWidget(close_btn)
|
||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
@ -153,14 +133,13 @@ class AchievementsView(QDialog):
|
|||||||
# ----- 헤더 -----
|
# ----- 헤더 -----
|
||||||
def _build_header(self, stats: dict) -> QWidget:
|
def _build_header(self, stats: dict) -> QWidget:
|
||||||
container = QFrame()
|
container = QFrame()
|
||||||
container.setStyleSheet("""
|
container.setStyleSheet(f"""
|
||||||
QFrame {
|
QFrame {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: {tc('panel')};
|
||||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
border: 1px solid {tc('border')};
|
||||||
border: 1px solid #3a3a5a;
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}}
|
||||||
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
||||||
""")
|
""")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
@ -172,22 +151,28 @@ class AchievementsView(QDialog):
|
|||||||
num_row = QHBoxLayout()
|
num_row = QHBoxLayout()
|
||||||
num_row.setSpacing(24)
|
num_row.setSpacing(24)
|
||||||
|
|
||||||
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
|
||||||
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>")
|
if _is_dark():
|
||||||
|
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
|
||||||
|
else:
|
||||||
|
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
|
||||||
|
|
||||||
|
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
|
||||||
|
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
|
||||||
big.setTextFormat(Qt.RichText)
|
big.setTextFormat(Qt.RichText)
|
||||||
num_row.addWidget(big)
|
num_row.addWidget(big)
|
||||||
|
|
||||||
spacer = QFrame()
|
spacer = QFrame()
|
||||||
spacer.setFrameShape(QFrame.VLine)
|
spacer.setFrameShape(QFrame.VLine)
|
||||||
spacer.setStyleSheet("color: #3a3a5a;")
|
spacer.setStyleSheet(f"color: {tc('border')};")
|
||||||
num_row.addWidget(spacer)
|
num_row.addWidget(spacer)
|
||||||
|
|
||||||
secret_lbl = QLabel(
|
secret_lbl = QLabel(
|
||||||
f"<div style='line-height: 1.3;'>"
|
f"<div style='line-height: 1.3;'>"
|
||||||
f"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>"
|
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
|
||||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||||
f"{stats['secret_earned']}</span>"
|
f"{stats['secret_earned']}</span>"
|
||||||
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
|
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||||
f"</div>"
|
f"</div>"
|
||||||
)
|
)
|
||||||
secret_lbl.setTextFormat(Qt.RichText)
|
secret_lbl.setTextFormat(Qt.RichText)
|
||||||
@ -197,8 +182,8 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
pct_lbl = QLabel(
|
pct_lbl = QLabel(
|
||||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||||
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>"
|
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
|
||||||
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||||
f"{pct:.1f}%</span></div>"
|
f"{pct:.1f}%</span></div>"
|
||||||
)
|
)
|
||||||
pct_lbl.setTextFormat(Qt.RichText)
|
pct_lbl.setTextFormat(Qt.RichText)
|
||||||
@ -214,17 +199,17 @@ class AchievementsView(QDialog):
|
|||||||
bar.setTextVisible(False)
|
bar.setTextVisible(False)
|
||||||
bar.setMinimumHeight(8)
|
bar.setMinimumHeight(8)
|
||||||
bar.setMaximumHeight(8)
|
bar.setMaximumHeight(8)
|
||||||
bar.setStyleSheet("""
|
bar.setStyleSheet(f"""
|
||||||
QProgressBar {
|
QProgressBar {{
|
||||||
background: #1a1a26;
|
background: {tc('panel2')};
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}}
|
||||||
QProgressBar::chunk {
|
QProgressBar::chunk {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}}
|
||||||
""")
|
""")
|
||||||
layout.addWidget(bar)
|
layout.addWidget(bar)
|
||||||
|
|
||||||
@ -235,17 +220,7 @@ class AchievementsView(QDialog):
|
|||||||
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||||
scroll = QScrollArea()
|
scroll = QScrollArea()
|
||||||
scroll.setWidgetResizable(True)
|
scroll.setWidgetResizable(True)
|
||||||
scroll.setStyleSheet("""
|
scroll.setStyleSheet(scroll_qss())
|
||||||
QScrollArea { background: transparent; border: none; }
|
|
||||||
QScrollBar:vertical {
|
|
||||||
background: #1a1a24; width: 10px; border-radius: 5px;
|
|
||||||
}
|
|
||||||
QScrollBar::handle:vertical {
|
|
||||||
background: #44446a; border-radius: 5px; min-height: 30px;
|
|
||||||
}
|
|
||||||
QScrollBar::handle:vertical:hover { background: #6b9eff; }
|
|
||||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
|
||||||
""")
|
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
container.setStyleSheet("background: transparent;")
|
container.setStyleSheet("background: transparent;")
|
||||||
grid = QGridLayout()
|
grid = QGridLayout()
|
||||||
@ -253,10 +228,10 @@ class AchievementsView(QDialog):
|
|||||||
grid.setContentsMargins(8, 8, 8, 8)
|
grid.setContentsMargins(8, 8, 8, 8)
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
empty = QLabel("(아직 없음)")
|
empty = QLabel(tr('achieve.empty'))
|
||||||
empty.setAlignment(Qt.AlignCenter)
|
empty.setAlignment(Qt.AlignCenter)
|
||||||
empty.setStyleSheet(
|
empty.setStyleSheet(
|
||||||
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
|
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||||
)
|
)
|
||||||
grid.addWidget(empty, 0, 0)
|
grid.addWidget(empty, 0, 0)
|
||||||
else:
|
else:
|
||||||
@ -279,11 +254,18 @@ class AchievementsView(QDialog):
|
|||||||
tier = item['tier'] or 'bronze'
|
tier = item['tier'] or 'bronze'
|
||||||
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
||||||
|
|
||||||
# 시크릿 미발견은 회색 톤으로
|
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
|
||||||
|
light = not _is_dark()
|
||||||
if is_locked_secret:
|
if is_locked_secret:
|
||||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
if light:
|
||||||
border = '#3a3a4a'
|
bg_top = bg_bot = tc('panel'); border = tc('border')
|
||||||
text_color = '#666'
|
else:
|
||||||
|
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
|
||||||
|
text_color = tc('text_faint')
|
||||||
|
elif light:
|
||||||
|
bg_top = bg_bot = tc('panel')
|
||||||
|
border = theme['border_strong'] if is_earned else theme['border']
|
||||||
|
text_color = tc('text') if is_earned else tc('text_dim')
|
||||||
else:
|
else:
|
||||||
bg_top = theme['bg_top']
|
bg_top = theme['bg_top']
|
||||||
bg_bot = theme['bg_bot']
|
bg_bot = theme['bg_bot']
|
||||||
@ -342,19 +324,19 @@ class AchievementsView(QDialog):
|
|||||||
name = QLabel(name_text)
|
name = QLabel(name_text)
|
||||||
name.setStyleSheet(
|
name.setStyleSheet(
|
||||||
f"font-size: 12pt; font-weight: bold; "
|
f"font-size: 12pt; font-weight: bold; "
|
||||||
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
|
f"color: {tc('text') if is_earned else tc('text_dim')}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
)
|
)
|
||||||
name.setWordWrap(True)
|
name.setWordWrap(True)
|
||||||
name_box.addWidget(name)
|
name_box.addWidget(name)
|
||||||
|
|
||||||
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
|
cat_text = tr(f"achieve.cat_{item['category']}")
|
||||||
if not is_locked_secret:
|
if not is_locked_secret:
|
||||||
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} · {cat_text} ")
|
||||||
cat_label.setStyleSheet(
|
cat_label.setStyleSheet(
|
||||||
f"font-size: 8.5pt; "
|
f"font-size: 8.5pt; "
|
||||||
f"color: {theme['border_strong']}; "
|
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
||||||
f"background: rgba(255,255,255,0.05); "
|
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
|
||||||
f"border: 1px solid {theme['border']}; "
|
f"border: 1px solid {theme['border']}; "
|
||||||
f"border-radius: 8px; "
|
f"border-radius: 8px; "
|
||||||
f"padding: 1px 4px;"
|
f"padding: 1px 4px;"
|
||||||
@ -372,24 +354,24 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
# 2행: 설명
|
# 2행: 설명
|
||||||
if is_locked_secret:
|
if is_locked_secret:
|
||||||
desc_text = "🔒 달성하면 공개됩니다"
|
desc_text = tr('achieve.secret_locked')
|
||||||
else:
|
else:
|
||||||
desc_text = item['description'] or ''
|
desc_text = item['description'] or ''
|
||||||
desc = QLabel(desc_text)
|
desc = QLabel(desc_text)
|
||||||
desc.setWordWrap(True)
|
desc.setWordWrap(True)
|
||||||
desc.setStyleSheet(
|
desc.setStyleSheet(
|
||||||
f"color: #a0a0b8; font-size: 9.5pt; "
|
f"color: {tc('text_dim')}; font-size: 9.5pt; "
|
||||||
f"background: transparent; border: none; padding: 0;"
|
f"background: transparent; border: none; padding: 0;"
|
||||||
)
|
)
|
||||||
outer.addWidget(desc)
|
outer.addWidget(desc)
|
||||||
|
|
||||||
# 3행: 진행 게이지 또는 획득 일자
|
# 3행: 진행 게이지 또는 획득 일자
|
||||||
if is_earned:
|
if is_earned:
|
||||||
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
earned = QLabel(tr('achieve.earned_date', date=item['earned_date']))
|
||||||
earned.setStyleSheet(
|
earned.setStyleSheet(
|
||||||
f"color: {theme['border_strong']}; "
|
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||||
f"font-weight: bold; font-size: 9.5pt; "
|
f"font-weight: bold; font-size: 9.5pt; "
|
||||||
f"background: rgba(255,255,255,0.08); "
|
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
|
||||||
f"border: 1px solid {theme['border']}; "
|
f"border: 1px solid {theme['border']}; "
|
||||||
f"border-radius: 6px; padding: 4px 8px;"
|
f"border-radius: 6px; padding: 4px 8px;"
|
||||||
)
|
)
|
||||||
@ -415,7 +397,7 @@ class AchievementsView(QDialog):
|
|||||||
pb.setMaximumHeight(10)
|
pb.setMaximumHeight(10)
|
||||||
pb.setStyleSheet(f"""
|
pb.setStyleSheet(f"""
|
||||||
QProgressBar {{
|
QProgressBar {{
|
||||||
background: rgba(0,0,0,0.4);
|
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}}
|
}}
|
||||||
@ -429,7 +411,7 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
num = QLabel(f"{progress} / {target}")
|
num = QLabel(f"{progress} / {target}")
|
||||||
num.setStyleSheet(
|
num.setStyleSheet(
|
||||||
f"color: {theme['border_strong']}; font-size: 9pt; "
|
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
|
||||||
f"font-weight: bold; background: transparent; border: none;"
|
f"font-weight: bold; background: transparent; border: none;"
|
||||||
)
|
)
|
||||||
num.setMinimumWidth(60)
|
num.setMinimumWidth(60)
|
||||||
@ -453,32 +435,5 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
# ----- 탭 QSS (다이얼로그 전용) -----
|
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||||
def _tabs_qss(self) -> str:
|
def _tabs_qss(self) -> str:
|
||||||
return """
|
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
|
||||||
QTabWidget::pane {
|
return tabs_qss(ACCENT_GOLD)
|
||||||
background: #14141c;
|
|
||||||
border: 1px solid #2a2a3a;
|
|
||||||
border-radius: 10px;
|
|
||||||
top: -1px;
|
|
||||||
}
|
|
||||||
QTabBar::tab {
|
|
||||||
background: #1c1c28;
|
|
||||||
color: #a0a0b8;
|
|
||||||
padding: 9px 18px;
|
|
||||||
border: 1px solid #2a2a3a;
|
|
||||||
border-bottom: none;
|
|
||||||
border-top-left-radius: 8px;
|
|
||||||
border-top-right-radius: 8px;
|
|
||||||
margin-right: 3px;
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
QTabBar::tab:selected {
|
|
||||||
background: #14141c;
|
|
||||||
color: #ffd24a;
|
|
||||||
font-weight: bold;
|
|
||||||
border-bottom: 2px solid #ffd24a;
|
|
||||||
}
|
|
||||||
QTabBar::tab:hover:!selected {
|
|
||||||
background: #2a2a36;
|
|
||||||
color: #e0e0e8;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import os
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
from core.database import Database
|
from core.database import Database
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import ThemeColors, apply_dark_titlebar
|
from ui.styles import ThemeColors, apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -37,7 +38,7 @@ class CalendarView(QDialog):
|
|||||||
layout.setContentsMargins(12, 10, 12, 10)
|
layout.setContentsMargins(12, 10, 12, 10)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel("월간 근무 기록")
|
title = QLabel(tr('cal.dialog_title'))
|
||||||
title.setObjectName("dialog_title")
|
title.setObjectName("dialog_title")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignCenter)
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
@ -55,15 +56,16 @@ class CalendarView(QDialog):
|
|||||||
# 범례
|
# 범례
|
||||||
legend_layout = QHBoxLayout()
|
legend_layout = QHBoxLayout()
|
||||||
legend_layout.setSpacing(12)
|
legend_layout.setSpacing(12)
|
||||||
legend_layout.addWidget(QLabel("🟢 정상"))
|
for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')),
|
||||||
legend_layout.addWidget(QLabel("🔴 연장"))
|
('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]:
|
||||||
legend_layout.addWidget(QLabel("🟡 휴가"))
|
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||||
legend_layout.addWidget(QLabel("⚪ 없음"))
|
_item.setTextFormat(Qt.RichText)
|
||||||
|
legend_layout.addWidget(_item)
|
||||||
legend_layout.addStretch()
|
legend_layout.addStretch()
|
||||||
layout.addLayout(legend_layout)
|
layout.addLayout(legend_layout)
|
||||||
|
|
||||||
# 선택된 날짜 상세 정보
|
# 선택된 날짜 상세 정보
|
||||||
detail_group = QGroupBox("선택된 날짜 정보")
|
detail_group = QGroupBox(tr('cal.detail_group_title'))
|
||||||
detail_layout = QVBoxLayout()
|
detail_layout = QVBoxLayout()
|
||||||
detail_layout.setSpacing(6)
|
detail_layout.setSpacing(6)
|
||||||
detail_layout.setContentsMargins(10, 20, 10, 8)
|
detail_layout.setContentsMargins(10, 20, 10, 8)
|
||||||
@ -77,13 +79,13 @@ class CalendarView(QDialog):
|
|||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setSpacing(6)
|
button_layout.setSpacing(6)
|
||||||
|
|
||||||
self.edit_time_button = QPushButton("✏️ 시간 수정")
|
self.edit_time_button = QPushButton(tr('cal.edit_time'))
|
||||||
self.edit_time_button.setObjectName("btn_primary")
|
self.edit_time_button.setObjectName("btn_primary")
|
||||||
self.edit_time_button.setEnabled(False)
|
self.edit_time_button.setEnabled(False)
|
||||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||||
button_layout.addWidget(self.edit_time_button)
|
button_layout.addWidget(self.edit_time_button)
|
||||||
|
|
||||||
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
|
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
||||||
self.delete_record_button.setObjectName("btn_danger")
|
self.delete_record_button.setObjectName("btn_danger")
|
||||||
self.delete_record_button.setEnabled(False)
|
self.delete_record_button.setEnabled(False)
|
||||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||||
@ -94,17 +96,17 @@ class CalendarView(QDialog):
|
|||||||
layout.addWidget(detail_group)
|
layout.addWidget(detail_group)
|
||||||
|
|
||||||
# 메모 그룹
|
# 메모 그룹
|
||||||
memo_group = QGroupBox("메모")
|
memo_group = QGroupBox(tr('cal.memo_group'))
|
||||||
memo_layout = QVBoxLayout()
|
memo_layout = QVBoxLayout()
|
||||||
memo_layout.setSpacing(6)
|
memo_layout.setSpacing(6)
|
||||||
memo_layout.setContentsMargins(10, 20, 10, 8)
|
memo_layout.setContentsMargins(10, 20, 10, 8)
|
||||||
|
|
||||||
self.memo_edit = QTextEdit()
|
self.memo_edit = QTextEdit()
|
||||||
self.memo_edit.setMaximumHeight(70)
|
self.memo_edit.setMaximumHeight(70)
|
||||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder'))
|
||||||
memo_layout.addWidget(self.memo_edit)
|
memo_layout.addWidget(self.memo_edit)
|
||||||
|
|
||||||
self.save_memo_button = QPushButton("💾 메모 저장")
|
self.save_memo_button = QPushButton(tr('cal.save_memo'))
|
||||||
self.save_memo_button.setObjectName("btn_primary")
|
self.save_memo_button.setObjectName("btn_primary")
|
||||||
self.save_memo_button.setEnabled(False)
|
self.save_memo_button.setEnabled(False)
|
||||||
self.save_memo_button.clicked.connect(self.save_memo)
|
self.save_memo_button.clicked.connect(self.save_memo)
|
||||||
@ -163,21 +165,23 @@ class CalendarView(QDialog):
|
|||||||
existing = self.db.get_work_record(date_str)
|
existing = self.db.get_work_record(date_str)
|
||||||
|
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
edit_action = delete_action = add_action = None
|
||||||
if existing:
|
if existing:
|
||||||
edit_action = menu.addAction(f"✏️ {date_str} 편집")
|
edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
|
||||||
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
|
delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
|
||||||
else:
|
else:
|
||||||
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
|
add_action = menu.addAction(tr('cal.context_add', date=date_str))
|
||||||
|
|
||||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||||
if action is None:
|
if action is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if existing and action.text().startswith("✏️"):
|
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||||
|
if action == edit_action:
|
||||||
self._open_edit_dialog(date_str)
|
self._open_edit_dialog(date_str)
|
||||||
elif existing and action.text().startswith("🗑️"):
|
elif action == delete_action:
|
||||||
self._delete_record(date_str)
|
self._delete_record(date_str)
|
||||||
elif not existing and action.text().startswith("➕"):
|
elif action == add_action:
|
||||||
self._add_past_record(date_str)
|
self._add_past_record(date_str)
|
||||||
|
|
||||||
def _add_past_record(self, date_str: str):
|
def _add_past_record(self, date_str: str):
|
||||||
@ -214,9 +218,9 @@ class CalendarView(QDialog):
|
|||||||
if ot_earned > 0:
|
if ot_earned > 0:
|
||||||
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
|
QMessageBox.information(self, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
|
QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e))
|
||||||
|
|
||||||
def _open_edit_dialog(self, date_str: str):
|
def _open_edit_dialog(self, date_str: str):
|
||||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||||
@ -228,8 +232,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
def _delete_record(self, date_str: str):
|
def _delete_record(self, date_str: str):
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "삭제 확인",
|
tr('cal.delete_confirm_title'),
|
||||||
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
tr('cal.delete_confirm_body', date=date_str),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.Yes:
|
if reply != QMessageBox.Yes:
|
||||||
@ -242,10 +246,10 @@ class CalendarView(QDialog):
|
|||||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
self._refresh_calendar()
|
self._refresh_calendar()
|
||||||
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
|
QMessageBox.information(self, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
QMessageBox.critical(self, "오류", str(e))
|
QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@ -267,33 +271,33 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
if record:
|
if record:
|
||||||
# 상세 정보 표시
|
# 상세 정보 표시
|
||||||
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
|
||||||
detail += f"출근: {record['clock_in']}\n"
|
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
||||||
|
|
||||||
if record.get('clock_out'):
|
if record.get('clock_out'):
|
||||||
detail += f"퇴근: {record['clock_out']}\n"
|
detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
|
||||||
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
|
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
||||||
|
|
||||||
if record.get('lunch_break'):
|
if record.get('lunch_break'):
|
||||||
detail += f"점심시간: 사용함\n"
|
detail += tr('cal.detail_lunch_used') + '\n'
|
||||||
else:
|
else:
|
||||||
detail += f"점심시간: 미사용\n"
|
detail += tr('cal.detail_lunch_unused') + '\n'
|
||||||
|
|
||||||
if record.get('dinner_break'):
|
if record.get('dinner_break'):
|
||||||
detail += f"저녁시간: 사용함\n"
|
detail += tr('cal.detail_dinner_used') + '\n'
|
||||||
else:
|
else:
|
||||||
detail += f"저녁시간: 미사용\n"
|
detail += tr('cal.detail_dinner_unused') + '\n'
|
||||||
|
|
||||||
if record.get('overtime_earned', 0) > 0:
|
if record.get('overtime_earned', 0) > 0:
|
||||||
earned_min = record['overtime_earned']
|
earned_min = record['overtime_earned']
|
||||||
earned_hours = earned_min // 60
|
earned_hours = earned_min // 60
|
||||||
earned_mins = earned_min % 60
|
earned_mins = earned_min % 60
|
||||||
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n"
|
detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n'
|
||||||
else:
|
else:
|
||||||
detail += f"퇴근: 미기록\n"
|
detail += tr('cal.detail_clock_out_none') + '\n'
|
||||||
|
|
||||||
if record.get('memo'):
|
if record.get('memo'):
|
||||||
detail += f"\n메모: {record['memo']}\n"
|
detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n'
|
||||||
|
|
||||||
self.detail_text.setText(detail)
|
self.detail_text.setText(detail)
|
||||||
self.edit_time_button.setEnabled(True)
|
self.edit_time_button.setEnabled(True)
|
||||||
@ -303,7 +307,7 @@ class CalendarView(QDialog):
|
|||||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||||
self.save_memo_button.setEnabled(True)
|
self.save_memo_button.setEnabled(True)
|
||||||
else:
|
else:
|
||||||
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
|
||||||
self.edit_time_button.setEnabled(False)
|
self.edit_time_button.setEnabled(False)
|
||||||
self.delete_record_button.setEnabled(False)
|
self.delete_record_button.setEnabled(False)
|
||||||
self.memo_edit.setPlainText('')
|
self.memo_edit.setPlainText('')
|
||||||
@ -316,10 +320,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"출근 기록 삭제",
|
tr('cal.delete_selected_title'),
|
||||||
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
|
tr('cal.delete_selected_body', date=self.selected_date_str),
|
||||||
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
|
|
||||||
f"※ 이 작업은 되돌릴 수 없습니다.",
|
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
QMessageBox.No
|
QMessageBox.No
|
||||||
)
|
)
|
||||||
@ -329,8 +331,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"삭제 완료",
|
tr('cal.delete_done_title'),
|
||||||
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
|
tr('cal.delete_done_body', date=self.selected_date_str)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 캘린더 새로고침
|
# 캘린더 새로고침
|
||||||
@ -350,8 +352,8 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"메모 저장",
|
tr('cal.save_memo_title'),
|
||||||
f"{self.selected_date_str}의 메모가 저장되었습니다."
|
tr('cal.save_memo_body', date=self.selected_date_str)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 상세 정보 새로고침
|
# 상세 정보 새로고침
|
||||||
@ -397,7 +399,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
from PyQt5.QtWidgets import QTimeEdit
|
from PyQt5.QtWidgets import QTimeEdit
|
||||||
from PyQt5.QtCore import QTime
|
from PyQt5.QtCore import QTime
|
||||||
|
|
||||||
self.setWindowTitle("출퇴근 시간 수정")
|
self.setWindowTitle(tr('cal.edit_dialog_title'))
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumWidth(420)
|
self.setMinimumWidth(420)
|
||||||
|
|
||||||
@ -406,19 +408,19 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
layout.setContentsMargins(12, 10, 12, 10)
|
layout.setContentsMargins(12, 10, 12, 10)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str))
|
||||||
title.setObjectName("dialog_subtitle")
|
title.setObjectName("dialog_subtitle")
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
# 출근 시간
|
# 출근 시간
|
||||||
clock_in_layout = QHBoxLayout()
|
clock_in_layout = QHBoxLayout()
|
||||||
clock_in_layout.setSpacing(4)
|
clock_in_layout.setSpacing(4)
|
||||||
clock_in_label = QLabel("출근:")
|
clock_in_label = QLabel(tr('cal.label_clock_in'))
|
||||||
clock_in_label.setObjectName("field_label")
|
clock_in_label.setObjectName("field_label")
|
||||||
clock_in_label.setFixedWidth(40)
|
clock_in_label.setFixedWidth(40)
|
||||||
clock_in_layout.addWidget(clock_in_label)
|
clock_in_layout.addWidget(clock_in_label)
|
||||||
|
|
||||||
clock_in_minus_btn = QPushButton("-30분")
|
clock_in_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
||||||
clock_in_minus_btn.setFixedWidth(55)
|
clock_in_minus_btn.setFixedWidth(55)
|
||||||
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
||||||
clock_in_layout.addWidget(clock_in_minus_btn)
|
clock_in_layout.addWidget(clock_in_minus_btn)
|
||||||
@ -429,7 +431,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
self.clock_in_edit.setTime(clock_in_time)
|
self.clock_in_edit.setTime(clock_in_time)
|
||||||
clock_in_layout.addWidget(self.clock_in_edit)
|
clock_in_layout.addWidget(self.clock_in_edit)
|
||||||
|
|
||||||
clock_in_plus_btn = QPushButton("+30분")
|
clock_in_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||||
clock_in_plus_btn.setFixedWidth(55)
|
clock_in_plus_btn.setFixedWidth(55)
|
||||||
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
||||||
clock_in_layout.addWidget(clock_in_plus_btn)
|
clock_in_layout.addWidget(clock_in_plus_btn)
|
||||||
@ -438,12 +440,12 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
# 퇴근 시간
|
# 퇴근 시간
|
||||||
clock_out_layout = QHBoxLayout()
|
clock_out_layout = QHBoxLayout()
|
||||||
clock_out_layout.setSpacing(4)
|
clock_out_layout.setSpacing(4)
|
||||||
clock_out_label = QLabel("퇴근:")
|
clock_out_label = QLabel(tr('cal.label_clock_out'))
|
||||||
clock_out_label.setObjectName("field_label")
|
clock_out_label.setObjectName("field_label")
|
||||||
clock_out_label.setFixedWidth(40)
|
clock_out_label.setFixedWidth(40)
|
||||||
clock_out_layout.addWidget(clock_out_label)
|
clock_out_layout.addWidget(clock_out_label)
|
||||||
|
|
||||||
clock_out_minus_btn = QPushButton("-30분")
|
clock_out_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
||||||
clock_out_minus_btn.setFixedWidth(55)
|
clock_out_minus_btn.setFixedWidth(55)
|
||||||
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
||||||
clock_out_layout.addWidget(clock_out_minus_btn)
|
clock_out_layout.addWidget(clock_out_minus_btn)
|
||||||
@ -455,7 +457,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
self.clock_out_edit.setTime(clock_out_time)
|
self.clock_out_edit.setTime(clock_out_time)
|
||||||
clock_out_layout.addWidget(self.clock_out_edit)
|
clock_out_layout.addWidget(self.clock_out_edit)
|
||||||
|
|
||||||
clock_out_plus_btn = QPushButton("+30분")
|
clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||||
clock_out_plus_btn.setFixedWidth(55)
|
clock_out_plus_btn.setFixedWidth(55)
|
||||||
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
||||||
clock_out_layout.addWidget(clock_out_plus_btn)
|
clock_out_layout.addWidget(clock_out_plus_btn)
|
||||||
@ -464,27 +466,27 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
# 점심/저녁 체크박스 - 한 줄에
|
# 점심/저녁 체크박스 - 한 줄에
|
||||||
from PyQt5.QtWidgets import QCheckBox
|
from PyQt5.QtWidgets import QCheckBox
|
||||||
check_layout = QHBoxLayout()
|
check_layout = QHBoxLayout()
|
||||||
self.lunch_check = QCheckBox("점심 (1시간)")
|
self.lunch_check = QCheckBox(tr('cal.check_lunch_1h'))
|
||||||
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
||||||
check_layout.addWidget(self.lunch_check)
|
check_layout.addWidget(self.lunch_check)
|
||||||
|
|
||||||
self.dinner_check = QCheckBox("저녁 (1시간)")
|
self.dinner_check = QCheckBox(tr('cal.check_dinner_1h'))
|
||||||
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
||||||
check_layout.addWidget(self.dinner_check)
|
check_layout.addWidget(self.dinner_check)
|
||||||
layout.addLayout(check_layout)
|
layout.addLayout(check_layout)
|
||||||
|
|
||||||
# 안내 메시지
|
# 안내 메시지
|
||||||
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
|
note = QLabel(tr('cal.edit_note'))
|
||||||
note.setObjectName("note_text")
|
note.setObjectName("note_text")
|
||||||
layout.addWidget(note)
|
layout.addWidget(note)
|
||||||
|
|
||||||
# 버튼
|
# 버튼
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
save_button = QPushButton("저장")
|
save_button = QPushButton(tr('btn.save'))
|
||||||
save_button.setObjectName("btn_success")
|
save_button.setObjectName("btn_success")
|
||||||
save_button.clicked.connect(self.save_changes)
|
save_button.clicked.connect(self.save_changes)
|
||||||
|
|
||||||
cancel_button = QPushButton("취소")
|
cancel_button = QPushButton(tr('btn.cancel'))
|
||||||
cancel_button.clicked.connect(self.reject)
|
cancel_button.clicked.connect(self.reject)
|
||||||
|
|
||||||
button_layout.addWidget(save_button)
|
button_layout.addWidget(save_button)
|
||||||
@ -510,8 +512,8 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
if clock_out <= clock_in:
|
if clock_out <= clock_in:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"시간 오류",
|
tr('cal.time_error_title'),
|
||||||
"퇴근 시간은 출근 시간보다 늦어야 합니다."
|
tr('cal.time_error_body')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -594,15 +596,12 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"수정 완료",
|
tr('cal.edit_done_title'),
|
||||||
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
|
tr('cal.edit_done_body',
|
||||||
f"출근: {clock_in}\n"
|
date=self.date_str, clock_in=clock_in, clock_out=clock_out,
|
||||||
f"퇴근: {clock_out}\n"
|
lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
|
||||||
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
|
dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
|
||||||
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
|
break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
|
||||||
f"외출시간: {break_minutes}분\n"
|
|
||||||
f"총 근무시간: {total_hours:.1f}시간\n"
|
|
||||||
f"연장근무: {overtime_earned}분 적립"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.accept()
|
self.accept()
|
||||||
@ -610,8 +609,8 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
"오류",
|
tr('cal.edit_error_title'),
|
||||||
f"수정 중 오류가 발생했습니다:\n{str(e)}"
|
tr('cal.edit_error_body', error=str(e))
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
|
|||||||
@ -9,25 +9,52 @@ from typing import List, Tuple
|
|||||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from matplotlib.figure import Figure
|
|
||||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
||||||
import matplotlib
|
import matplotlib
|
||||||
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
from matplotlib.figure import Figure
|
||||||
|
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
|
||||||
|
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
|
||||||
|
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
|
||||||
|
try:
|
||||||
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||||
|
except Exception:
|
||||||
|
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||||
|
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||||
_MPL = True
|
_MPL = True
|
||||||
except ImportError:
|
except Exception as _mpl_err:
|
||||||
|
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
|
||||||
_MPL = False
|
_MPL = False
|
||||||
|
try:
|
||||||
|
from utils.debug_log import dlog
|
||||||
|
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# 다크 테마 색상 (dark_components 톤과 일치)
|
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
|
||||||
_CHART_BG = '#14141c'
|
# 막대/선은 데이터 구분용 고정 색.
|
||||||
_CHART_GRID = '#2a2a3a'
|
_CHART_BG = '#25262B'
|
||||||
_CHART_TEXT = '#c0c0d0'
|
_CHART_GRID = '#2C2E33'
|
||||||
_CHART_BAR_NORMAL = '#6b9eff' # blue
|
_CHART_TEXT = '#909296'
|
||||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink
|
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
|
||||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold
|
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
|
||||||
_CHART_AVG_LINE = '#4ade80' # green
|
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
|
||||||
|
_CHART_AVG_LINE = '#51CF66' # green
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_chart_colors() -> None:
|
||||||
|
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
|
||||||
|
global _CHART_BG, _CHART_GRID, _CHART_TEXT
|
||||||
|
try:
|
||||||
|
from ui.styles import ThemeColors
|
||||||
|
_CHART_BG = ThemeColors.get('bg_secondary')
|
||||||
|
_CHART_GRID = ThemeColors.get('border_subtle')
|
||||||
|
_CHART_TEXT = ThemeColors.get('text_secondary')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _apply_dark_axes(ax) -> None:
|
def _apply_dark_axes(ax) -> None:
|
||||||
@ -43,7 +70,8 @@ def _apply_dark_axes(ax) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _apply_dark_figure(fig) -> None:
|
def _apply_dark_figure(fig) -> None:
|
||||||
"""figure 배경을 다크 톤으로."""
|
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
|
||||||
|
_refresh_chart_colors()
|
||||||
fig.patch.set_facecolor(_CHART_BG)
|
fig.patch.set_facecolor(_CHART_BG)
|
||||||
|
|
||||||
|
|
||||||
@ -55,7 +83,7 @@ class _Fallback(QWidget):
|
|||||||
label = QLabel(message)
|
label = QLabel(message)
|
||||||
label.setAlignment(Qt.AlignCenter)
|
label.setAlignment(Qt.AlignCenter)
|
||||||
label.setWordWrap(True)
|
label.setWordWrap(True)
|
||||||
label.setStyleSheet("color: #888; padding: 20px;")
|
label.setStyleSheet("color: #909296; padding: 20px;")
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
@ -63,7 +91,8 @@ class _Fallback(QWidget):
|
|||||||
def make_chart_widget(parent=None) -> QWidget:
|
def make_chart_widget(parent=None) -> QWidget:
|
||||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||||
if not _MPL:
|
if not _MPL:
|
||||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
return _Fallback(tr('chart.need_matplotlib'))
|
||||||
|
_refresh_chart_colors()
|
||||||
widget = QWidget(parent)
|
widget = QWidget(parent)
|
||||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
@ -88,7 +117,7 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
if not records:
|
if not records:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
@ -100,10 +129,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
|
||||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'),
|
||||||
color=_CHART_BAR_OVERTIME)
|
color=_CHART_BAR_OVERTIME)
|
||||||
ax.set_ylabel('시간')
|
ax.set_ylabel(tr('chart.ylabel_hours'))
|
||||||
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||||
@ -130,9 +159,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
for i, bar in enumerate(bars):
|
for i, bar in enumerate(bars):
|
||||||
if bar.contains(event)[0]:
|
if bar.contains(event)[0]:
|
||||||
h = hours[i]; ot = overtimes[i]
|
h = hours[i]; ot = overtimes[i]
|
||||||
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
text = tr('chart.hover_text',
|
||||||
|
date=full_dates[i], hours=f"{h:.1f}")
|
||||||
if ot > 0:
|
if ot > 0:
|
||||||
text += f"\n연장 +{ot:.1f}h"
|
text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}")
|
||||||
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
||||||
annot.set_text(text)
|
annot.set_text(text)
|
||||||
annot.set_visible(True)
|
annot.set_visible(True)
|
||||||
@ -164,7 +194,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
if not records:
|
if not records:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
@ -184,7 +214,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
if not minutes_list:
|
if not minutes_list:
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
return
|
return
|
||||||
@ -198,12 +228,13 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
|||||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||||
edgecolor=_CHART_BG, linewidth=1)
|
edgecolor=_CHART_BG, linewidth=1)
|
||||||
avg = sum(minutes_list) / len(minutes_list)
|
avg = sum(minutes_list) / len(minutes_list)
|
||||||
|
avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}"
|
||||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||||
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
label=tr('chart.avg_line', time=avg_time))
|
||||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||||
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||||
rotation=45, fontsize=8)
|
rotation=45, fontsize=8)
|
||||||
ax.set_ylabel('일수')
|
ax.set_ylabel(tr('chart.ylabel_days'))
|
||||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
@ -230,11 +261,13 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
|||||||
weekday_counts[d.weekday()] += 1
|
weekday_counts[d.weekday()] += 1
|
||||||
|
|
||||||
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
||||||
labels = ['월', '화', '수', '목', '금', '토', '일']
|
labels = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
||||||
|
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'),
|
||||||
|
tr('label.weekday_sun')]
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||||
ax.bar(labels, avg, color=colors)
|
ax.bar(labels, avg, color=colors)
|
||||||
ax.set_ylabel('평균 시간')
|
ax.set_ylabel(tr('chart.ylabel_avg_hours'))
|
||||||
_apply_dark_axes(ax)
|
_apply_dark_axes(ax)
|
||||||
widget._canvas.draw()
|
widget._canvas.draw()
|
||||||
|
|||||||
@ -134,8 +134,8 @@ if __name__ == "__main__":
|
|||||||
dialog = ClockInDialog()
|
dialog = ClockInDialog()
|
||||||
if dialog.exec_() == QDialog.Accepted:
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
selected_time = dialog.get_time()
|
selected_time = dialog.get_time()
|
||||||
print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}")
|
print(tr('clock_in_dialog.selected', time=selected_time.strftime('%H:%M:%S')))
|
||||||
else:
|
else:
|
||||||
print("취소됨")
|
print(tr('clock_in_dialog.cancelled'))
|
||||||
|
|
||||||
sys.exit()
|
sys.exit()
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
class LockMonitor:
|
class LockMonitor:
|
||||||
@ -61,14 +62,23 @@ class LockMonitor:
|
|||||||
clock_in_str = when.strftime("%H:%M:%S")
|
clock_in_str = when.strftime("%H:%M:%S")
|
||||||
existing = self.db.get_today_record()
|
existing = self.db.get_today_record()
|
||||||
if existing:
|
if existing:
|
||||||
conn = self.db.get_connection()
|
with self.db._conn() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
||||||
(clock_in_str, today),
|
(clock_in_str, today),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
|
||||||
else:
|
else:
|
||||||
|
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,6 +10,7 @@ from datetime import datetime
|
|||||||
from core.settings_keys import (
|
from core.settings_keys import (
|
||||||
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||||
)
|
)
|
||||||
|
from core.i18n import tr
|
||||||
from utils.debug_log import dlog
|
from utils.debug_log import dlog
|
||||||
|
|
||||||
|
|
||||||
@ -54,12 +55,12 @@ class NotificationOrchestrator:
|
|||||||
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
||||||
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
||||||
|
|
||||||
title = "📊 지난주 요약"
|
title = tr('notif.weekly_report.title')
|
||||||
body = (f"기간: {last_mon} ~ {last_sun}\n"
|
body = tr('notif.weekly_report.body',
|
||||||
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
|
start=last_mon, end=last_sun,
|
||||||
f"일 평균: {avg_h:.1f}시간\n"
|
total_h=total_h, days=len(closed),
|
||||||
f"연장근무: {ot_h}시간 {ot_m}분\n"
|
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
||||||
f"가장 긴 날: {longest_str}")
|
longest=longest_str)
|
||||||
self.notifier.notification_signal.emit(title, body)
|
self.notifier.notification_signal.emit(title, body)
|
||||||
self.db.log_notification('system', 'weekly_report')
|
self.db.log_notification('system', 'weekly_report')
|
||||||
|
|
||||||
@ -70,13 +71,17 @@ class NotificationOrchestrator:
|
|||||||
try:
|
try:
|
||||||
from utils.discord_webhook import send, COLOR_BLUE
|
from utils.discord_webhook import send, COLOR_BLUE
|
||||||
fields = [
|
fields = [
|
||||||
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
|
{"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True},
|
||||||
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
|
{"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True},
|
||||||
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
|
{"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True},
|
||||||
{"name": "가장 긴 날", "value": longest_str, "inline": False},
|
{"name": tr('field.longest_day'), "value": longest_str, "inline": False},
|
||||||
]
|
]
|
||||||
ok = send(url, "📊 지난주 요약",
|
ok = send(url, tr('notif.weekly_report.title'),
|
||||||
f"기간: {last_mon} ~ {last_sun}",
|
tr('notif.weekly_report.body',
|
||||||
|
start=last_mon, end=last_sun,
|
||||||
|
total_h=total_h, days=len(closed),
|
||||||
|
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
||||||
|
longest=longest_str),
|
||||||
color=COLOR_BLUE, fields=fields)
|
color=COLOR_BLUE, fields=fields)
|
||||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -147,8 +152,8 @@ class NotificationOrchestrator:
|
|||||||
for a in unlocked:
|
for a in unlocked:
|
||||||
self.db.log_notification('system', f'achievement:{a.code}')
|
self.db.log_notification('system', f'achievement:{a.code}')
|
||||||
if notif_on:
|
if notif_on:
|
||||||
title = f"{a.badge_icon} 도전과제 달성!"
|
title = tr('notif.achievement.title', icon=a.badge_icon)
|
||||||
body = f"{a.name}\n{a.description}"
|
body = tr('notif.achievement.body', name=a.name, description=a.description)
|
||||||
self.notifier.notification_signal.emit(title, body)
|
self.notifier.notification_signal.emit(title, body)
|
||||||
# Discord 통합 push (여러 개면 묶어서)
|
# Discord 통합 push (여러 개면 묶어서)
|
||||||
self._discord_achievements(unlocked)
|
self._discord_achievements(unlocked)
|
||||||
@ -167,8 +172,8 @@ class NotificationOrchestrator:
|
|||||||
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||||
ok = discord_webhook.send(
|
ok = discord_webhook.send(
|
||||||
url,
|
url,
|
||||||
f"🏆 도전과제 {len(unlocked)}개 달성!",
|
tr('discord.achievement.title', count=len(unlocked)),
|
||||||
f"새로 잠금 해제된 도전과제 입니다.{extra}",
|
tr('discord.achievement.body', extra=extra),
|
||||||
color=discord_webhook.COLOR_YELLOW,
|
color=discord_webhook.COLOR_YELLOW,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt
|
|||||||
|
|
||||||
|
|
||||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||||
DARK_BG = '#0e0e14'
|
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
||||||
DARK_PANEL = '#14141c'
|
DARK_BG = '#1A1B1E'
|
||||||
DARK_PANEL_2 = '#1c1c28'
|
DARK_PANEL = '#25262B'
|
||||||
DARK_BORDER = '#2a2a3a'
|
DARK_PANEL_2 = '#2C2E33'
|
||||||
DARK_BORDER_STRONG = '#44446a'
|
DARK_BORDER = '#2C2E33'
|
||||||
DARK_TEXT = '#e8e8f4'
|
DARK_BORDER_STRONG = '#373A40'
|
||||||
DARK_TEXT_DIM = '#a0a0b8'
|
DARK_TEXT = '#E9ECEF'
|
||||||
DARK_TEXT_FAINT = '#666680'
|
DARK_TEXT_DIM = '#909296'
|
||||||
|
DARK_TEXT_FAINT = '#6C6E73'
|
||||||
|
|
||||||
|
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||||
ACCENT_GOLD = '#ffd24a'
|
ACCENT_GOLD = '#ffd24a'
|
||||||
ACCENT_BLUE = '#6b9eff'
|
ACCENT_BLUE = '#4DABF7'
|
||||||
ACCENT_CYAN = '#4adef0'
|
ACCENT_CYAN = '#4adef0'
|
||||||
ACCENT_PINK = '#ff90b8'
|
ACCENT_PINK = '#ff90b8'
|
||||||
ACCENT_GREEN = '#4ade80'
|
ACCENT_GREEN = '#51CF66'
|
||||||
ACCENT_ORANGE = '#fcd34d'
|
ACCENT_ORANGE = '#fcd34d'
|
||||||
ACCENT_RED = '#fb7185'
|
ACCENT_RED = '#FA5252'
|
||||||
|
|
||||||
# 카드 테마 (등급/상태별)
|
# 카드 테마 (등급/상태별)
|
||||||
CARD_THEMES = {
|
CARD_THEMES = {
|
||||||
@ -76,26 +78,59 @@ CARD_THEMES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 테마 연동 ──────────────────────────────────────────────────
|
||||||
|
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
||||||
|
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
|
||||||
|
|
||||||
|
def _pal() -> dict:
|
||||||
|
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
|
||||||
|
from ui.styles import ThemeColors
|
||||||
|
g = ThemeColors.get
|
||||||
|
return {
|
||||||
|
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
|
||||||
|
'border': g('border_subtle'), 'border_strong': g('border_default'),
|
||||||
|
'text': g('text_primary'), 'text_dim': g('text_secondary'),
|
||||||
|
'text_faint': g('text_tertiary'),
|
||||||
|
'blue': g('accent_primary'), 'green': g('accent_success'),
|
||||||
|
'red': g('accent_danger'),
|
||||||
|
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
|
||||||
|
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dark() -> bool:
|
||||||
|
from ui.styles import ThemeColors, DARK_COLORS
|
||||||
|
return ThemeColors.current is DARK_COLORS
|
||||||
|
|
||||||
|
|
||||||
|
def tc(role: str) -> str:
|
||||||
|
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
|
||||||
|
return _pal().get(role, '#FF00FF')
|
||||||
|
|
||||||
|
|
||||||
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def dialog_qss() -> str:
|
def dialog_qss() -> str:
|
||||||
"""다이얼로그 전체 배경."""
|
"""다이얼로그 전체 배경 (현재 테마)."""
|
||||||
return f"QDialog {{ background: {DARK_BG}; }}"
|
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
||||||
|
|
||||||
|
|
||||||
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
def tabs_qss(accent: str = None) -> str:
|
||||||
|
p = _pal()
|
||||||
|
if accent is None:
|
||||||
|
accent = p['blue']
|
||||||
return f"""
|
return f"""
|
||||||
QTabWidget::pane {{
|
QTabWidget::pane {{
|
||||||
background: {DARK_PANEL};
|
background: {p['panel']};
|
||||||
border: 1px solid {DARK_BORDER};
|
border: 1px solid {p['border']};
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}}
|
}}
|
||||||
QTabBar::tab {{
|
QTabBar::tab {{
|
||||||
background: {DARK_PANEL_2};
|
background: {p['panel2']};
|
||||||
color: {DARK_TEXT_DIM};
|
color: {p['text_dim']};
|
||||||
padding: 9px 18px;
|
padding: 9px 18px;
|
||||||
border: 1px solid {DARK_BORDER};
|
border: 1px solid {p['border']};
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-top-left-radius: 8px;
|
border-top-left-radius: 8px;
|
||||||
border-top-right-radius: 8px;
|
border-top-right-radius: 8px;
|
||||||
@ -103,88 +138,90 @@ def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
|||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QTabBar::tab:selected {{
|
QTabBar::tab:selected {{
|
||||||
background: {DARK_PANEL};
|
background: {p['panel']};
|
||||||
color: {accent};
|
color: {accent};
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border-bottom: 2px solid {accent};
|
border-bottom: 2px solid {accent};
|
||||||
}}
|
}}
|
||||||
QTabBar::tab:hover:!selected {{
|
QTabBar::tab:hover:!selected {{
|
||||||
background: #2a2a36;
|
background: {p['border_strong']};
|
||||||
color: {DARK_TEXT};
|
color: {p['text']};
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def scroll_qss() -> str:
|
def scroll_qss() -> str:
|
||||||
|
p = _pal()
|
||||||
return f"""
|
return f"""
|
||||||
QScrollArea {{ background: transparent; border: none; }}
|
QScrollArea {{ background: transparent; border: none; }}
|
||||||
QScrollBar:vertical {{
|
QScrollBar:vertical {{
|
||||||
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
|
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
||||||
}}
|
}}
|
||||||
QScrollBar::handle:vertical {{
|
QScrollBar::handle:vertical {{
|
||||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
|
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
||||||
}}
|
}}
|
||||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
|
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
||||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||||
QScrollBar:horizontal {{
|
QScrollBar:horizontal {{
|
||||||
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
|
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
||||||
}}
|
}}
|
||||||
QScrollBar::handle:horizontal {{
|
QScrollBar::handle:horizontal {{
|
||||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
|
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
||||||
}}
|
}}
|
||||||
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
|
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def button_qss(variant: str = 'default') -> str:
|
def button_qss(variant: str = 'default') -> str:
|
||||||
""" variant: default | primary | success | danger | ghost """
|
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
||||||
|
p = _pal()
|
||||||
if variant == 'primary':
|
if variant == 'primary':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {ACCENT_BLUE}; color: white;
|
background: {p['blue']}; color: white;
|
||||||
border: none; border-radius: 6px;
|
border: none; border-radius: 8px;
|
||||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #82b0ff; }}
|
QPushButton:hover {{ background: {p['blue_hover']}; }}
|
||||||
QPushButton:pressed {{ background: #5a8eee; }}
|
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
|
||||||
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
|
||||||
"""
|
"""
|
||||||
if variant == 'success':
|
if variant == 'success':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {ACCENT_GREEN}; color: #0e2a1a;
|
background: {p['green']}; color: white;
|
||||||
border: none; border-radius: 6px;
|
border: none; border-radius: 8px;
|
||||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #6ae899; }}
|
QPushButton:hover {{ background: {p['green_hover']}; }}
|
||||||
"""
|
"""
|
||||||
if variant == 'danger':
|
if variant == 'danger':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {ACCENT_RED}; color: white;
|
background: {p['red']}; color: white;
|
||||||
border: none; border-radius: 6px;
|
border: none; border-radius: 8px;
|
||||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #fc8896; }}
|
QPushButton:hover {{ background: {p['red_hover']}; }}
|
||||||
"""
|
"""
|
||||||
if variant == 'ghost':
|
if variant == 'ghost':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: transparent; color: {DARK_TEXT_DIM};
|
background: transparent; color: {p['text_dim']};
|
||||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||||
padding: 6px 14px; font-size: 9.5pt;
|
padding: 6px 14px; font-size: 9.5pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
||||||
border-color: {ACCENT_BLUE}; }}
|
border-color: {p['blue']}; }}
|
||||||
"""
|
"""
|
||||||
# default
|
# default
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
background: {p['panel2']}; color: {p['text']};
|
||||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||||
padding: 8px 18px; font-size: 10pt;
|
padding: 8px 18px; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@ -202,15 +239,15 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
|||||||
big_color: 큰 숫자 색
|
big_color: 큰 숫자 색
|
||||||
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||||
"""
|
"""
|
||||||
|
p = _pal()
|
||||||
container = QFrame()
|
container = QFrame()
|
||||||
container.setStyleSheet(f"""
|
container.setStyleSheet(f"""
|
||||||
QFrame {{
|
QFrame {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: {p['panel']};
|
||||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
border: 1px solid {p['border']};
|
||||||
border: 1px solid #3a3a5a;
|
border-radius: 8px;
|
||||||
border-radius: 12px;
|
|
||||||
}}
|
}}
|
||||||
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
|
||||||
""")
|
""")
|
||||||
layout = QHBoxLayout()
|
layout = QHBoxLayout()
|
||||||
layout.setContentsMargins(20, 14, 20, 14)
|
layout.setContentsMargins(20, 14, 20, 14)
|
||||||
@ -222,13 +259,13 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
|||||||
if title:
|
if title:
|
||||||
t = QLabel(title)
|
t = QLabel(title)
|
||||||
t.setStyleSheet(
|
t.setStyleSheet(
|
||||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
)
|
)
|
||||||
left.addWidget(t)
|
left.addWidget(t)
|
||||||
big = QLabel(
|
big = QLabel(
|
||||||
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
||||||
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
|
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
|
||||||
f" {subtitle}</span>" if subtitle else '')
|
f" {subtitle}</span>" if subtitle else '')
|
||||||
)
|
)
|
||||||
big.setTextFormat(Qt.RichText)
|
big.setTextFormat(Qt.RichText)
|
||||||
@ -252,29 +289,49 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
|||||||
theme: str = 'blue', icon: str = '') -> QFrame:
|
theme: str = 'blue', icon: str = '') -> QFrame:
|
||||||
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
||||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||||
|
p = _pal()
|
||||||
|
dark = _is_dark()
|
||||||
|
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
|
||||||
|
if dark:
|
||||||
|
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||||
|
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||||
|
card_border = t['border']
|
||||||
|
label_color = t['text']
|
||||||
|
value_color = t['border_strong']
|
||||||
|
else:
|
||||||
|
card_bg = p['panel']
|
||||||
|
card_border = p['border']
|
||||||
|
label_color = p['text']
|
||||||
|
value_color = p['text']
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||||
card.setStyleSheet(f"""
|
card.setStyleSheet(f"""
|
||||||
QFrame {{
|
QFrame {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
background: {card_bg};
|
||||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
border: 1px solid {card_border};
|
||||||
border: 1px solid {t['border']};
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}}
|
}}
|
||||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||||
""")
|
""")
|
||||||
outer = QHBoxLayout()
|
outer = QHBoxLayout()
|
||||||
outer.setContentsMargins(16, 12, 16, 12)
|
outer.setContentsMargins(16, 12, 16, 12)
|
||||||
outer.setSpacing(12)
|
outer.setSpacing(12)
|
||||||
|
|
||||||
if icon:
|
if icon:
|
||||||
icon_lbl = QLabel(icon)
|
icon_lbl = QLabel()
|
||||||
|
icon_lbl.setMinimumWidth(48)
|
||||||
|
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||||
|
from ui.icons import get_icon, _PATHS
|
||||||
|
if icon in _PATHS:
|
||||||
|
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
|
||||||
|
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
|
||||||
|
else:
|
||||||
|
# 이모지/텍스트 폴백 (구버전 호환)
|
||||||
|
icon_lbl.setText(icon)
|
||||||
icon_lbl.setStyleSheet(
|
icon_lbl.setStyleSheet(
|
||||||
f"font-size: 28pt; background: transparent; border: none; "
|
f"font-size: 28pt; background: transparent; border: none; "
|
||||||
f"color: {t['border_strong']};"
|
f"color: {t['border_strong']};"
|
||||||
)
|
)
|
||||||
icon_lbl.setMinimumWidth(48)
|
|
||||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
|
||||||
outer.addWidget(icon_lbl)
|
outer.addWidget(icon_lbl)
|
||||||
|
|
||||||
text_box = QVBoxLayout()
|
text_box = QVBoxLayout()
|
||||||
@ -282,13 +339,13 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
|||||||
|
|
||||||
title_lbl = QLabel(title)
|
title_lbl = QLabel(title)
|
||||||
title_lbl.setStyleSheet(
|
title_lbl.setStyleSheet(
|
||||||
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; "
|
f"font-size: 9.5pt; color: {p['text_dim']}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
)
|
)
|
||||||
text_box.addWidget(title_lbl)
|
text_box.addWidget(title_lbl)
|
||||||
|
|
||||||
val_lbl = QLabel(
|
val_lbl = QLabel(
|
||||||
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
|
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
|
||||||
f"{value}</span>"
|
f"{value}</span>"
|
||||||
)
|
)
|
||||||
val_lbl.setTextFormat(Qt.RichText)
|
val_lbl.setTextFormat(Qt.RichText)
|
||||||
@ -298,7 +355,7 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
|||||||
if subtitle:
|
if subtitle:
|
||||||
sub_lbl = QLabel(subtitle)
|
sub_lbl = QLabel(subtitle)
|
||||||
sub_lbl.setStyleSheet(
|
sub_lbl.setStyleSheet(
|
||||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
)
|
)
|
||||||
sub_lbl.setWordWrap(True)
|
sub_lbl.setWordWrap(True)
|
||||||
@ -313,16 +370,25 @@ def build_section_card(title: str, content: QWidget,
|
|||||||
theme: str = 'gray', icon: str = '') -> QFrame:
|
theme: str = 'gray', icon: str = '') -> QFrame:
|
||||||
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
||||||
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
||||||
|
p = _pal()
|
||||||
|
if _is_dark():
|
||||||
|
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||||
|
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||||
|
card_border = t['border']
|
||||||
|
label_color = t['text']
|
||||||
|
else:
|
||||||
|
card_bg = p['panel']
|
||||||
|
card_border = p['border']
|
||||||
|
label_color = p['text']
|
||||||
card = QFrame()
|
card = QFrame()
|
||||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
card.setStyleSheet(f"""
|
card.setStyleSheet(f"""
|
||||||
QFrame {{
|
QFrame {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
background: {card_bg};
|
||||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
border: 1px solid {card_border};
|
||||||
border: 1px solid {t['border']};
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}}
|
}}
|
||||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||||
""")
|
""")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setContentsMargins(16, 12, 16, 14)
|
layout.setContentsMargins(16, 12, 16, 14)
|
||||||
@ -330,7 +396,12 @@ def build_section_card(title: str, content: QWidget,
|
|||||||
|
|
||||||
head = QHBoxLayout()
|
head = QHBoxLayout()
|
||||||
if icon:
|
if icon:
|
||||||
i = QLabel(icon)
|
i = QLabel()
|
||||||
|
from ui.icons import get_icon, _PATHS
|
||||||
|
if icon in _PATHS:
|
||||||
|
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
|
||||||
|
else:
|
||||||
|
i.setText(icon)
|
||||||
i.setStyleSheet(
|
i.setStyleSheet(
|
||||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
@ -338,7 +409,7 @@ def build_section_card(title: str, content: QWidget,
|
|||||||
head.addWidget(i)
|
head.addWidget(i)
|
||||||
title_lbl = QLabel(title)
|
title_lbl = QLabel(title)
|
||||||
title_lbl.setStyleSheet(
|
title_lbl.setStyleSheet(
|
||||||
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
|
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
)
|
)
|
||||||
head.addWidget(title_lbl)
|
head.addWidget(title_lbl)
|
||||||
@ -372,9 +443,11 @@ def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
|||||||
|
|
||||||
|
|
||||||
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||||
color: str = DARK_TEXT) -> QLabel:
|
color: str = None) -> QLabel:
|
||||||
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
|
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
||||||
lbl = QLabel(text)
|
lbl = QLabel(text)
|
||||||
|
if color is None:
|
||||||
|
color = _pal()['text']
|
||||||
weight_str = 'bold' if weight == 'bold' else 'normal'
|
weight_str = 'bold' if weight == 'bold' else 'normal'
|
||||||
lbl.setStyleSheet(
|
lbl.setStyleSheet(
|
||||||
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
||||||
|
|||||||
@ -9,6 +9,8 @@ from datetime import datetime, date
|
|||||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
|
|
||||||
|
|
||||||
class GoalWidget(QWidget):
|
class GoalWidget(QWidget):
|
||||||
"""월간 목표 진행률 표시."""
|
"""월간 목표 진행률 표시."""
|
||||||
@ -20,13 +22,13 @@ class GoalWidget(QWidget):
|
|||||||
layout.setContentsMargins(8, 6, 8, 6)
|
layout.setContentsMargins(8, 6, 8, 6)
|
||||||
layout.setSpacing(4)
|
layout.setSpacing(4)
|
||||||
|
|
||||||
title = QLabel("🎯 이번 달 목표")
|
title = QLabel(tr('goal.title'))
|
||||||
title.setStyleSheet("font-weight: bold;")
|
title.setStyleSheet("font-weight: bold;")
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
# 연장근무 상한
|
# 연장근무 상한
|
||||||
ot_row = QHBoxLayout()
|
ot_row = QHBoxLayout()
|
||||||
self.ot_label = QLabel("연장근무:")
|
self.ot_label = QLabel(tr('goal.overtime'))
|
||||||
self.ot_label.setFixedWidth(100)
|
self.ot_label.setFixedWidth(100)
|
||||||
self.ot_bar = QProgressBar()
|
self.ot_bar = QProgressBar()
|
||||||
self.ot_bar.setTextVisible(True)
|
self.ot_bar.setTextVisible(True)
|
||||||
@ -37,7 +39,7 @@ class GoalWidget(QWidget):
|
|||||||
|
|
||||||
# 일평균
|
# 일평균
|
||||||
avg_row = QHBoxLayout()
|
avg_row = QHBoxLayout()
|
||||||
self.avg_label = QLabel("일평균:")
|
self.avg_label = QLabel(tr('goal.avg_daily'))
|
||||||
self.avg_label.setFixedWidth(100)
|
self.avg_label.setFixedWidth(100)
|
||||||
self.avg_bar = QProgressBar()
|
self.avg_bar = QProgressBar()
|
||||||
self.avg_bar.setTextVisible(True)
|
self.avg_bar.setTextVisible(True)
|
||||||
@ -78,7 +80,7 @@ class GoalWidget(QWidget):
|
|||||||
ot_h, ot_m = ot_total // 60, ot_total % 60
|
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||||
tg_h, tg_m = ot_target // 60, ot_target % 60
|
tg_h, tg_m = ot_target // 60, ot_target % 60
|
||||||
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
|
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
|
||||||
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
|
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
|
||||||
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||||
else:
|
else:
|
||||||
self.ot_label.setVisible(False)
|
self.ot_label.setVisible(False)
|
||||||
@ -93,7 +95,7 @@ class GoalWidget(QWidget):
|
|||||||
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
|
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
|
||||||
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
||||||
ratio = avg / avg_target if avg_target else 0
|
ratio = avg / avg_target if avg_target else 0
|
||||||
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
|
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
|
||||||
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||||
else:
|
else:
|
||||||
self.avg_label.setVisible(False)
|
self.avg_label.setVisible(False)
|
||||||
|
|||||||
@ -10,10 +10,7 @@ from PyQt5.QtCore import Qt
|
|||||||
|
|
||||||
from core.i18n import tr, tr_html
|
from core.i18n import tr, tr_html
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
from ui.dark_components import (
|
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
|
||||||
dialog_qss, tabs_qss, button_qss,
|
|
||||||
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HelpView(QDialog):
|
class HelpView(QDialog):
|
||||||
@ -37,7 +34,7 @@ class HelpView(QDialog):
|
|||||||
self.resize(820, 760)
|
self.resize(820, 760)
|
||||||
self.setStyleSheet(dialog_qss())
|
self.setStyleSheet(dialog_qss())
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
apply_dark_titlebar(self, dark=True)
|
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
main_layout = QVBoxLayout()
|
main_layout = QVBoxLayout()
|
||||||
@ -45,15 +42,14 @@ class HelpView(QDialog):
|
|||||||
main_layout.setSpacing(10)
|
main_layout.setSpacing(10)
|
||||||
|
|
||||||
# 다크 타이틀
|
# 다크 타이틀
|
||||||
title = QLabel(f"📖 {tr('window.help')}")
|
title = QLabel(tr('window.help'))
|
||||||
title.setStyleSheet(
|
title.setStyleSheet(
|
||||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
)
|
)
|
||||||
main_layout.addWidget(title)
|
main_layout.addWidget(title)
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
tabs.setDocumentMode(True)
|
|
||||||
tabs.setStyleSheet(tabs_qss())
|
tabs.setStyleSheet(tabs_qss())
|
||||||
for html_key, tab_label_key in self._TABS:
|
for html_key, tab_label_key in self._TABS:
|
||||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
||||||
@ -63,7 +59,7 @@ class HelpView(QDialog):
|
|||||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||||
|
|
||||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
onboarding_button = QPushButton(tr('help.onboarding_button'))
|
||||||
onboarding_button.setMinimumHeight(36)
|
onboarding_button.setMinimumHeight(36)
|
||||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||||
@ -89,7 +85,7 @@ class HelpView(QDialog):
|
|||||||
|
|
||||||
def _make_tab(self, html: str) -> QWidget:
|
def _make_tab(self, html: str) -> QWidget:
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
container.setStyleSheet(f"background: {DARK_PANEL};")
|
container.setStyleSheet(f"background: {tc('panel')};")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
@ -100,21 +96,21 @@ class HelpView(QDialog):
|
|||||||
browser.setHtml(styled_html)
|
browser.setHtml(styled_html)
|
||||||
browser.setStyleSheet(f"""
|
browser.setStyleSheet(f"""
|
||||||
QTextBrowser {{
|
QTextBrowser {{
|
||||||
background: {DARK_PANEL};
|
background: {tc('panel')};
|
||||||
color: {DARK_TEXT};
|
color: {tc('text')};
|
||||||
border: none;
|
border: none;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
font-size: 10.5pt;
|
font-size: 10.5pt;
|
||||||
selection-background-color: {ACCENT_GOLD};
|
selection-background-color: {tc('blue')};
|
||||||
selection-color: #1a1a26;
|
selection-color: #ffffff;
|
||||||
}}
|
}}
|
||||||
QScrollBar:vertical {{
|
QScrollBar:vertical {{
|
||||||
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
|
background: {tc('panel')}; width: 10px; border-radius: 5px;
|
||||||
}}
|
}}
|
||||||
QScrollBar::handle:vertical {{
|
QScrollBar::handle:vertical {{
|
||||||
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
|
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
|
||||||
}}
|
}}
|
||||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
|
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
|
||||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||||
""")
|
""")
|
||||||
layout.addWidget(browser)
|
layout.addWidget(browser)
|
||||||
@ -123,61 +119,67 @@ class HelpView(QDialog):
|
|||||||
|
|
||||||
def _inject_dark_styles(self, html: str) -> str:
|
def _inject_dark_styles(self, html: str) -> str:
|
||||||
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||||
|
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
|
||||||
|
text = tc('text')
|
||||||
|
dim = tc('text_dim')
|
||||||
|
blue = tc('blue')
|
||||||
|
green = tc('green')
|
||||||
|
panel2 = tc('panel2')
|
||||||
|
border = tc('border')
|
||||||
css = f"""
|
css = f"""
|
||||||
<style>
|
<style>
|
||||||
body, p, li {{
|
body, p, li {{
|
||||||
color: #e8e8f4;
|
color: {text};
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}}
|
}}
|
||||||
h1, h2, h3, h4 {{
|
h1, h2, h3, h4 {{
|
||||||
color: #ffd24a;
|
color: {blue};
|
||||||
margin-top: 1.2em;
|
margin-top: 1.2em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}}
|
}}
|
||||||
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }}
|
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
|
||||||
h3 {{ font-size: 13pt; color: #6b9eff; }}
|
h3 {{ font-size: 13pt; color: {blue}; }}
|
||||||
h4 {{ font-size: 11pt; color: #4ade80; }}
|
h4 {{ font-size: 11pt; color: {green}; }}
|
||||||
b, strong {{ color: #ff90b8; }}
|
b, strong {{ color: {text}; }}
|
||||||
code {{
|
code {{
|
||||||
background: #1c1c28;
|
background: {panel2};
|
||||||
color: #ffd24a;
|
color: {blue};
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: Consolas, monospace;
|
font-family: Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}}
|
}}
|
||||||
pre {{
|
pre {{
|
||||||
background: #1c1c28;
|
background: {panel2};
|
||||||
border: 1px solid #2a2a3a;
|
border: 1px solid {border};
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
color: #e8e8f4;
|
color: {text};
|
||||||
}}
|
}}
|
||||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||||
li {{ margin-bottom: 4px; }}
|
li {{ margin-bottom: 4px; }}
|
||||||
a {{ color: #4adef0; text-decoration: none; }}
|
a {{ color: {blue}; text-decoration: none; }}
|
||||||
a:hover {{ text-decoration: underline; }}
|
a:hover {{ text-decoration: underline; }}
|
||||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||||
th {{
|
th {{
|
||||||
background: #2a2a3a;
|
background: {panel2};
|
||||||
color: #ffd24a;
|
color: {text};
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border: 1px solid #44446a;
|
border: 1px solid {border};
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}}
|
}}
|
||||||
td {{
|
td {{
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border: 1px solid #2a2a3a;
|
border: 1px solid {border};
|
||||||
color: #e8e8f4;
|
color: {text};
|
||||||
}}
|
}}
|
||||||
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
|
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
|
||||||
blockquote {{
|
blockquote {{
|
||||||
border-left: 3px solid #6b9eff;
|
border-left: 3px solid {blue};
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
color: #a0a0b8;
|
color: {dim};
|
||||||
background: rgba(107, 158, 255, 0.05);
|
|
||||||
}}
|
}}
|
||||||
</style>
|
</style>
|
||||||
"""
|
"""
|
||||||
|
|||||||
82
ui/icons.py
Normal file
82
ui/icons.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
|
||||||
|
|
||||||
|
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
|
||||||
|
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환 시
|
||||||
|
재호출하면 자동으로 재틴팅된다.
|
||||||
|
|
||||||
|
사용:
|
||||||
|
from ui.icons import get_icon
|
||||||
|
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
|
||||||
|
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import QByteArray, QRectF, Qt
|
||||||
|
from PyQt5.QtGui import QIcon, QPixmap, QPainter
|
||||||
|
from PyQt5.QtSvg import QSvgRenderer
|
||||||
|
|
||||||
|
from ui.styles import ThemeColors
|
||||||
|
|
||||||
|
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
|
||||||
|
_PATHS = {
|
||||||
|
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||||
|
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||||
|
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||||
|
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
|
||||||
|
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||||
|
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||||
|
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
|
||||||
|
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||||
|
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||||
|
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||||
|
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
|
||||||
|
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||||
|
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||||
|
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
|
||||||
|
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
||||||
|
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||||
|
}
|
||||||
|
|
||||||
|
_SVG_TMPL = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||||
|
'fill="none" stroke="{color}" stroke-width="2" '
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
|
||||||
|
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
|
||||||
|
if color is None:
|
||||||
|
color = ThemeColors.get('text_secondary')
|
||||||
|
key = (name, color, size)
|
||||||
|
cached = _cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
paths = _PATHS.get(name)
|
||||||
|
if paths is None:
|
||||||
|
return QIcon()
|
||||||
|
|
||||||
|
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
|
||||||
|
renderer = QSvgRenderer(QByteArray(svg))
|
||||||
|
|
||||||
|
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
|
||||||
|
pm = QPixmap(size * dpr, size * dpr)
|
||||||
|
pm.fill(Qt.transparent)
|
||||||
|
painter = QPainter(pm)
|
||||||
|
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||||
|
painter.end()
|
||||||
|
pm.setDevicePixelRatio(dpr)
|
||||||
|
|
||||||
|
icon = QIcon(pm)
|
||||||
|
_cache[key] = icon
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache() -> None:
|
||||||
|
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
|
||||||
|
_cache.clear()
|
||||||
@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
from PyQt5.QtCore import Qt, QDate
|
from PyQt5.QtCore import Qt, QDate
|
||||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -22,7 +23,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("📅 연차 캘린더")
|
self.setWindowTitle(tr('leave_cal.title'))
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumSize(540, 480)
|
self.setMinimumSize(540, 480)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@ -37,7 +38,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||||
used = total - balance
|
used = total - balance
|
||||||
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch()
|
header.addStretch()
|
||||||
@ -45,10 +46,11 @@ class LeaveCalendarView(QDialog):
|
|||||||
|
|
||||||
# 범례 (사용 완료 + 예정 분리)
|
# 범례 (사용 완료 + 예정 분리)
|
||||||
legend = QHBoxLayout()
|
legend = QHBoxLayout()
|
||||||
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
|
for _color, _txt in [('#51CF66', tr('leave_cal.legend_full')), ('#FAB005', tr('leave_cal.legend_half')),
|
||||||
"🔵 예정", "🔘 종일+예정"]:
|
('#B197FC', tr('leave_cal.legend_quarter')), ('#4DABF7', tr('leave_cal.legend_planned')),
|
||||||
l = QLabel(label)
|
('#748FFC', tr('leave_cal.legend_full_planned'))]:
|
||||||
l.setStyleSheet(f"padding: 2px 6px;")
|
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||||
|
l.setStyleSheet("padding: 2px 6px;")
|
||||||
legend.addWidget(l)
|
legend.addWidget(l)
|
||||||
legend.addStretch()
|
legend.addStretch()
|
||||||
layout.addLayout(legend)
|
layout.addLayout(legend)
|
||||||
@ -61,13 +63,13 @@ class LeaveCalendarView(QDialog):
|
|||||||
|
|
||||||
# 선택 일자 정보
|
# 선택 일자 정보
|
||||||
self.detail_label = QLabel("")
|
self.detail_label = QLabel("")
|
||||||
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
|
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
|
||||||
layout.addWidget(self.detail_label)
|
layout.addWidget(self.detail_label)
|
||||||
|
|
||||||
# 닫기 버튼
|
# 닫기 버튼
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
close_btn = QPushButton("닫기")
|
close_btn = QPushButton(tr('btn.close'))
|
||||||
close_btn.clicked.connect(self.close)
|
close_btn.clicked.connect(self.close)
|
||||||
btn_row.addWidget(close_btn)
|
btn_row.addWidget(close_btn)
|
||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
@ -108,12 +110,12 @@ class LeaveCalendarView(QDialog):
|
|||||||
records = self.db.get_all_leave_records(limit=365)
|
records = self.db.get_all_leave_records(limit=365)
|
||||||
match = [r for r in records if r['date'] == date_str]
|
match = [r for r in records if r['date'] == date_str]
|
||||||
if not match:
|
if not match:
|
||||||
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
|
self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
|
||||||
return
|
return
|
||||||
parts = []
|
parts = []
|
||||||
for r in match:
|
for r in match:
|
||||||
t = r.get('leave_type', 'annual')
|
t = r.get('leave_type', 'annual')
|
||||||
d = float(r.get('days') or 0)
|
d = float(r.get('days') or 0)
|
||||||
memo = r.get('memo') or ''
|
memo = r.get('memo') or ''
|
||||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
parts.append(tr('leave_cal.detail_memo', type=t, days=d, memo=memo) if memo else tr('leave_cal.detail_label', type=t, days=d))
|
||||||
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
|
self.detail_label.setText(f"{date_str}: " + ", ".join(parts))
|
||||||
|
|||||||
@ -90,8 +90,8 @@ class LeaveView(QDialog):
|
|||||||
cal_button.clicked.connect(self._show_calendar)
|
cal_button.clicked.connect(self._show_calendar)
|
||||||
button_layout.addWidget(cal_button)
|
button_layout.addWidget(cal_button)
|
||||||
|
|
||||||
schedule_button = QPushButton("🗓️ 스케줄")
|
schedule_button = QPushButton(tr('view.leave.btn_schedule'))
|
||||||
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
schedule_button.setToolTip(tr('view.leave.schedule_tooltip'))
|
||||||
schedule_button.clicked.connect(self._show_schedule)
|
schedule_button.clicked.connect(self._show_schedule)
|
||||||
button_layout.addWidget(schedule_button)
|
button_layout.addWidget(schedule_button)
|
||||||
|
|
||||||
@ -137,16 +137,16 @@ class LeaveView(QDialog):
|
|||||||
days = record['days']
|
days = record['days']
|
||||||
hours = days * 8
|
hours = days * 8
|
||||||
if days == 1.0:
|
if days == 1.0:
|
||||||
days_str = "1일"
|
days_str = tr('view.leave.used_1day')
|
||||||
elif days == 0.5:
|
elif days == 0.5:
|
||||||
days_str = "0.5일 (4시간)"
|
days_str = tr('view.leave.used_half_day')
|
||||||
elif hours < 8:
|
elif hours < 8:
|
||||||
days_str = f"{days}일 ({hours}시간)"
|
days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours)
|
||||||
else:
|
else:
|
||||||
days_str = f"{days}일"
|
days_str = tr('view.leave.used_days_fmt', days=days)
|
||||||
days_item = QTableWidgetItem(days_str)
|
days_item = QTableWidgetItem(days_str)
|
||||||
days_item.setTextAlignment(Qt.AlignCenter)
|
days_item.setTextAlignment(Qt.AlignCenter)
|
||||||
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||||
|
|
||||||
memo_item = QTableWidgetItem(record['memo'] or "")
|
memo_item = QTableWidgetItem(record['memo'] or "")
|
||||||
|
|
||||||
@ -365,17 +365,17 @@ class AddLeaveDialog(QDialog):
|
|||||||
if date_dt.weekday() in (5, 6): # 토/일
|
if date_dt.weekday() in (5, 6): # 토/일
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"주말 등록 불가",
|
tr('view.leave.weekend_register_forbidden_title'),
|
||||||
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
|
tr('view.leave.weekend_register_forbidden_body')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if self.db.is_holiday(date):
|
if self.db.is_holiday(date):
|
||||||
holiday = self.db.get_holiday(date)
|
holiday = self.db.get_holiday(date)
|
||||||
name = (holiday or {}).get('name', '공휴일')
|
name = (holiday or {}).get('name', tr('label.holiday_default'))
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"공휴일 등록 불가",
|
tr('view.leave.holiday_register_forbidden_title'),
|
||||||
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
|
tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -385,9 +385,8 @@ class AddLeaveDialog(QDialog):
|
|||||||
if existing_days + days > 1.0001: # 부동소수점 여유
|
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"중복 등록 초과",
|
tr('view.leave.duplicate_register_title'),
|
||||||
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
|
tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days)
|
||||||
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
QPushButton, QTimeEdit, QMessageBox)
|
QPushButton, QTimeEdit, QMessageBox)
|
||||||
from PyQt5.QtCore import QTime
|
from PyQt5.QtCore import QTime
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -35,8 +36,8 @@ class MealTimeDialog(QDialog):
|
|||||||
self.meal_type = meal_type
|
self.meal_type = meal_type
|
||||||
self._clock_in = clock_in_time
|
self._clock_in = clock_in_time
|
||||||
self._clock_out = clock_out_time
|
self._clock_out = clock_out_time
|
||||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short')
|
||||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
self.setWindowTitle(tr('meal.dialog_title', meal=meal_label))
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setFixedSize(380, 260)
|
self.setFixedSize(380, 260)
|
||||||
|
|
||||||
@ -44,13 +45,12 @@ class MealTimeDialog(QDialog):
|
|||||||
layout.setSpacing(10)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
|
info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
|
||||||
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
|
||||||
if clock_in_time is not None:
|
if clock_in_time is not None:
|
||||||
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M'))
|
||||||
info = QLabel(info_text)
|
info = QLabel(info_text)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
# 합리적 기본값: 출근 이후로 보정
|
# 합리적 기본값: 출근 이후로 보정
|
||||||
@ -63,7 +63,7 @@ class MealTimeDialog(QDialog):
|
|||||||
|
|
||||||
# 시작
|
# 시작
|
||||||
start_row = QHBoxLayout()
|
start_row = QHBoxLayout()
|
||||||
start_row.addWidget(QLabel("시작:"))
|
start_row.addWidget(QLabel(tr('meal.label_start')))
|
||||||
self.start_edit = QTimeEdit()
|
self.start_edit = QTimeEdit()
|
||||||
self.start_edit.setDisplayFormat("HH:mm")
|
self.start_edit.setDisplayFormat("HH:mm")
|
||||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||||
@ -73,7 +73,7 @@ class MealTimeDialog(QDialog):
|
|||||||
|
|
||||||
# 종료
|
# 종료
|
||||||
end_row = QHBoxLayout()
|
end_row = QHBoxLayout()
|
||||||
end_row.addWidget(QLabel("종료:"))
|
end_row.addWidget(QLabel(tr('meal.label_end')))
|
||||||
self.end_edit = QTimeEdit()
|
self.end_edit = QTimeEdit()
|
||||||
self.end_edit.setDisplayFormat("HH:mm")
|
self.end_edit.setDisplayFormat("HH:mm")
|
||||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||||
@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
|
|||||||
|
|
||||||
# 미리보기 라벨
|
# 미리보기 라벨
|
||||||
self.preview = QLabel("")
|
self.preview = QLabel("")
|
||||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
|
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
|
||||||
layout.addWidget(self.preview)
|
layout.addWidget(self.preview)
|
||||||
self._update_preview()
|
self._update_preview()
|
||||||
self.start_edit.timeChanged.connect(self._update_preview)
|
self.start_edit.timeChanged.connect(self._update_preview)
|
||||||
@ -92,10 +92,10 @@ class MealTimeDialog(QDialog):
|
|||||||
# 버튼
|
# 버튼
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
ok_btn = QPushButton("저장")
|
ok_btn = QPushButton(tr('btn.save'))
|
||||||
ok_btn.setObjectName("btn_primary")
|
ok_btn.setObjectName("btn_primary")
|
||||||
ok_btn.clicked.connect(self.accept)
|
ok_btn.clicked.connect(self.accept)
|
||||||
cancel_btn = QPushButton("취소")
|
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||||
cancel_btn.clicked.connect(self.reject)
|
cancel_btn.clicked.connect(self.reject)
|
||||||
btn_row.addWidget(ok_btn)
|
btn_row.addWidget(ok_btn)
|
||||||
btn_row.addWidget(cancel_btn)
|
btn_row.addWidget(cancel_btn)
|
||||||
@ -137,24 +137,24 @@ class MealTimeDialog(QDialog):
|
|||||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
if not ok:
|
if not ok:
|
||||||
self.preview.setText(f"⚠️ {reason}")
|
self.preview.setText(reason)
|
||||||
self.preview.setStyleSheet("color: #f44336;")
|
self.preview.setStyleSheet("color: #FA5252;")
|
||||||
else:
|
else:
|
||||||
self.preview.setText(f"총 {minutes}분")
|
self.preview.setText(tr('meal.preview_total', minutes=minutes))
|
||||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||||
|
|
||||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||||
minutes: int) -> tuple[bool, str]:
|
minutes: int) -> tuple[bool, str]:
|
||||||
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||||
if minutes <= 0:
|
if minutes <= 0:
|
||||||
return False, "시작이 종료보다 늦습니다"
|
return False, tr('meal.error_start_after_end')
|
||||||
if minutes > 8 * 60:
|
if minutes > 8 * 60:
|
||||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||||
return False, "식사 시간이 8시간을 초과합니다"
|
return False, tr('meal.error_too_long')
|
||||||
if self._clock_in is not None and start_dt < self._clock_in:
|
if self._clock_in is not None and start_dt < self._clock_in:
|
||||||
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다"
|
return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M'))
|
||||||
if self._clock_out is not None and end_dt > self._clock_out:
|
if self._clock_out is not None and end_dt > self._clock_out:
|
||||||
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다"
|
return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M'))
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
@ -162,7 +162,7 @@ class MealTimeDialog(QDialog):
|
|||||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
if not ok:
|
if not ok:
|
||||||
QMessageBox.warning(self, "입력 오류", reason)
|
QMessageBox.warning(self, tr('meal.input_error_title'), reason)
|
||||||
return
|
return
|
||||||
super().accept()
|
super().accept()
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class MiniWidget(QWidget):
|
|||||||
|
|
||||||
self.title_label = QLabel(tr('label.remaining'))
|
self.title_label = QLabel(tr('label.remaining'))
|
||||||
self.title_label.setAlignment(Qt.AlignCenter)
|
self.title_label.setAlignment(Qt.AlignCenter)
|
||||||
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
|
self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||||
|
|
||||||
self.time_label = QLabel("--:--:--")
|
self.time_label = QLabel("--:--:--")
|
||||||
self.time_label.setAlignment(Qt.AlignCenter)
|
self.time_label.setAlignment(Qt.AlignCenter)
|
||||||
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
|
|||||||
layout.addWidget(self.time_label)
|
layout.addWidget(self.time_label)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
# 기본 스타일 (테마 무관 가독성 유지)
|
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
|
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
|
||||||
QLabel { color: #fff; }
|
QLabel { color: #E9ECEF; background: transparent; }
|
||||||
""")
|
""")
|
||||||
|
|
||||||
apply_dark_titlebar(self)
|
apply_dark_titlebar(self)
|
||||||
@ -63,11 +63,12 @@ class MiniWidget(QWidget):
|
|||||||
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
||||||
self.time_label.setText(remaining_str)
|
self.time_label.setText(remaining_str)
|
||||||
if remaining_str.startswith('+'):
|
if remaining_str.startswith('+'):
|
||||||
|
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
|
||||||
self.title_label.setText(tr('label.overtime_progress'))
|
self.title_label.setText(tr('label.overtime_progress'))
|
||||||
self.time_label.setStyleSheet("color: #ff6b6b;")
|
self.time_label.setStyleSheet("color: #51CF66;")
|
||||||
else:
|
else:
|
||||||
self.title_label.setText(tr('label.remaining'))
|
self.title_label.setText(tr('label.remaining'))
|
||||||
self.time_label.setStyleSheet("color: #fff;")
|
self.time_label.setStyleSheet("color: #E9ECEF;")
|
||||||
|
|
||||||
# 드래그 이동
|
# 드래그 이동
|
||||||
def mousePressEvent(self, event: QMouseEvent):
|
def mousePressEvent(self, event: QMouseEvent):
|
||||||
@ -90,8 +91,19 @@ class MiniWidget(QWidget):
|
|||||||
def contextMenuEvent(self, event):
|
def contextMenuEvent(self, event):
|
||||||
from PyQt5.QtWidgets import QMenu
|
from PyQt5.QtWidgets import QMenu
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
open_main = menu.addAction("메인 창 열기")
|
# 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
|
||||||
close_mini = menu.addAction("미니 위젯 닫기")
|
# 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
|
||||||
|
qss = self.parent_window.styleSheet() if self.parent_window else ''
|
||||||
|
if not qss:
|
||||||
|
try:
|
||||||
|
from ui.styles import get_theme
|
||||||
|
qss = get_theme('dark')
|
||||||
|
except Exception:
|
||||||
|
qss = ''
|
||||||
|
if qss:
|
||||||
|
menu.setStyleSheet(qss)
|
||||||
|
open_main = menu.addAction(tr('mini.open_main'))
|
||||||
|
close_mini = menu.addAction(tr('mini.close'))
|
||||||
action = menu.exec_(event.globalPos())
|
action = menu.exec_(event.globalPos())
|
||||||
if action == open_main and self.parent_window:
|
if action == open_main and self.parent_window:
|
||||||
self.parent_window.show()
|
self.parent_window.show()
|
||||||
|
|||||||
@ -32,17 +32,10 @@ WORK_PRESETS = [
|
|||||||
class WelcomePage(QWizardPage):
|
class WelcomePage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("👋 환영합니다!")
|
self.setTitle(tr('onboarding.welcome_title'))
|
||||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
self.setSubTitle(tr('onboarding.welcome_subtitle'))
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
intro = QLabel(
|
intro = QLabel(tr('onboarding.welcome_intro'))
|
||||||
"이 앱은:\n"
|
|
||||||
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
|
|
||||||
"• 30분 단위 연장근무 적립\n"
|
|
||||||
"• 연차·반차·외출 시간 추적\n"
|
|
||||||
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
|
|
||||||
"[다음] 버튼을 눌러 시작하세요."
|
|
||||||
)
|
|
||||||
intro.setWordWrap(True)
|
intro.setWordWrap(True)
|
||||||
layout.addWidget(intro)
|
layout.addWidget(intro)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
@ -51,8 +44,8 @@ class WelcomePage(QWizardPage):
|
|||||||
class WorkPatternPage(QWizardPage):
|
class WorkPatternPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("🕘 근무 패턴")
|
self.setTitle(tr('onboarding.work_pattern_title'))
|
||||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
self.setSubTitle(tr('onboarding.work_pattern_subtitle'))
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.button_group = QButtonGroup(self)
|
self.button_group = QButtonGroup(self)
|
||||||
@ -127,22 +120,20 @@ class WorkPatternPage(QWizardPage):
|
|||||||
class ClockInDetectionPage(QWizardPage):
|
class ClockInDetectionPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("⏰ 출근 시간 감지 방식")
|
self.setTitle(tr('onboarding.detection_title'))
|
||||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
self.setSubTitle(tr('onboarding.detection_subtitle'))
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
|
self.option_boot = QRadioButton(tr('onboarding.detection_boot'))
|
||||||
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
|
self.option_unlock = QRadioButton(tr('onboarding.detection_unlock'))
|
||||||
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
|
self.option_manual = QRadioButton(tr('onboarding.detection_manual'))
|
||||||
self.option_boot.setChecked(True)
|
self.option_boot.setChecked(True)
|
||||||
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
||||||
layout.addWidget(opt)
|
layout.addWidget(opt)
|
||||||
|
|
||||||
info = QLabel(
|
info = QLabel(tr('onboarding.detection_info'))
|
||||||
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
|
||||||
)
|
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #888; padding: 8px;")
|
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
@ -159,35 +150,35 @@ class ClockInDetectionPage(QWizardPage):
|
|||||||
class LeaveSalaryPage(QWizardPage):
|
class LeaveSalaryPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
|
self.setTitle(tr('onboarding.leave_salary_title'))
|
||||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
self.setSubTitle(tr('onboarding.leave_salary_subtitle'))
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
# 연차
|
# 연차
|
||||||
leave_box = QGroupBox("연간 연차")
|
leave_box = QGroupBox(tr('onboarding.leave_group'))
|
||||||
leave_layout = QHBoxLayout()
|
leave_layout = QHBoxLayout()
|
||||||
self.leave_spin = QSpinBox()
|
self.leave_spin = QSpinBox()
|
||||||
self.leave_spin.setRange(0, 30)
|
self.leave_spin.setRange(0, 30)
|
||||||
self.leave_spin.setValue(15)
|
self.leave_spin.setValue(15)
|
||||||
self.leave_spin.setSuffix(" 일")
|
self.leave_spin.setSuffix(tr('label.unit_day'))
|
||||||
leave_layout.addWidget(QLabel("내 연차:"))
|
leave_layout.addWidget(QLabel(tr('onboarding.my_leave')))
|
||||||
leave_layout.addWidget(self.leave_spin)
|
leave_layout.addWidget(self.leave_spin)
|
||||||
leave_layout.addStretch()
|
leave_layout.addStretch()
|
||||||
leave_box.setLayout(leave_layout)
|
leave_box.setLayout(leave_layout)
|
||||||
layout.addWidget(leave_box)
|
layout.addWidget(leave_box)
|
||||||
|
|
||||||
# 급여 (옵션)
|
# 급여 (옵션)
|
||||||
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
|
salary_box = QGroupBox(tr('onboarding.salary_group'))
|
||||||
salary_layout = QVBoxLayout()
|
salary_layout = QVBoxLayout()
|
||||||
self.salary_enabled = QCheckBox("급여 추정 활성화")
|
self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled'))
|
||||||
salary_layout.addWidget(self.salary_enabled)
|
salary_layout.addWidget(self.salary_enabled)
|
||||||
|
|
||||||
wage_row = QHBoxLayout()
|
wage_row = QHBoxLayout()
|
||||||
wage_row.addWidget(QLabel("시급:"))
|
wage_row.addWidget(QLabel(tr('onboarding.hourly_wage')))
|
||||||
self.wage_spin = QSpinBox()
|
self.wage_spin = QSpinBox()
|
||||||
self.wage_spin.setRange(0, 1000000)
|
self.wage_spin.setRange(0, 1000000)
|
||||||
self.wage_spin.setSingleStep(1000)
|
self.wage_spin.setSingleStep(1000)
|
||||||
self.wage_spin.setSuffix(" 원/시간")
|
self.wage_spin.setSuffix(tr('onboarding.wage_suffix'))
|
||||||
self.wage_spin.setValue(0)
|
self.wage_spin.setValue(0)
|
||||||
self.wage_spin.setEnabled(False)
|
self.wage_spin.setEnabled(False)
|
||||||
wage_row.addWidget(self.wage_spin)
|
wage_row.addWidget(self.wage_spin)
|
||||||
@ -195,11 +186,11 @@ class LeaveSalaryPage(QWizardPage):
|
|||||||
salary_layout.addLayout(wage_row)
|
salary_layout.addLayout(wage_row)
|
||||||
|
|
||||||
rate_row = QHBoxLayout()
|
rate_row = QHBoxLayout()
|
||||||
rate_row.addWidget(QLabel("연장수당 가산률:"))
|
rate_row.addWidget(QLabel(tr('onboarding.overtime_rate')))
|
||||||
self.rate_combo = QComboBox()
|
self.rate_combo = QComboBox()
|
||||||
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
|
self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0)
|
||||||
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
|
self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5)
|
||||||
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
|
self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0)
|
||||||
self.rate_combo.setCurrentIndex(1)
|
self.rate_combo.setCurrentIndex(1)
|
||||||
self.rate_combo.setEnabled(False)
|
self.rate_combo.setEnabled(False)
|
||||||
rate_row.addWidget(self.rate_combo)
|
rate_row.addWidget(self.rate_combo)
|
||||||
@ -218,30 +209,25 @@ class LeaveSalaryPage(QWizardPage):
|
|||||||
class DiscordPage(QWizardPage):
|
class DiscordPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("💬 Discord 알림 (선택)")
|
self.setTitle(tr('onboarding.discord_title'))
|
||||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
self.setSubTitle(tr('onboarding.discord_subtitle'))
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
|
self.enable_check = QCheckBox(tr('onboarding.discord_enable'))
|
||||||
layout.addWidget(self.enable_check)
|
layout.addWidget(self.enable_check)
|
||||||
|
|
||||||
self.url_edit = QLineEdit()
|
self.url_edit = QLineEdit()
|
||||||
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
|
self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder'))
|
||||||
self.url_edit.setEnabled(False)
|
self.url_edit.setEnabled(False)
|
||||||
layout.addWidget(self.url_edit)
|
layout.addWidget(self.url_edit)
|
||||||
|
|
||||||
guide = QLabel(
|
guide = QLabel(tr('onboarding.discord_guide'))
|
||||||
"셋업 방법:\n"
|
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||||
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
|
|
||||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
|
||||||
"3. 위 입력란에 붙여넣기"
|
|
||||||
)
|
|
||||||
guide.setStyleSheet("color: #888; padding: 6px;")
|
|
||||||
guide.setWordWrap(True)
|
guide.setWordWrap(True)
|
||||||
layout.addWidget(guide)
|
layout.addWidget(guide)
|
||||||
|
|
||||||
test_row = QHBoxLayout()
|
test_row = QHBoxLayout()
|
||||||
self.test_btn = QPushButton("테스트 메시지 보내기")
|
self.test_btn = QPushButton(tr('onboarding.discord_test'))
|
||||||
self.test_btn.setEnabled(False)
|
self.test_btn.setEnabled(False)
|
||||||
self.test_btn.clicked.connect(self._test_webhook)
|
self.test_btn.clicked.connect(self._test_webhook)
|
||||||
test_row.addWidget(self.test_btn)
|
test_row.addWidget(self.test_btn)
|
||||||
@ -257,39 +243,30 @@ class DiscordPage(QWizardPage):
|
|||||||
def _test_webhook(self):
|
def _test_webhook(self):
|
||||||
url = self.url_edit.text().strip()
|
url = self.url_edit.text().strip()
|
||||||
if not url:
|
if not url:
|
||||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body'))
|
||||||
return
|
return
|
||||||
from utils import discord_webhook
|
from utils import discord_webhook
|
||||||
if not discord_webhook.is_valid_webhook_url(url):
|
if not discord_webhook.is_valid_webhook_url(url):
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self, "URL 형식 오류",
|
self, tr('onboarding.discord_url_invalid_title'),
|
||||||
"Discord 웹훅 URL 형식이 아닙니다.\n"
|
tr('onboarding.discord_url_invalid_body')
|
||||||
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
ok = discord_webhook.send_test(url)
|
ok = discord_webhook.send_test(url)
|
||||||
if ok:
|
if ok:
|
||||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body'))
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
|
QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body'))
|
||||||
|
|
||||||
|
|
||||||
class FinishPage(QWizardPage):
|
class FinishPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("🎉 준비 완료!")
|
self.setTitle(tr('onboarding.finish_title'))
|
||||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
self.setSubTitle(tr('onboarding.finish_subtitle'))
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
msg = QLabel(
|
msg = QLabel(tr('onboarding.finish_msg'))
|
||||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
|
||||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
|
||||||
"🕐 단축키:\n"
|
|
||||||
" • Ctrl+O — 출퇴근 토글\n"
|
|
||||||
" • F1 — 도움말\n"
|
|
||||||
" • F5 — 업데이트 확인\n"
|
|
||||||
" • Ctrl+, — 설정"
|
|
||||||
)
|
|
||||||
msg.setWordWrap(True)
|
msg.setWordWrap(True)
|
||||||
layout.addWidget(msg)
|
layout.addWidget(msg)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
@ -301,7 +278,7 @@ class OnboardingWizard(QWizard):
|
|||||||
def __init__(self, db, parent=None):
|
def __init__(self, db, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("Clock-out Calculator — 시작 설정")
|
self.setWindowTitle(tr('onboarding.window_title'))
|
||||||
self.setMinimumSize(600, 500)
|
self.setMinimumSize(600, 500)
|
||||||
self.setWizardStyle(QWizard.ModernStyle)
|
self.setWizardStyle(QWizard.ModernStyle)
|
||||||
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
||||||
@ -323,7 +300,7 @@ class OnboardingWizard(QWizard):
|
|||||||
# 1. 근무 패턴
|
# 1. 근무 패턴
|
||||||
wm, lm, dm = self.work_page.selected_minutes()
|
wm, lm, dm = self.work_page.selected_minutes()
|
||||||
if wm < 30:
|
if wm < 30:
|
||||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small'))
|
||||||
return
|
return
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
|
|||||||
@ -66,6 +66,8 @@ class OvertimeView(QDialog):
|
|||||||
self.earned_table.setAlternatingRowColors(True)
|
self.earned_table.setAlternatingRowColors(True)
|
||||||
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||||
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
|
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
|
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||||
|
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
|
||||||
earned_layout.addWidget(self.earned_table)
|
earned_layout.addWidget(self.earned_table)
|
||||||
|
|
||||||
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
||||||
@ -126,7 +128,7 @@ class OvertimeView(QDialog):
|
|||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
|
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
|
||||||
FROM overtime_bank ob
|
FROM overtime_bank ob
|
||||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||||
ORDER BY ob.date DESC
|
ORDER BY ob.date DESC
|
||||||
@ -138,6 +140,7 @@ class OvertimeView(QDialog):
|
|||||||
for i, record in enumerate(earned_records):
|
for i, record in enumerate(earned_records):
|
||||||
date_item = QTableWidgetItem(record[0])
|
date_item = QTableWidgetItem(record[0])
|
||||||
date_item.setTextAlignment(Qt.AlignCenter)
|
date_item.setTextAlignment(Qt.AlignCenter)
|
||||||
|
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
|
||||||
|
|
||||||
minutes = record[1]
|
minutes = record[1]
|
||||||
hours = minutes // 60
|
hours = minutes // 60
|
||||||
@ -148,7 +151,7 @@ class OvertimeView(QDialog):
|
|||||||
time_str = tr('view.break.duration_min_only', m=mins)
|
time_str = tr('view.break.duration_min_only', m=mins)
|
||||||
time_item = QTableWidgetItem(time_str)
|
time_item = QTableWidgetItem(time_str)
|
||||||
time_item.setTextAlignment(Qt.AlignCenter)
|
time_item.setTextAlignment(Qt.AlignCenter)
|
||||||
time_item.setForeground(QColor(39, 174, 96)) # 초록색
|
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
|
||||||
|
|
||||||
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
memo_text = manual_label if record[2] is None else (record[3] or "")
|
||||||
@ -183,7 +186,7 @@ class OvertimeView(QDialog):
|
|||||||
time_str = tr('view.break.duration_min_only', m=mins)
|
time_str = tr('view.break.duration_min_only', m=mins)
|
||||||
time_item = QTableWidgetItem(time_str)
|
time_item = QTableWidgetItem(time_str)
|
||||||
time_item.setTextAlignment(Qt.AlignCenter)
|
time_item.setTextAlignment(Qt.AlignCenter)
|
||||||
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||||
|
|
||||||
reason_item = QTableWidgetItem(record[3] or "")
|
reason_item = QTableWidgetItem(record[3] or "")
|
||||||
|
|
||||||
@ -249,6 +252,46 @@ class OvertimeView(QDialog):
|
|||||||
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||||
self.parent().update_overtime_balance()
|
self.parent().update_overtime_balance()
|
||||||
|
|
||||||
|
def show_earned_context_menu(self, position):
|
||||||
|
"""적립 내역 우클릭 메뉴 (삭제)."""
|
||||||
|
selected_rows = self.earned_table.selectionModel().selectedRows()
|
||||||
|
if not selected_rows:
|
||||||
|
return
|
||||||
|
menu = QMenu(self)
|
||||||
|
delete_action = QAction(tr('view.overtime.menu_delete'), self)
|
||||||
|
delete_action.triggered.connect(self.delete_earned_record)
|
||||||
|
menu.addAction(delete_action)
|
||||||
|
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def delete_earned_record(self):
|
||||||
|
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
|
||||||
|
selected_rows = self.earned_table.selectionModel().selectedRows()
|
||||||
|
if not selected_rows:
|
||||||
|
return
|
||||||
|
row = selected_rows[0].row()
|
||||||
|
date_item = self.earned_table.item(row, 0)
|
||||||
|
time_item = self.earned_table.item(row, 1)
|
||||||
|
|
||||||
|
# 행에 저장된 overtime_bank.id
|
||||||
|
bank_id = date_item.data(Qt.UserRole)
|
||||||
|
if bank_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
tr('msg.confirm_delete.title'),
|
||||||
|
tr('view.overtime.delete_earned_confirm_body',
|
||||||
|
date=date_item.text(), time=time_item.text()),
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
)
|
||||||
|
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
self.db.delete_overtime_earned(bank_id)
|
||||||
|
self.load_data()
|
||||||
|
# 부모 윈도우 잔액 업데이트
|
||||||
|
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||||
|
self.parent().update_overtime_balance()
|
||||||
|
|
||||||
def add_earned_record(self):
|
def add_earned_record(self):
|
||||||
"""수동 적립 추가"""
|
"""수동 적립 추가"""
|
||||||
dialog = AddOvertimeEarnedDialog(self, self.db)
|
dialog = AddOvertimeEarnedDialog(self, self.db)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
QMessageBox)
|
QMessageBox)
|
||||||
from PyQt5.QtCore import QTime, Qt
|
from PyQt5.QtCore import QTime, Qt
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -18,7 +19,7 @@ class PastRecordDialog(QDialog):
|
|||||||
def __init__(self, parent=None, date_str: str = ''):
|
def __init__(self, parent=None, date_str: str = ''):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.date_str = date_str
|
self.date_str = date_str
|
||||||
self.setWindowTitle(f"기록 추가 — {date_str}")
|
self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setFixedSize(380, 320)
|
self.setFixedSize(380, 320)
|
||||||
|
|
||||||
@ -26,13 +27,13 @@ class PastRecordDialog(QDialog):
|
|||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
|
info = QLabel(tr('past_record.info', date=date_str))
|
||||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
# 출근
|
# 출근
|
||||||
ci_row = QHBoxLayout()
|
ci_row = QHBoxLayout()
|
||||||
ci_row.addWidget(QLabel("출근:"))
|
ci_row.addWidget(QLabel(tr('past_record.label_clock_in')))
|
||||||
self.clock_in_edit = QTimeEdit()
|
self.clock_in_edit = QTimeEdit()
|
||||||
self.clock_in_edit.setDisplayFormat("HH:mm")
|
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||||
self.clock_in_edit.setTime(QTime(9, 0))
|
self.clock_in_edit.setTime(QTime(9, 0))
|
||||||
@ -42,8 +43,8 @@ class PastRecordDialog(QDialog):
|
|||||||
|
|
||||||
# 퇴근
|
# 퇴근
|
||||||
co_row = QHBoxLayout()
|
co_row = QHBoxLayout()
|
||||||
co_row.addWidget(QLabel("퇴근:"))
|
co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
|
||||||
self.clock_out_check = QCheckBox("입력")
|
self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
|
||||||
self.clock_out_check.setChecked(True)
|
self.clock_out_check.setChecked(True)
|
||||||
self.clock_out_edit = QTimeEdit()
|
self.clock_out_edit = QTimeEdit()
|
||||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||||
@ -56,18 +57,18 @@ class PastRecordDialog(QDialog):
|
|||||||
|
|
||||||
# 점심/저녁
|
# 점심/저녁
|
||||||
meal_row = QHBoxLayout()
|
meal_row = QHBoxLayout()
|
||||||
self.lunch_check = QCheckBox("🍱 점심시간 포함")
|
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
||||||
self.lunch_check.setChecked(True)
|
self.lunch_check.setChecked(True)
|
||||||
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
|
self.dinner_check = QCheckBox(tr('past_record.check_dinner'))
|
||||||
meal_row.addWidget(self.lunch_check)
|
meal_row.addWidget(self.lunch_check)
|
||||||
meal_row.addWidget(self.dinner_check)
|
meal_row.addWidget(self.dinner_check)
|
||||||
meal_row.addStretch()
|
meal_row.addStretch()
|
||||||
layout.addLayout(meal_row)
|
layout.addLayout(meal_row)
|
||||||
|
|
||||||
# 메모
|
# 메모
|
||||||
layout.addWidget(QLabel("메모 (선택):"))
|
layout.addWidget(QLabel(tr('past_record.label_memo')))
|
||||||
self.memo_edit = QLineEdit()
|
self.memo_edit = QLineEdit()
|
||||||
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
|
||||||
layout.addWidget(self.memo_edit)
|
layout.addWidget(self.memo_edit)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
@ -75,10 +76,10 @@ class PastRecordDialog(QDialog):
|
|||||||
# 버튼
|
# 버튼
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
ok_btn = QPushButton("저장")
|
ok_btn = QPushButton(tr('btn.save'))
|
||||||
ok_btn.setObjectName("btn_primary")
|
ok_btn.setObjectName("btn_primary")
|
||||||
ok_btn.clicked.connect(self._validate_and_accept)
|
ok_btn.clicked.connect(self._validate_and_accept)
|
||||||
cancel_btn = QPushButton("취소")
|
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||||
cancel_btn.clicked.connect(self.reject)
|
cancel_btn.clicked.connect(self.reject)
|
||||||
btn_row.addWidget(ok_btn)
|
btn_row.addWidget(ok_btn)
|
||||||
btn_row.addWidget(cancel_btn)
|
btn_row.addWidget(cancel_btn)
|
||||||
@ -92,8 +93,8 @@ class PastRecordDialog(QDialog):
|
|||||||
ci = self.clock_in_edit.time()
|
ci = self.clock_in_edit.time()
|
||||||
co = self.clock_out_edit.time()
|
co = self.clock_out_edit.time()
|
||||||
if co <= ci:
|
if co <= ci:
|
||||||
QMessageBox.warning(self, "입력 오류",
|
QMessageBox.warning(self, tr('past_record.input_error_title'),
|
||||||
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
tr('past_record.input_error_body'))
|
||||||
return
|
return
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|||||||
from PyQt5.QtCore import QDate, Qt
|
from PyQt5.QtCore import QDate, Qt
|
||||||
|
|
||||||
from core.recurring_leaves import describe_pattern
|
from core.recurring_leaves import describe_pattern
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -27,7 +28,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("🔁 반복 연차 관리")
|
self.setWindowTitle(tr('recurring.title'))
|
||||||
self.setMinimumSize(540, 480)
|
self.setMinimumSize(540, 480)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._reload_list()
|
self._reload_list()
|
||||||
@ -37,29 +38,29 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
# 기존 패턴 목록
|
# 기존 패턴 목록
|
||||||
list_group = QGroupBox("등록된 반복 패턴")
|
list_group = QGroupBox(tr('recurring.list_group'))
|
||||||
lg = QVBoxLayout()
|
lg = QVBoxLayout()
|
||||||
self.list_widget = QListWidget()
|
self.list_widget = QListWidget()
|
||||||
self.list_widget.setMinimumHeight(160)
|
self.list_widget.setMinimumHeight(160)
|
||||||
lg.addWidget(self.list_widget)
|
lg.addWidget(self.list_widget)
|
||||||
del_btn = QPushButton("선택 삭제")
|
del_btn = QPushButton(tr('recurring.btn_delete_selected'))
|
||||||
del_btn.clicked.connect(self._delete_selected)
|
del_btn.clicked.connect(self._delete_selected)
|
||||||
lg.addWidget(del_btn)
|
lg.addWidget(del_btn)
|
||||||
list_group.setLayout(lg)
|
list_group.setLayout(lg)
|
||||||
layout.addWidget(list_group)
|
layout.addWidget(list_group)
|
||||||
|
|
||||||
# 신규 등록
|
# 신규 등록
|
||||||
add_group = QGroupBox("신규 패턴 추가")
|
add_group = QGroupBox(tr('recurring.add_group'))
|
||||||
ag = QVBoxLayout()
|
ag = QVBoxLayout()
|
||||||
|
|
||||||
# 패턴 종류
|
# 패턴 종류
|
||||||
kind_row = QHBoxLayout()
|
kind_row = QHBoxLayout()
|
||||||
kind_row.addWidget(QLabel("주기:"))
|
kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
|
||||||
self.kind_group = QButtonGroup(self)
|
self.kind_group = QButtonGroup(self)
|
||||||
self.rb_weekly = QRadioButton("매주")
|
self.rb_weekly = QRadioButton(tr('recurring.weekly'))
|
||||||
self.rb_weekly.setChecked(True)
|
self.rb_weekly.setChecked(True)
|
||||||
self.rb_biweekly = QRadioButton("격주")
|
self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
|
||||||
self.rb_monthly = QRadioButton("매월 N일")
|
self.rb_monthly = QRadioButton(tr('recurring.monthly'))
|
||||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||||
self.kind_group.addButton(rb)
|
self.kind_group.addButton(rb)
|
||||||
kind_row.addWidget(rb)
|
kind_row.addWidget(rb)
|
||||||
@ -68,10 +69,10 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
|
|
||||||
# 요일 체크박스 (weekly/biweekly)
|
# 요일 체크박스 (weekly/biweekly)
|
||||||
wd_row = QHBoxLayout()
|
wd_row = QHBoxLayout()
|
||||||
wd_row.addWidget(QLabel("요일:"))
|
wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
|
||||||
self.weekday_checks = []
|
self.weekday_checks = []
|
||||||
for ko, en in _KO_WEEKDAYS:
|
for ko, en in _KO_WEEKDAYS:
|
||||||
cb = QCheckBox(ko)
|
cb = QCheckBox(tr(f'label.weekday_{en}'))
|
||||||
self.weekday_checks.append((cb, en))
|
self.weekday_checks.append((cb, en))
|
||||||
wd_row.addWidget(cb)
|
wd_row.addWidget(cb)
|
||||||
wd_row.addStretch()
|
wd_row.addStretch()
|
||||||
@ -79,40 +80,40 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
|
|
||||||
# 매월 N일
|
# 매월 N일
|
||||||
month_row = QHBoxLayout()
|
month_row = QHBoxLayout()
|
||||||
month_row.addWidget(QLabel("매월:"))
|
month_row.addWidget(QLabel(tr('recurring.label_monthly_day')))
|
||||||
self.day_of_month = QSpinBox()
|
self.day_of_month = QSpinBox()
|
||||||
self.day_of_month.setRange(1, 31)
|
self.day_of_month.setRange(1, 31)
|
||||||
self.day_of_month.setValue(15)
|
self.day_of_month.setValue(15)
|
||||||
self.day_of_month.setSuffix("일")
|
self.day_of_month.setSuffix(tr('recurring.day_suffix'))
|
||||||
month_row.addWidget(self.day_of_month)
|
month_row.addWidget(self.day_of_month)
|
||||||
month_row.addStretch()
|
month_row.addStretch()
|
||||||
ag.addLayout(month_row)
|
ag.addLayout(month_row)
|
||||||
|
|
||||||
# 차감 일수
|
# 차감 일수
|
||||||
days_row = QHBoxLayout()
|
days_row = QHBoxLayout()
|
||||||
days_row.addWidget(QLabel("차감:"))
|
days_row.addWidget(QLabel(tr('recurring.label_deduction')))
|
||||||
self.days_combo = QComboBox()
|
self.days_combo = QComboBox()
|
||||||
self.days_combo.addItem("1.0일 (종일)", 1.0)
|
self.days_combo.addItem(tr('recurring.deduction_full'), 1.0)
|
||||||
self.days_combo.addItem("0.5일 (반차)", 0.5)
|
self.days_combo.addItem(tr('recurring.deduction_half'), 0.5)
|
||||||
self.days_combo.addItem("0.25일 (반반차)", 0.25)
|
self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25)
|
||||||
days_row.addWidget(self.days_combo)
|
days_row.addWidget(self.days_combo)
|
||||||
days_row.addStretch()
|
days_row.addStretch()
|
||||||
ag.addLayout(days_row)
|
ag.addLayout(days_row)
|
||||||
|
|
||||||
# 시작/종료 날짜
|
# 시작/종료 날짜
|
||||||
date_row = QHBoxLayout()
|
date_row = QHBoxLayout()
|
||||||
date_row.addWidget(QLabel("시작:"))
|
date_row.addWidget(QLabel(tr('recurring.label_start')))
|
||||||
self.start_edit = QDateEdit()
|
self.start_edit = QDateEdit()
|
||||||
self.start_edit.setDate(QDate.currentDate())
|
self.start_edit.setDate(QDate.currentDate())
|
||||||
self.start_edit.setCalendarPopup(True)
|
self.start_edit.setCalendarPopup(True)
|
||||||
date_row.addWidget(self.start_edit)
|
date_row.addWidget(self.start_edit)
|
||||||
|
|
||||||
date_row.addWidget(QLabel("종료:"))
|
date_row.addWidget(QLabel(tr('recurring.label_end')))
|
||||||
self.end_edit = QDateEdit()
|
self.end_edit = QDateEdit()
|
||||||
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||||
self.end_edit.setCalendarPopup(True)
|
self.end_edit.setCalendarPopup(True)
|
||||||
date_row.addWidget(self.end_edit)
|
date_row.addWidget(self.end_edit)
|
||||||
self.no_end_check = QCheckBox("종료 없음 (무기한)")
|
self.no_end_check = QCheckBox(tr('recurring.no_end'))
|
||||||
self.no_end_check.toggled.connect(
|
self.no_end_check.toggled.connect(
|
||||||
lambda v: self.end_edit.setEnabled(not v)
|
lambda v: self.end_edit.setEnabled(not v)
|
||||||
)
|
)
|
||||||
@ -122,14 +123,14 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
|
|
||||||
# 메모
|
# 메모
|
||||||
memo_row = QHBoxLayout()
|
memo_row = QHBoxLayout()
|
||||||
memo_row.addWidget(QLabel("메모:"))
|
memo_row.addWidget(QLabel(tr('recurring.label_memo')))
|
||||||
self.memo_edit = QLineEdit()
|
self.memo_edit = QLineEdit()
|
||||||
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
|
self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
|
||||||
memo_row.addWidget(self.memo_edit)
|
memo_row.addWidget(self.memo_edit)
|
||||||
ag.addLayout(memo_row)
|
ag.addLayout(memo_row)
|
||||||
|
|
||||||
# 추가 버튼
|
# 추가 버튼
|
||||||
add_btn = QPushButton("➕ 추가")
|
add_btn = QPushButton(tr('recurring.btn_add'))
|
||||||
add_btn.setObjectName("btn_primary")
|
add_btn.setObjectName("btn_primary")
|
||||||
add_btn.clicked.connect(self._save)
|
add_btn.clicked.connect(self._save)
|
||||||
ag.addWidget(add_btn)
|
ag.addWidget(add_btn)
|
||||||
@ -138,7 +139,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
layout.addWidget(add_group)
|
layout.addWidget(add_group)
|
||||||
|
|
||||||
# 닫기
|
# 닫기
|
||||||
close_btn = QPushButton("닫기")
|
close_btn = QPushButton(tr('btn.close'))
|
||||||
close_btn.clicked.connect(self.close)
|
close_btn.clicked.connect(self.close)
|
||||||
layout.addWidget(close_btn)
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
@ -148,7 +149,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
self.list_widget.clear()
|
self.list_widget.clear()
|
||||||
for r in self.db.get_recurring_leaves():
|
for r in self.db.get_recurring_leaves():
|
||||||
desc = describe_pattern(r['pattern'])
|
desc = describe_pattern(r['pattern'])
|
||||||
end = r.get('end_date') or '무기한'
|
end = r.get('end_date') or tr('recurring.no_end')
|
||||||
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||||
f"· {r['start_date']} ~ {end}")
|
f"· {r['start_date']} ~ {end}")
|
||||||
if r.get('memo'):
|
if r.get('memo'):
|
||||||
@ -163,8 +164,8 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
return
|
return
|
||||||
rec_id = item.data(Qt.UserRole)
|
rec_id = item.data(Qt.UserRole)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "삭제 확인",
|
self, tr('recurring.delete_confirm_title'),
|
||||||
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
|
tr('recurring.delete_confirm_body', item=item.text()),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@ -184,7 +185,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
def _save(self):
|
def _save(self):
|
||||||
pattern = self._build_pattern()
|
pattern = self._build_pattern()
|
||||||
if not pattern:
|
if not pattern:
|
||||||
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
|
QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
|
||||||
return
|
return
|
||||||
days = self.days_combo.currentData()
|
days = self.days_combo.currentData()
|
||||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||||
@ -193,7 +194,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
memo = self.memo_edit.text().strip()
|
memo = self.memo_edit.text().strip()
|
||||||
|
|
||||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||||
QMessageBox.information(self, "추가 완료",
|
QMessageBox.information(self, tr('recurring.add_done_title'),
|
||||||
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
|
tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
|
||||||
self.memo_edit.clear()
|
self.memo_edit.clear()
|
||||||
self._reload_list()
|
self._reload_list()
|
||||||
|
|||||||
@ -18,6 +18,7 @@ from PyQt5.QtCore import Qt, QDate
|
|||||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||||
|
|
||||||
from core.recurring_leaves import expand_for_range, describe_pattern
|
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||||
|
from core.i18n import tr
|
||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
|
|
||||||
|
|
||||||
@ -38,7 +39,7 @@ class ScheduleView(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("🗓️ 스케줄")
|
self.setWindowTitle(tr('schedule.title'))
|
||||||
self.setMinimumSize(820, 560)
|
self.setMinimumSize(820, 560)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._reload()
|
self._reload()
|
||||||
@ -49,16 +50,16 @@ class ScheduleView(QDialog):
|
|||||||
|
|
||||||
# 상단 툴바
|
# 상단 툴바
|
||||||
bar = QHBoxLayout()
|
bar = QHBoxLayout()
|
||||||
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
|
title = QLabel(tr('schedule.header'))
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
bar.addWidget(title)
|
bar.addWidget(title)
|
||||||
bar.addStretch()
|
bar.addStretch()
|
||||||
|
|
||||||
rec_btn = QPushButton("🔁 반복 패턴 관리")
|
rec_btn = QPushButton(tr('schedule.btn_recurring'))
|
||||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||||
bar.addWidget(rec_btn)
|
bar.addWidget(rec_btn)
|
||||||
|
|
||||||
add_btn = QPushButton("➕ 연차 등록")
|
add_btn = QPushButton(tr('schedule.btn_add_leave'))
|
||||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||||
bar.addWidget(add_btn)
|
bar.addWidget(add_btn)
|
||||||
|
|
||||||
@ -66,11 +67,11 @@ class ScheduleView(QDialog):
|
|||||||
|
|
||||||
# 범례
|
# 범례
|
||||||
legend = QHBoxLayout()
|
legend = QHBoxLayout()
|
||||||
for label, color in [("공휴일", _C_HOLIDAY),
|
for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
|
||||||
("연차 사용", _C_LEAVE_FULL_PAST),
|
(tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
|
||||||
("연차 예정", _C_LEAVE_FULL_PLAN),
|
(tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
|
||||||
("반차/반반차", _C_LEAVE_HALF_PAST),
|
(tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
|
||||||
("반복 패턴", _C_RECURRING)]:
|
(tr('schedule.legend_recurring'), _C_RECURRING)]:
|
||||||
sw = QLabel(f" {label} ")
|
sw = QLabel(f" {label} ")
|
||||||
sw.setStyleSheet(
|
sw.setStyleSheet(
|
||||||
f"background-color: {color.name()}; color: white; "
|
f"background-color: {color.name()}; color: white; "
|
||||||
@ -94,7 +95,7 @@ class ScheduleView(QDialog):
|
|||||||
right = QWidget()
|
right = QWidget()
|
||||||
right_layout = QVBoxLayout()
|
right_layout = QVBoxLayout()
|
||||||
|
|
||||||
self.detail_title = QLabel("날짜를 선택하세요")
|
self.detail_title = QLabel(tr('schedule.detail_placeholder'))
|
||||||
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||||
right_layout.addWidget(self.detail_title)
|
right_layout.addWidget(self.detail_title)
|
||||||
|
|
||||||
@ -109,7 +110,7 @@ class ScheduleView(QDialog):
|
|||||||
|
|
||||||
layout.addWidget(splitter, 1)
|
layout.addWidget(splitter, 1)
|
||||||
|
|
||||||
close_btn = QPushButton("닫기")
|
close_btn = QPushButton(tr('btn.close'))
|
||||||
close_btn.clicked.connect(self.close)
|
close_btn.clicked.connect(self.close)
|
||||||
layout.addWidget(close_btn)
|
layout.addWidget(close_btn)
|
||||||
|
|
||||||
@ -189,18 +190,19 @@ class ScheduleView(QDialog):
|
|||||||
def _on_date_click(self, qd: QDate):
|
def _on_date_click(self, qd: QDate):
|
||||||
d = date(qd.year(), qd.month(), qd.day())
|
d = date(qd.year(), qd.month(), qd.day())
|
||||||
date_str = d.isoformat()
|
date_str = d.isoformat()
|
||||||
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
|
weekday_kr = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
||||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
|
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), tr('label.weekday_sun')]
|
||||||
|
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
|
||||||
self.detail_list.clear()
|
self.detail_list.clear()
|
||||||
|
|
||||||
# 휴일
|
# 휴일
|
||||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||||
if holiday:
|
if holiday:
|
||||||
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
|
item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
|
||||||
item.setForeground(QBrush(QColor("#e53935")))
|
item.setForeground(QBrush(QColor("#e53935")))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
elif d.weekday() in (5, 6):
|
elif d.weekday() in (5, 6):
|
||||||
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
|
item = QListWidgetItem(tr('schedule.weekend', weekday=weekday_kr[d.weekday()]))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
# 연차 (구체)
|
# 연차 (구체)
|
||||||
@ -208,7 +210,7 @@ class ScheduleView(QDialog):
|
|||||||
days = float(r.get('days') or 0)
|
days = float(r.get('days') or 0)
|
||||||
t = r.get('leave_type', '연차')
|
t = r.get('leave_type', '연차')
|
||||||
memo = r.get('memo') or ''
|
memo = r.get('memo') or ''
|
||||||
label = f"📌 {t} {days}일"
|
label = tr('schedule.leave_label', type=t, days=days)
|
||||||
if memo:
|
if memo:
|
||||||
label += f" — {memo}"
|
label += f" — {memo}"
|
||||||
label += f" [id={r['id']}]"
|
label += f" [id={r['id']}]"
|
||||||
@ -221,13 +223,14 @@ class ScheduleView(QDialog):
|
|||||||
from core.recurring_leaves import expand_for_date
|
from core.recurring_leaves import expand_for_date
|
||||||
for occ in expand_for_date(recurring, d):
|
for occ in expand_for_date(recurring, d):
|
||||||
item = QListWidgetItem(
|
item = QListWidgetItem(
|
||||||
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
|
tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
|
||||||
|
days=occ.days, type=occ.leave_type)
|
||||||
)
|
)
|
||||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
if self.detail_list.count() == 0:
|
if self.detail_list.count() == 0:
|
||||||
self.detail_list.addItem("일정 없음")
|
self.detail_list.addItem(tr('schedule.no_events'))
|
||||||
|
|
||||||
def _on_page_change(self, year: int, month: int):
|
def _on_page_change(self, year: int, month: int):
|
||||||
self._reload()
|
self._reload()
|
||||||
@ -241,7 +244,7 @@ class ScheduleView(QDialog):
|
|||||||
return
|
return
|
||||||
kind, _id = data
|
kind, _id = data
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
del_act = menu.addAction("삭제")
|
del_act = menu.addAction(tr('schedule.delete'))
|
||||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||||
if chosen == del_act:
|
if chosen == del_act:
|
||||||
self._delete_record(kind, _id)
|
self._delete_record(kind, _id)
|
||||||
@ -249,8 +252,8 @@ class ScheduleView(QDialog):
|
|||||||
def _delete_record(self, kind: str, _id: int):
|
def _delete_record(self, kind: str, _id: int):
|
||||||
if kind == 'concrete':
|
if kind == 'concrete':
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "삭제 확인",
|
self, tr('schedule.delete_leave_confirm_title'),
|
||||||
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
|
tr('schedule.delete_leave_confirm_body'),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@ -261,8 +264,8 @@ class ScheduleView(QDialog):
|
|||||||
self._on_date_click(d)
|
self._on_date_click(d)
|
||||||
elif kind == 'recurring':
|
elif kind == 'recurring':
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "삭제 확인",
|
self, tr('schedule.delete_recurring_confirm_title'),
|
||||||
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
|
tr('schedule.delete_recurring_confirm_body'),
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
|
|||||||
@ -58,7 +58,7 @@ class SettingsView(QDialog):
|
|||||||
main_layout.setSpacing(0)
|
main_layout.setSpacing(0)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel("설정")
|
title = QLabel(tr('settings.title'))
|
||||||
title.setObjectName("dialog_title")
|
title.setObjectName("dialog_title")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignCenter)
|
||||||
main_layout.addWidget(title)
|
main_layout.addWidget(title)
|
||||||
@ -137,16 +137,16 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 근무 패턴 프리셋
|
# 근무 패턴 프리셋
|
||||||
preset_layout = QHBoxLayout()
|
preset_layout = QHBoxLayout()
|
||||||
preset_label = QLabel("근무 패턴:")
|
preset_label = QLabel(tr('settings.work_pattern'))
|
||||||
preset_label.setFixedWidth(130)
|
preset_label.setFixedWidth(130)
|
||||||
self.work_preset_combo = QComboBox()
|
self.work_preset_combo = QComboBox()
|
||||||
# (label, work_minutes, lunch_minutes)
|
# (label, work_minutes, lunch_minutes)
|
||||||
self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60))
|
self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60))
|
||||||
self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30))
|
self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30))
|
||||||
self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60))
|
self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60))
|
||||||
self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30))
|
self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30))
|
||||||
self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0))
|
self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0))
|
||||||
self.work_preset_combo.addItem("사용자 정의", None)
|
self.work_preset_combo.addItem(tr('settings.preset.custom'), None)
|
||||||
self.work_preset_combo.setFixedWidth(260)
|
self.work_preset_combo.setFixedWidth(260)
|
||||||
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
|
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
|
||||||
preset_layout.addWidget(preset_label)
|
preset_layout.addWidget(preset_label)
|
||||||
@ -156,18 +156,18 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 하루 기본 근무 시간 (시 + 분 분리 입력)
|
# 하루 기본 근무 시간 (시 + 분 분리 입력)
|
||||||
work_hours_layout = QHBoxLayout()
|
work_hours_layout = QHBoxLayout()
|
||||||
work_hours_label = QLabel("하루 기본 근무:")
|
work_hours_label = QLabel(tr('settings.daily_work'))
|
||||||
work_hours_label.setFixedWidth(130)
|
work_hours_label.setFixedWidth(130)
|
||||||
self.work_hours_spin = QSpinBox()
|
self.work_hours_spin = QSpinBox()
|
||||||
self.work_hours_spin.setRange(0, 12)
|
self.work_hours_spin.setRange(0, 12)
|
||||||
self.work_hours_spin.setValue(8)
|
self.work_hours_spin.setValue(8)
|
||||||
self.work_hours_spin.setSuffix(" 시간")
|
self.work_hours_spin.setSuffix(tr('settings.suffix_hour'))
|
||||||
self.work_hours_spin.setFixedWidth(100)
|
self.work_hours_spin.setFixedWidth(100)
|
||||||
self.work_minutes_spin = QSpinBox()
|
self.work_minutes_spin = QSpinBox()
|
||||||
self.work_minutes_spin.setRange(0, 59)
|
self.work_minutes_spin.setRange(0, 59)
|
||||||
self.work_minutes_spin.setValue(0)
|
self.work_minutes_spin.setValue(0)
|
||||||
self.work_minutes_spin.setSingleStep(15)
|
self.work_minutes_spin.setSingleStep(15)
|
||||||
self.work_minutes_spin.setSuffix(" 분")
|
self.work_minutes_spin.setSuffix(tr('settings.suffix_minute'))
|
||||||
self.work_minutes_spin.setFixedWidth(100)
|
self.work_minutes_spin.setFixedWidth(100)
|
||||||
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
|
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
|
||||||
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
|
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||||
@ -180,34 +180,34 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 점심시간 기본값
|
# 점심시간 기본값
|
||||||
lunch_layout = QHBoxLayout()
|
lunch_layout = QHBoxLayout()
|
||||||
lunch_label = QLabel("점심시간 기본:")
|
lunch_label = QLabel(tr('settings.lunch_default'))
|
||||||
lunch_label.setFixedWidth(130)
|
lunch_label.setFixedWidth(130)
|
||||||
self.lunch_spin = QSpinBox()
|
self.lunch_spin = QSpinBox()
|
||||||
self.lunch_spin.setRange(0, 120)
|
self.lunch_spin.setRange(0, 120)
|
||||||
self.lunch_spin.setValue(60)
|
self.lunch_spin.setValue(60)
|
||||||
self.lunch_spin.setSingleStep(5)
|
self.lunch_spin.setSingleStep(5)
|
||||||
self.lunch_spin.setSuffix(" 분")
|
self.lunch_spin.setSuffix(tr('settings.suffix_minute'))
|
||||||
self.lunch_spin.setFixedWidth(110)
|
self.lunch_spin.setFixedWidth(110)
|
||||||
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
|
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||||
lunch_layout.addWidget(lunch_label)
|
lunch_layout.addWidget(lunch_label)
|
||||||
lunch_layout.addWidget(self.lunch_spin)
|
lunch_layout.addWidget(self.lunch_spin)
|
||||||
|
|
||||||
# 점심시간 자동 적용
|
# 점심시간 자동 적용
|
||||||
self.auto_lunch_check = QCheckBox("자동 적용")
|
self.auto_lunch_check = QCheckBox(tr('settings.auto_apply'))
|
||||||
self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용")
|
self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip'))
|
||||||
lunch_layout.addWidget(self.auto_lunch_check)
|
lunch_layout.addWidget(self.auto_lunch_check)
|
||||||
lunch_layout.addStretch()
|
lunch_layout.addStretch()
|
||||||
layout.addLayout(lunch_layout)
|
layout.addLayout(lunch_layout)
|
||||||
|
|
||||||
# 저녁시간 기본값
|
# 저녁시간 기본값
|
||||||
dinner_layout = QHBoxLayout()
|
dinner_layout = QHBoxLayout()
|
||||||
dinner_label = QLabel("저녁시간 기본:")
|
dinner_label = QLabel(tr('settings.dinner_default'))
|
||||||
dinner_label.setFixedWidth(130)
|
dinner_label.setFixedWidth(130)
|
||||||
self.dinner_spin = QSpinBox()
|
self.dinner_spin = QSpinBox()
|
||||||
self.dinner_spin.setRange(0, 120)
|
self.dinner_spin.setRange(0, 120)
|
||||||
self.dinner_spin.setValue(60)
|
self.dinner_spin.setValue(60)
|
||||||
self.dinner_spin.setSingleStep(5)
|
self.dinner_spin.setSingleStep(5)
|
||||||
self.dinner_spin.setSuffix(" 분")
|
self.dinner_spin.setSuffix(tr('settings.suffix_minute'))
|
||||||
self.dinner_spin.setFixedWidth(110)
|
self.dinner_spin.setFixedWidth(110)
|
||||||
dinner_layout.addWidget(dinner_label)
|
dinner_layout.addWidget(dinner_label)
|
||||||
dinner_layout.addWidget(self.dinner_spin)
|
dinner_layout.addWidget(self.dinner_spin)
|
||||||
@ -306,30 +306,30 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||||
check_row1 = QHBoxLayout()
|
check_row1 = QHBoxLayout()
|
||||||
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
self.clock_out_notification_check = QCheckBox(tr('settings.notif_clock_out'))
|
||||||
self.clock_out_notification_check.setChecked(True)
|
self.clock_out_notification_check.setChecked(True)
|
||||||
self.lunch_notification_check = QCheckBox("점심시간 등록 알림")
|
self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch'))
|
||||||
self.lunch_notification_check.setChecked(True)
|
self.lunch_notification_check.setChecked(True)
|
||||||
check_row1.addWidget(self.clock_out_notification_check)
|
check_row1.addWidget(self.clock_out_notification_check)
|
||||||
check_row1.addWidget(self.lunch_notification_check)
|
check_row1.addWidget(self.lunch_notification_check)
|
||||||
layout.addLayout(check_row1)
|
layout.addLayout(check_row1)
|
||||||
|
|
||||||
check_row2 = QHBoxLayout()
|
check_row2 = QHBoxLayout()
|
||||||
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner'))
|
||||||
self.dinner_notification_check.setChecked(True)
|
self.dinner_notification_check.setChecked(True)
|
||||||
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
self.overtime_notification_check = QCheckBox(tr('settings.notif_overtime'))
|
||||||
self.overtime_notification_check.setChecked(True)
|
self.overtime_notification_check.setChecked(True)
|
||||||
check_row2.addWidget(self.dinner_notification_check)
|
check_row2.addWidget(self.dinner_notification_check)
|
||||||
check_row2.addWidget(self.overtime_notification_check)
|
check_row2.addWidget(self.overtime_notification_check)
|
||||||
layout.addLayout(check_row2)
|
layout.addLayout(check_row2)
|
||||||
|
|
||||||
check_row3 = QHBoxLayout()
|
check_row3 = QHBoxLayout()
|
||||||
self.health_notification_check = QCheckBox("건강 경고 알림")
|
self.health_notification_check = QCheckBox(tr('settings.notif_health'))
|
||||||
self.health_notification_check.setChecked(True)
|
self.health_notification_check.setChecked(True)
|
||||||
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
|
self.health_break_notification_check = QCheckBox(tr('settings.notif_break'))
|
||||||
self.health_break_notification_check.setChecked(True)
|
self.health_break_notification_check.setChecked(True)
|
||||||
self.health_break_notification_check.setToolTip(
|
self.health_break_notification_check.setToolTip(
|
||||||
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
|
tr('settings.notif_break_tooltip')
|
||||||
)
|
)
|
||||||
check_row3.addWidget(self.health_notification_check)
|
check_row3.addWidget(self.health_notification_check)
|
||||||
check_row3.addWidget(self.health_break_notification_check)
|
check_row3.addWidget(self.health_break_notification_check)
|
||||||
@ -337,75 +337,75 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 퇴근 N분 전 알림 시점 설정
|
# 퇴근 N분 전 알림 시점 설정
|
||||||
before_row = QHBoxLayout()
|
before_row = QHBoxLayout()
|
||||||
before_label = QLabel("퇴근 알림 시점:")
|
before_label = QLabel(tr('settings.notif_before'))
|
||||||
before_label.setFixedWidth(110)
|
before_label.setFixedWidth(110)
|
||||||
self.notif_before_spin = QSpinBox()
|
self.notif_before_spin = QSpinBox()
|
||||||
self.notif_before_spin.setRange(1, 120)
|
self.notif_before_spin.setRange(1, 120)
|
||||||
self.notif_before_spin.setSingleStep(5)
|
self.notif_before_spin.setSingleStep(5)
|
||||||
self.notif_before_spin.setValue(30)
|
self.notif_before_spin.setValue(30)
|
||||||
self.notif_before_spin.setSuffix(" 분 전")
|
self.notif_before_spin.setSuffix(' ' + tr('settings.notif_before_spin_suffix'))
|
||||||
self.notif_before_spin.setFixedWidth(110)
|
self.notif_before_spin.setFixedWidth(110)
|
||||||
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip'))
|
||||||
before_row.addWidget(before_label)
|
before_row.addWidget(before_label)
|
||||||
before_row.addWidget(self.notif_before_spin)
|
before_row.addWidget(self.notif_before_spin)
|
||||||
before_row.addStretch()
|
before_row.addStretch()
|
||||||
layout.addLayout(before_row)
|
layout.addLayout(before_row)
|
||||||
|
|
||||||
# === 고급 임계값 (접이식 그룹박스) ===
|
# === 고급 임계값 (접이식 그룹박스) ===
|
||||||
adv_box = QGroupBox("고급 임계값")
|
adv_box = QGroupBox(tr('settings.advanced_thresholds'))
|
||||||
adv_box.setCheckable(True)
|
adv_box.setCheckable(True)
|
||||||
adv_box.setChecked(False) # 기본 접힘
|
adv_box.setChecked(False) # 기본 접힘
|
||||||
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
|
adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip'))
|
||||||
adv_layout = QVBoxLayout()
|
adv_layout = QVBoxLayout()
|
||||||
adv_layout.setSpacing(4)
|
adv_layout.setSpacing(4)
|
||||||
|
|
||||||
# 점심 알림 임계 (출근 후 N시간)
|
# 점심 알림 임계 (출근 후 N시간)
|
||||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
||||||
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
|
self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip'))
|
||||||
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
|
adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin))
|
||||||
|
|
||||||
# 저녁 알림 임계 (출근 후 N시간)
|
# 저녁 알림 임계 (출근 후 N시간)
|
||||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
|
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour'))
|
||||||
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
|
self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip'))
|
||||||
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
|
adv_layout.addLayout(self._labeled_row(tr('settings.dinner_alert_after'), self.dinner_reminder_spin))
|
||||||
|
|
||||||
# 연장근무 누적 임계
|
# 연장근무 누적 임계
|
||||||
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
|
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour'))
|
||||||
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
|
self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip'))
|
||||||
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
|
adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin))
|
||||||
|
|
||||||
# 주 X시간 임계
|
# 주 X시간 임계
|
||||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
|
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour'))
|
||||||
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
|
self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip'))
|
||||||
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
|
adv_layout.addLayout(self._labeled_row(tr('settings.weekly_limit'), self.weekly_hours_spin))
|
||||||
|
|
||||||
# 연속 연장근무 일수
|
# 연속 연장근무 일수
|
||||||
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, " 일")
|
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day'))
|
||||||
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
|
self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback=''))
|
||||||
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
|
adv_layout.addLayout(self._labeled_row(tr('settings.consecutive_ot'), self.health_consecutive_spin))
|
||||||
|
|
||||||
# 휴식 권고 (연속 근무 시간)
|
# 휴식 권고 (연속 근무 시간)
|
||||||
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
||||||
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
|
self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip'))
|
||||||
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
|
adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin))
|
||||||
|
|
||||||
adv_box.setLayout(adv_layout)
|
adv_box.setLayout(adv_layout)
|
||||||
layout.addWidget(adv_box)
|
layout.addWidget(adv_box)
|
||||||
|
|
||||||
# 시간 형식 + 테마 한 줄에
|
# 시간 형식 + 테마 한 줄에
|
||||||
format_row = QHBoxLayout()
|
format_row = QHBoxLayout()
|
||||||
time_format_label = QLabel("시간 형식:")
|
time_format_label = QLabel(tr('settings.time_format'))
|
||||||
time_format_label.setFixedWidth(70)
|
time_format_label.setFixedWidth(70)
|
||||||
self.time_format_combo = QComboBox()
|
self.time_format_combo = QComboBox()
|
||||||
self.time_format_combo.addItem("24시간 (17:30)", "24")
|
self.time_format_combo.addItem(tr('settings.time_format_24'), "24")
|
||||||
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12")
|
self.time_format_combo.addItem(tr('settings.time_format_12'), "12")
|
||||||
self.time_format_combo.setFixedWidth(180)
|
self.time_format_combo.setFixedWidth(180)
|
||||||
|
|
||||||
theme_label = QLabel("테마:")
|
theme_label = QLabel(tr('settings.theme'))
|
||||||
theme_label.setFixedWidth(40)
|
theme_label.setFixedWidth(40)
|
||||||
self.theme_combo = QComboBox()
|
self.theme_combo = QComboBox()
|
||||||
self.theme_combo.addItem("라이트", "light")
|
self.theme_combo.addItem(tr('settings.theme_light'), "light")
|
||||||
self.theme_combo.addItem("다크", "dark")
|
self.theme_combo.addItem(tr('settings.theme_dark'), "dark")
|
||||||
self.theme_combo.setFixedWidth(90)
|
self.theme_combo.setFixedWidth(90)
|
||||||
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
||||||
|
|
||||||
@ -419,7 +419,7 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 접근성: 글꼴 크기 + 고대비
|
# 접근성: 글꼴 크기 + 고대비
|
||||||
a11y_row = QHBoxLayout()
|
a11y_row = QHBoxLayout()
|
||||||
a11y_row.addWidget(QLabel("글꼴 크기:"))
|
a11y_row.addWidget(QLabel(tr('settings.font_scale')))
|
||||||
self.font_scale_combo = QComboBox()
|
self.font_scale_combo = QComboBox()
|
||||||
self.font_scale_combo.addItem("100%", "1.0")
|
self.font_scale_combo.addItem("100%", "1.0")
|
||||||
self.font_scale_combo.addItem("125%", "1.25")
|
self.font_scale_combo.addItem("125%", "1.25")
|
||||||
@ -427,8 +427,8 @@ class SettingsView(QDialog):
|
|||||||
self.font_scale_combo.setFixedWidth(90)
|
self.font_scale_combo.setFixedWidth(90)
|
||||||
a11y_row.addWidget(self.font_scale_combo)
|
a11y_row.addWidget(self.font_scale_combo)
|
||||||
a11y_row.addSpacing(16)
|
a11y_row.addSpacing(16)
|
||||||
self.high_contrast_check = QCheckBox("고대비 모드")
|
self.high_contrast_check = QCheckBox(tr('settings.high_contrast'))
|
||||||
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
|
self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip'))
|
||||||
a11y_row.addWidget(self.high_contrast_check)
|
a11y_row.addWidget(self.high_contrast_check)
|
||||||
a11y_row.addStretch()
|
a11y_row.addStretch()
|
||||||
layout.addLayout(a11y_row)
|
layout.addLayout(a11y_row)
|
||||||
@ -436,13 +436,13 @@ class SettingsView(QDialog):
|
|||||||
# 언어 선택
|
# 언어 선택
|
||||||
from core.i18n import available_languages, language_label
|
from core.i18n import available_languages, language_label
|
||||||
lang_row = QHBoxLayout()
|
lang_row = QHBoxLayout()
|
||||||
lang_label = QLabel("언어 / Language:")
|
lang_label = QLabel(tr('label.language'))
|
||||||
lang_label.setFixedWidth(120)
|
lang_label.setFixedWidth(120)
|
||||||
self.language_combo = QComboBox()
|
self.language_combo = QComboBox()
|
||||||
for code in available_languages():
|
for code in available_languages():
|
||||||
self.language_combo.addItem(language_label(code), code)
|
self.language_combo.addItem(language_label(code), code)
|
||||||
self.language_combo.setFixedWidth(140)
|
self.language_combo.setFixedWidth(140)
|
||||||
self.language_combo.setToolTip("언어 변경은 재시작 후 완전히 적용됩니다.")
|
self.language_combo.setToolTip(tr('group.language_restart_tooltip', fallback=''))
|
||||||
lang_row.addWidget(lang_label)
|
lang_row.addWidget(lang_label)
|
||||||
lang_row.addWidget(self.language_combo)
|
lang_row.addWidget(self.language_combo)
|
||||||
lang_row.addStretch()
|
lang_row.addStretch()
|
||||||
@ -459,16 +459,16 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 잔액 + 계산 단위 한 줄
|
# 잔액 + 계산 단위 한 줄
|
||||||
top_row = QHBoxLayout()
|
top_row = QHBoxLayout()
|
||||||
self.current_overtime_label = QLabel("현재 잔액: 계산 중...")
|
self.current_overtime_label = QLabel(tr('settings.current_balance'))
|
||||||
self.current_overtime_label.setObjectName("badge_success")
|
self.current_overtime_label.setObjectName("badge_success")
|
||||||
top_row.addWidget(self.current_overtime_label)
|
top_row.addWidget(self.current_overtime_label)
|
||||||
top_row.addStretch()
|
top_row.addStretch()
|
||||||
|
|
||||||
unit_label = QLabel("계산 단위:")
|
unit_label = QLabel(tr('settings.calc_unit'))
|
||||||
self.overtime_unit_combo = QComboBox()
|
self.overtime_unit_combo = QComboBox()
|
||||||
self.overtime_unit_combo.addItem("30분", 30)
|
self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30)
|
||||||
self.overtime_unit_combo.addItem("1시간", 60)
|
self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60)
|
||||||
self.overtime_unit_combo.addItem("15분", 15)
|
self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15)
|
||||||
self.overtime_unit_combo.setFixedWidth(100)
|
self.overtime_unit_combo.setFixedWidth(100)
|
||||||
top_row.addWidget(unit_label)
|
top_row.addWidget(unit_label)
|
||||||
top_row.addWidget(self.overtime_unit_combo)
|
top_row.addWidget(self.overtime_unit_combo)
|
||||||
@ -476,28 +476,28 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 초기 연장근무 설정
|
# 초기 연장근무 설정
|
||||||
initial_overtime_layout = QHBoxLayout()
|
initial_overtime_layout = QHBoxLayout()
|
||||||
initial_overtime_label = QLabel("기존 연장근무:")
|
initial_overtime_label = QLabel(tr('settings.initial_overtime'))
|
||||||
initial_overtime_label.setFixedWidth(100)
|
initial_overtime_label.setFixedWidth(100)
|
||||||
self.initial_overtime_hours = QSpinBox()
|
self.initial_overtime_hours = QSpinBox()
|
||||||
self.initial_overtime_hours.setRange(0, 200)
|
self.initial_overtime_hours.setRange(0, 200)
|
||||||
self.initial_overtime_hours.setValue(0)
|
self.initial_overtime_hours.setValue(0)
|
||||||
self.initial_overtime_hours.setSuffix(" 시간")
|
self.initial_overtime_hours.setSuffix(tr('settings.suffix_hour'))
|
||||||
self.initial_overtime_hours.setFixedWidth(110)
|
self.initial_overtime_hours.setFixedWidth(110)
|
||||||
|
|
||||||
self.initial_overtime_mins = QSpinBox()
|
self.initial_overtime_mins = QSpinBox()
|
||||||
self.initial_overtime_mins.setRange(0, 59)
|
self.initial_overtime_mins.setRange(0, 59)
|
||||||
self.initial_overtime_mins.setValue(0)
|
self.initial_overtime_mins.setValue(0)
|
||||||
self.initial_overtime_mins.setSuffix(" 분")
|
self.initial_overtime_mins.setSuffix(tr('settings.suffix_minute'))
|
||||||
self.initial_overtime_mins.setFixedWidth(100)
|
self.initial_overtime_mins.setFixedWidth(100)
|
||||||
|
|
||||||
apply_overtime_btn = QPushButton("적용")
|
apply_overtime_btn = QPushButton(tr('btn.apply'))
|
||||||
apply_overtime_btn.setObjectName("btn_small")
|
apply_overtime_btn.setObjectName("btn_small")
|
||||||
apply_overtime_btn.setFixedWidth(50)
|
apply_overtime_btn.setFixedWidth(50)
|
||||||
apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
|
apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
|
||||||
|
|
||||||
self.auto_overtime_check = QCheckBox("자동 적립")
|
self.auto_overtime_check = QCheckBox(tr('settings.auto_bank'))
|
||||||
self.auto_overtime_check.setChecked(True)
|
self.auto_overtime_check.setChecked(True)
|
||||||
self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립")
|
self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip'))
|
||||||
|
|
||||||
initial_overtime_layout.addWidget(initial_overtime_label)
|
initial_overtime_layout.addWidget(initial_overtime_label)
|
||||||
initial_overtime_layout.addWidget(self.initial_overtime_hours)
|
initial_overtime_layout.addWidget(self.initial_overtime_hours)
|
||||||
@ -507,7 +507,7 @@ class SettingsView(QDialog):
|
|||||||
initial_overtime_layout.addWidget(self.auto_overtime_check)
|
initial_overtime_layout.addWidget(self.auto_overtime_check)
|
||||||
layout.addLayout(initial_overtime_layout)
|
layout.addLayout(initial_overtime_layout)
|
||||||
|
|
||||||
initial_overtime_note = QLabel("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)")
|
initial_overtime_note = QLabel(tr('settings.initial_overtime_note', fallback=''))
|
||||||
initial_overtime_note.setObjectName("note_text")
|
initial_overtime_note.setObjectName("note_text")
|
||||||
layout.addWidget(initial_overtime_note)
|
layout.addWidget(initial_overtime_note)
|
||||||
|
|
||||||
@ -516,22 +516,22 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
def create_goal_group(self) -> QGroupBox:
|
def create_goal_group(self) -> QGroupBox:
|
||||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||||
group = QGroupBox("🎯 월간 목표 (0=비활성)")
|
group = QGroupBox(tr('settings.goal_group'))
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(6)
|
||||||
|
|
||||||
# 연장근무 상한
|
# 연장근무 상한
|
||||||
ot_row = QHBoxLayout()
|
ot_row = QHBoxLayout()
|
||||||
ot_label = QLabel("월 연장근무 상한:")
|
ot_label = QLabel(tr('settings.monthly_ot_cap'))
|
||||||
ot_label.setFixedWidth(150)
|
ot_label.setFixedWidth(150)
|
||||||
self.goal_ot_h = QSpinBox()
|
self.goal_ot_h = QSpinBox()
|
||||||
self.goal_ot_h.setRange(0, 100)
|
self.goal_ot_h.setRange(0, 100)
|
||||||
self.goal_ot_h.setSuffix(" 시간")
|
self.goal_ot_h.setSuffix(tr('settings.suffix_hour'))
|
||||||
self.goal_ot_h.setFixedWidth(100)
|
self.goal_ot_h.setFixedWidth(100)
|
||||||
self.goal_ot_m = QSpinBox()
|
self.goal_ot_m = QSpinBox()
|
||||||
self.goal_ot_m.setRange(0, 59)
|
self.goal_ot_m.setRange(0, 59)
|
||||||
self.goal_ot_m.setSingleStep(30)
|
self.goal_ot_m.setSingleStep(30)
|
||||||
self.goal_ot_m.setSuffix(" 분")
|
self.goal_ot_m.setSuffix(tr('settings.suffix_minute'))
|
||||||
self.goal_ot_m.setFixedWidth(90)
|
self.goal_ot_m.setFixedWidth(90)
|
||||||
ot_row.addWidget(ot_label)
|
ot_row.addWidget(ot_label)
|
||||||
ot_row.addWidget(self.goal_ot_h)
|
ot_row.addWidget(self.goal_ot_h)
|
||||||
@ -541,18 +541,18 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 일평균 목표
|
# 일평균 목표
|
||||||
avg_row = QHBoxLayout()
|
avg_row = QHBoxLayout()
|
||||||
avg_label = QLabel("일 평균 근무 목표:")
|
avg_label = QLabel(tr('settings.daily_avg_goal'))
|
||||||
avg_label.setFixedWidth(150)
|
avg_label.setFixedWidth(150)
|
||||||
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
||||||
self.goal_avg.setRange(0, 24)
|
self.goal_avg.setRange(0, 24)
|
||||||
self.goal_avg.setSuffix(" 시간")
|
self.goal_avg.setSuffix(tr('settings.suffix_hour'))
|
||||||
self.goal_avg.setFixedWidth(100)
|
self.goal_avg.setFixedWidth(100)
|
||||||
avg_row.addWidget(avg_label)
|
avg_row.addWidget(avg_label)
|
||||||
avg_row.addWidget(self.goal_avg)
|
avg_row.addWidget(self.goal_avg)
|
||||||
avg_row.addStretch()
|
avg_row.addStretch()
|
||||||
layout.addLayout(avg_row)
|
layout.addLayout(avg_row)
|
||||||
|
|
||||||
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
|
note = QLabel(tr('settings.goal_note', fallback=''))
|
||||||
note.setObjectName("note_text")
|
note.setObjectName("note_text")
|
||||||
layout.addWidget(note)
|
layout.addWidget(note)
|
||||||
|
|
||||||
@ -567,40 +567,40 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 연차 개수 + 남은 연차 한 줄
|
# 연차 개수 + 남은 연차 한 줄
|
||||||
top_row = QHBoxLayout()
|
top_row = QHBoxLayout()
|
||||||
annual_leave_label = QLabel("연간 연차:")
|
annual_leave_label = QLabel(tr('settings.annual_leave'))
|
||||||
annual_leave_label.setFixedWidth(70)
|
annual_leave_label.setFixedWidth(70)
|
||||||
self.annual_leave_days = QSpinBox()
|
self.annual_leave_days = QSpinBox()
|
||||||
self.annual_leave_days.setRange(0, 30)
|
self.annual_leave_days.setRange(0, 30)
|
||||||
self.annual_leave_days.setValue(15)
|
self.annual_leave_days.setValue(15)
|
||||||
self.annual_leave_days.setSuffix(" 일")
|
self.annual_leave_days.setSuffix(tr('label.unit_day'))
|
||||||
self.annual_leave_days.setFixedWidth(100)
|
self.annual_leave_days.setFixedWidth(100)
|
||||||
top_row.addWidget(annual_leave_label)
|
top_row.addWidget(annual_leave_label)
|
||||||
top_row.addWidget(self.annual_leave_days)
|
top_row.addWidget(self.annual_leave_days)
|
||||||
top_row.addStretch()
|
top_row.addStretch()
|
||||||
|
|
||||||
self.remaining_leave_label = QLabel("남은 연차: 계산 중...")
|
self.remaining_leave_label = QLabel(tr('settings.remaining_leave'))
|
||||||
self.remaining_leave_label.setObjectName("badge_leave")
|
self.remaining_leave_label.setObjectName("badge_leave")
|
||||||
top_row.addWidget(self.remaining_leave_label)
|
top_row.addWidget(self.remaining_leave_label)
|
||||||
layout.addLayout(top_row)
|
layout.addLayout(top_row)
|
||||||
|
|
||||||
# 기존 사용 연차 설정
|
# 기존 사용 연차 설정
|
||||||
used_leave_layout = QHBoxLayout()
|
used_leave_layout = QHBoxLayout()
|
||||||
used_leave_label = QLabel("기존 사용:")
|
used_leave_label = QLabel(tr('settings.used_leave'))
|
||||||
used_leave_label.setFixedWidth(70)
|
used_leave_label.setFixedWidth(70)
|
||||||
self.used_leave_hours = QSpinBox()
|
self.used_leave_hours = QSpinBox()
|
||||||
self.used_leave_hours.setRange(0, 200)
|
self.used_leave_hours.setRange(0, 200)
|
||||||
self.used_leave_hours.setValue(0)
|
self.used_leave_hours.setValue(0)
|
||||||
self.used_leave_hours.setSuffix(" 시간")
|
self.used_leave_hours.setSuffix(tr('settings.suffix_hour'))
|
||||||
self.used_leave_hours.setFixedWidth(110)
|
self.used_leave_hours.setFixedWidth(110)
|
||||||
|
|
||||||
self.used_leave_mins = QSpinBox()
|
self.used_leave_mins = QSpinBox()
|
||||||
self.used_leave_mins.setRange(0, 59)
|
self.used_leave_mins.setRange(0, 59)
|
||||||
self.used_leave_mins.setValue(0)
|
self.used_leave_mins.setValue(0)
|
||||||
self.used_leave_mins.setSuffix(" 분")
|
self.used_leave_mins.setSuffix(tr('settings.suffix_minute'))
|
||||||
self.used_leave_mins.setSingleStep(30)
|
self.used_leave_mins.setSingleStep(30)
|
||||||
self.used_leave_mins.setFixedWidth(100)
|
self.used_leave_mins.setFixedWidth(100)
|
||||||
|
|
||||||
apply_used_leave_btn = QPushButton("적용")
|
apply_used_leave_btn = QPushButton(tr('btn.apply'))
|
||||||
apply_used_leave_btn.setObjectName("btn_small")
|
apply_used_leave_btn.setObjectName("btn_small")
|
||||||
apply_used_leave_btn.setFixedWidth(50)
|
apply_used_leave_btn.setFixedWidth(50)
|
||||||
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
|
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
|
||||||
@ -612,7 +612,7 @@ class SettingsView(QDialog):
|
|||||||
used_leave_layout.addStretch()
|
used_leave_layout.addStretch()
|
||||||
layout.addLayout(used_leave_layout)
|
layout.addLayout(used_leave_layout)
|
||||||
|
|
||||||
used_leave_note = QLabel("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)")
|
used_leave_note = QLabel(tr('settings.used_leave_note', fallback=''))
|
||||||
used_leave_note.setObjectName("note_text")
|
used_leave_note.setObjectName("note_text")
|
||||||
layout.addWidget(used_leave_note)
|
layout.addWidget(used_leave_note)
|
||||||
|
|
||||||
@ -627,33 +627,33 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# 공휴일 목록 + 버튼 한 줄
|
# 공휴일 목록 + 버튼 한 줄
|
||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
holiday_list_label = QLabel("등록:")
|
holiday_list_label = QLabel(tr('settings.registered'))
|
||||||
button_layout.addWidget(holiday_list_label)
|
button_layout.addWidget(holiday_list_label)
|
||||||
|
|
||||||
self.holiday_count_label = QLabel("0개")
|
self.holiday_count_label = QLabel(tr('settings.holiday_count', count=0, year=datetime.now().year))
|
||||||
self.holiday_count_label.setObjectName("info_text")
|
self.holiday_count_label.setObjectName("info_text")
|
||||||
button_layout.addWidget(self.holiday_count_label)
|
button_layout.addWidget(self.holiday_count_label)
|
||||||
button_layout.addStretch()
|
button_layout.addStretch()
|
||||||
|
|
||||||
add_korean_btn = QPushButton("한국 공휴일 (자동)")
|
add_korean_btn = QPushButton(tr('settings.add_korean_holidays'))
|
||||||
add_korean_btn.setObjectName("btn_small")
|
add_korean_btn.setObjectName("btn_small")
|
||||||
add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록")
|
add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip'))
|
||||||
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
|
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
|
||||||
button_layout.addWidget(add_korean_btn)
|
button_layout.addWidget(add_korean_btn)
|
||||||
|
|
||||||
add_custom_btn = QPushButton("추가")
|
add_custom_btn = QPushButton(tr('btn.add'))
|
||||||
add_custom_btn.setObjectName("btn_small")
|
add_custom_btn.setObjectName("btn_small")
|
||||||
add_custom_btn.clicked.connect(self.add_custom_holiday)
|
add_custom_btn.clicked.connect(self.add_custom_holiday)
|
||||||
button_layout.addWidget(add_custom_btn)
|
button_layout.addWidget(add_custom_btn)
|
||||||
|
|
||||||
view_holidays_btn = QPushButton("목록")
|
view_holidays_btn = QPushButton(tr('settings.list'))
|
||||||
view_holidays_btn.setObjectName("btn_small")
|
view_holidays_btn.setObjectName("btn_small")
|
||||||
view_holidays_btn.clicked.connect(self.view_holidays)
|
view_holidays_btn.clicked.connect(self.view_holidays)
|
||||||
button_layout.addWidget(view_holidays_btn)
|
button_layout.addWidget(view_holidays_btn)
|
||||||
|
|
||||||
layout.addLayout(button_layout)
|
layout.addLayout(button_layout)
|
||||||
|
|
||||||
holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다")
|
holiday_note = QLabel(tr('settings.holiday_note', fallback=''))
|
||||||
holiday_note.setObjectName("note_text")
|
holiday_note.setObjectName("note_text")
|
||||||
layout.addWidget(holiday_note)
|
layout.addWidget(holiday_note)
|
||||||
|
|
||||||
@ -668,7 +668,7 @@ class SettingsView(QDialog):
|
|||||||
"""공휴일 개수 표시 업데이트"""
|
"""공휴일 개수 표시 업데이트"""
|
||||||
current_year = datetime.now().year
|
current_year = datetime.now().year
|
||||||
holidays = self.db.get_holidays_by_year(current_year)
|
holidays = self.db.get_holidays_by_year(current_year)
|
||||||
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
self.holiday_count_label.setText(tr('settings.holiday_count', count=len(holidays), year=current_year))
|
||||||
|
|
||||||
def add_korean_holidays_auto(self):
|
def add_korean_holidays_auto(self):
|
||||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||||
@ -680,19 +680,13 @@ class SettingsView(QDialog):
|
|||||||
current_year = now.year
|
current_year = now.year
|
||||||
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
||||||
include_next = now.month >= 11
|
include_next = now.month >= 11
|
||||||
target_label = (f"{current_year}년 + {current_year + 1}년"
|
target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1)
|
||||||
if include_next else f"{current_year}년")
|
if include_next else tr('settings.korean_holidays_years_label_single', year=current_year))
|
||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"한국 공휴일 자동 추가",
|
tr('settings.korean_holidays_title'),
|
||||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
tr('settings.korean_holidays_body', years=target_label),
|
||||||
"포함:\n"
|
|
||||||
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
|
||||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
|
||||||
"• 정부 지정 대체·임시공휴일\n\n"
|
|
||||||
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
|
||||||
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.Yes:
|
if reply != QMessageBox.Yes:
|
||||||
@ -707,18 +701,16 @@ class SettingsView(QDialog):
|
|||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"패키지 미설치",
|
tr('settings.package_not_installed'),
|
||||||
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n"
|
tr('settings.package_fallback_body', hint=tr('settings.package_install_hint'))
|
||||||
"음력/임시공휴일 자동 등록을 원하시면:\n"
|
|
||||||
" pip install holidays"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"추가 완료",
|
tr('settings.add_done'),
|
||||||
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다."
|
tr('settings.korean_holidays_added', year=current_year, count=added)
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_custom_holiday(self):
|
def add_custom_holiday(self):
|
||||||
@ -729,8 +721,8 @@ class SettingsView(QDialog):
|
|||||||
today = datetime.now().date().isoformat()
|
today = datetime.now().date().isoformat()
|
||||||
date_str, ok = QInputDialog.getText(
|
date_str, ok = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
"공휴일 추가",
|
tr('settings.holiday_add_title'),
|
||||||
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):",
|
tr('settings.holiday_date_prompt'),
|
||||||
QLineEdit.Normal,
|
QLineEdit.Normal,
|
||||||
today
|
today
|
||||||
)
|
)
|
||||||
@ -744,16 +736,16 @@ class SettingsView(QDialog):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
"입력 오류",
|
tr('msg.input_error.title'),
|
||||||
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)"
|
tr('msg.input_error.date_format')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 공휴일 이름 입력
|
# 공휴일 이름 입력
|
||||||
name, ok = QInputDialog.getText(
|
name, ok = QInputDialog.getText(
|
||||||
self,
|
self,
|
||||||
"공휴일 추가",
|
tr('settings.holiday_add_title'),
|
||||||
"공휴일 이름을 입력하세요:",
|
tr('settings.holiday_name_prompt'),
|
||||||
QLineEdit.Normal,
|
QLineEdit.Normal,
|
||||||
""
|
""
|
||||||
)
|
)
|
||||||
@ -767,8 +759,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"추가 완료",
|
tr('settings.add_done'),
|
||||||
f"공휴일이 추가되었습니다.\n{date_str}: {name}"
|
tr('settings.holiday_added', date=date_str, name=name)
|
||||||
)
|
)
|
||||||
|
|
||||||
def view_holidays(self):
|
def view_holidays(self):
|
||||||
@ -779,26 +771,28 @@ class SettingsView(QDialog):
|
|||||||
if not holidays:
|
if not holidays:
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"공휴일 목록",
|
tr('settings.holiday_list_title'),
|
||||||
f"{current_year}년에 등록된 공휴일이 없습니다."
|
tr('stats.no_data')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 목록 생성
|
# 목록 생성
|
||||||
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n"
|
holiday_list = tr('settings.holiday_list_header', year=current_year)
|
||||||
for h in holidays:
|
for h in holidays:
|
||||||
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
||||||
weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()]
|
weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}")
|
||||||
recurring = " (매년)" if h['is_recurring'] else ""
|
recurring = tr('label.recurring_yearly') if h['is_recurring'] else ""
|
||||||
holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n"
|
holiday_list += tr('settings.holiday_list_item',
|
||||||
|
date=h['date'], weekday=weekday,
|
||||||
|
name=h['name'], recurring=recurring)
|
||||||
|
|
||||||
holiday_list += f"\n총 {len(holidays)}개"
|
holiday_list += '\n' + tr('settings.holiday_total', count=len(holidays))
|
||||||
|
|
||||||
# 삭제 옵션 제공
|
# 삭제 옵션 제공
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"공휴일 목록",
|
tr('settings.holiday_list_title'),
|
||||||
holiday_list + "\n\n공휴일을 삭제하시겠습니까?",
|
holiday_list + tr('settings.holiday_delete_confirm'),
|
||||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -819,8 +813,8 @@ class SettingsView(QDialog):
|
|||||||
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
||||||
item, ok = QInputDialog.getItem(
|
item, ok = QInputDialog.getItem(
|
||||||
self,
|
self,
|
||||||
"공휴일 삭제",
|
tr('settings.holiday_delete_title'),
|
||||||
"삭제할 공휴일을 선택하세요:",
|
tr('settings.holiday_delete_prompt'),
|
||||||
items,
|
items,
|
||||||
0,
|
0,
|
||||||
False
|
False
|
||||||
@ -830,7 +824,7 @@ class SettingsView(QDialog):
|
|||||||
date_str = item.split(":")[0]
|
date_str = item.split(":")[0]
|
||||||
self.db.delete_holiday_by_date(date_str)
|
self.db.delete_holiday_by_date(date_str)
|
||||||
self.update_holiday_count()
|
self.update_holiday_count()
|
||||||
QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.")
|
QMessageBox.information(self, tr('settings.delete_done'), tr('settings.holiday_deleted', item=item))
|
||||||
|
|
||||||
def create_data_group(self) -> QGroupBox:
|
def create_data_group(self) -> QGroupBox:
|
||||||
"""데이터 관리 그룹"""
|
"""데이터 관리 그룹"""
|
||||||
@ -841,22 +835,22 @@ class SettingsView(QDialog):
|
|||||||
# CSV 내보내기 버튼들 한 줄
|
# CSV 내보내기 버튼들 한 줄
|
||||||
export_layout = QHBoxLayout()
|
export_layout = QHBoxLayout()
|
||||||
|
|
||||||
export_work_btn = QPushButton("근무기록")
|
export_work_btn = QPushButton(tr('settings.export_work'))
|
||||||
export_work_btn.setObjectName("btn_small")
|
export_work_btn.setObjectName("btn_small")
|
||||||
export_work_btn.clicked.connect(self.export_work_records)
|
export_work_btn.clicked.connect(self.export_work_records)
|
||||||
export_layout.addWidget(export_work_btn)
|
export_layout.addWidget(export_work_btn)
|
||||||
|
|
||||||
export_overtime_btn = QPushButton("연장근무")
|
export_overtime_btn = QPushButton(tr('settings.export_overtime'))
|
||||||
export_overtime_btn.setObjectName("btn_small")
|
export_overtime_btn.setObjectName("btn_small")
|
||||||
export_overtime_btn.clicked.connect(self.export_overtime_summary)
|
export_overtime_btn.clicked.connect(self.export_overtime_summary)
|
||||||
export_layout.addWidget(export_overtime_btn)
|
export_layout.addWidget(export_overtime_btn)
|
||||||
|
|
||||||
monthly_btn = QPushButton("월간 요약")
|
monthly_btn = QPushButton(tr('settings.export_monthly'))
|
||||||
monthly_btn.setObjectName("btn_small")
|
monthly_btn.setObjectName("btn_small")
|
||||||
monthly_btn.clicked.connect(self.export_monthly_summary)
|
monthly_btn.clicked.connect(self.export_monthly_summary)
|
||||||
export_layout.addWidget(monthly_btn)
|
export_layout.addWidget(monthly_btn)
|
||||||
|
|
||||||
export_label = QLabel("CSV 내보내기")
|
export_label = QLabel(tr('settings.export_csv'))
|
||||||
export_label.setObjectName("note_text")
|
export_label.setObjectName("note_text")
|
||||||
export_layout.addWidget(export_label)
|
export_layout.addWidget(export_label)
|
||||||
export_layout.addStretch()
|
export_layout.addStretch()
|
||||||
@ -865,12 +859,12 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# CSV 가져오기
|
# CSV 가져오기
|
||||||
import_layout = QHBoxLayout()
|
import_layout = QHBoxLayout()
|
||||||
import_btn = QPushButton("📥 CSV 가져오기")
|
import_btn = QPushButton(tr('settings.import_csv'))
|
||||||
import_btn.setObjectName("btn_small")
|
import_btn.setObjectName("btn_small")
|
||||||
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
import_btn.setToolTip(tr('settings.import_tooltip'))
|
||||||
import_btn.clicked.connect(self._import_csv)
|
import_btn.clicked.connect(self._import_csv)
|
||||||
import_layout.addWidget(import_btn)
|
import_layout.addWidget(import_btn)
|
||||||
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
|
import_label = QLabel(tr('settings.import_format'))
|
||||||
import_label.setObjectName("note_text")
|
import_label.setObjectName("note_text")
|
||||||
import_layout.addWidget(import_label)
|
import_layout.addWidget(import_label)
|
||||||
import_layout.addStretch()
|
import_layout.addStretch()
|
||||||
@ -878,14 +872,14 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# DB 경로 설정 (클라우드 동기화 가능)
|
# DB 경로 설정 (클라우드 동기화 가능)
|
||||||
db_path_layout = QHBoxLayout()
|
db_path_layout = QHBoxLayout()
|
||||||
db_path_label = QLabel("DB 경로:")
|
db_path_label = QLabel(tr('settings.db_path_label'))
|
||||||
db_path_label.setFixedWidth(60)
|
db_path_label.setFixedWidth(60)
|
||||||
self.db_path_edit = QLineEdit()
|
self.db_path_edit = QLineEdit()
|
||||||
self.db_path_edit.setReadOnly(True)
|
self.db_path_edit.setReadOnly(True)
|
||||||
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
|
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
|
||||||
db_path_btn = QPushButton("변경...")
|
db_path_btn = QPushButton(tr('settings.change'))
|
||||||
db_path_btn.setObjectName("btn_small")
|
db_path_btn.setObjectName("btn_small")
|
||||||
db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.")
|
db_path_btn.setToolTip(tr('settings.db_path_tooltip'))
|
||||||
db_path_btn.clicked.connect(self._change_db_path)
|
db_path_btn.clicked.connect(self._change_db_path)
|
||||||
db_path_layout.addWidget(db_path_label)
|
db_path_layout.addWidget(db_path_label)
|
||||||
db_path_layout.addWidget(self.db_path_edit, 1)
|
db_path_layout.addWidget(self.db_path_edit, 1)
|
||||||
@ -893,39 +887,35 @@ class SettingsView(QDialog):
|
|||||||
layout.addLayout(db_path_layout)
|
layout.addLayout(db_path_layout)
|
||||||
|
|
||||||
# 자동 외출 (화면 잠금 시)
|
# 자동 외출 (화면 잠금 시)
|
||||||
self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀")
|
self.auto_break_check = QCheckBox(tr('settings.auto_break_lock'))
|
||||||
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback=''))
|
||||||
layout.addWidget(self.auto_break_check)
|
layout.addWidget(self.auto_break_check)
|
||||||
|
|
||||||
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
||||||
feedback_layout = QHBoxLayout()
|
feedback_layout = QHBoxLayout()
|
||||||
feedback_label = QLabel("Gitea 피드백:")
|
feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback=''))
|
||||||
feedback_label.setFixedWidth(80)
|
feedback_label.setFixedWidth(80)
|
||||||
self.gitea_token_edit = QLineEdit()
|
self.gitea_token_edit = QLineEdit()
|
||||||
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
||||||
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
|
self.gitea_token_edit.setPlaceholderText(tr('settings.gitea_token_placeholder', fallback=''))
|
||||||
feedback_layout.addWidget(feedback_label)
|
feedback_layout.addWidget(feedback_label)
|
||||||
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
||||||
layout.addLayout(feedback_layout)
|
layout.addLayout(feedback_layout)
|
||||||
|
|
||||||
self.gitea_feedback_enabled_check = QCheckBox(
|
self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback'))
|
||||||
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
|
||||||
)
|
|
||||||
layout.addWidget(self.gitea_feedback_enabled_check)
|
layout.addWidget(self.gitea_feedback_enabled_check)
|
||||||
|
|
||||||
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
||||||
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock'))
|
||||||
self.clock_in_unlock_check.setToolTip(
|
self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback=''))
|
||||||
"PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다."
|
|
||||||
)
|
|
||||||
layout.addWidget(self.clock_in_unlock_check)
|
layout.addWidget(self.clock_in_unlock_check)
|
||||||
|
|
||||||
# 업데이트 확인
|
# 업데이트 확인
|
||||||
update_layout = QHBoxLayout()
|
update_layout = QHBoxLayout()
|
||||||
from core.version import __version__
|
from core.version import __version__
|
||||||
version_label = QLabel(f"버전: v{__version__}")
|
version_label = QLabel(tr('settings.version', version=__version__))
|
||||||
version_label.setObjectName("note_text")
|
version_label.setObjectName("note_text")
|
||||||
update_btn = QPushButton("업데이트 확인 (F5)")
|
update_btn = QPushButton(tr('settings.check_update'))
|
||||||
update_btn.setObjectName("btn_small")
|
update_btn.setObjectName("btn_small")
|
||||||
update_btn.clicked.connect(self._check_updates)
|
update_btn.clicked.connect(self._check_updates)
|
||||||
update_layout.addWidget(version_label)
|
update_layout.addWidget(version_label)
|
||||||
@ -941,7 +931,7 @@ class SettingsView(QDialog):
|
|||||||
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
||||||
new_path, _ = QFileDialog.getSaveFileName(
|
new_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
"데이터베이스 파일 선택",
|
tr('settings.select_db'),
|
||||||
current,
|
current,
|
||||||
"SQLite Database (*.db)"
|
"SQLite Database (*.db)"
|
||||||
)
|
)
|
||||||
@ -952,10 +942,8 @@ class SettingsView(QDialog):
|
|||||||
self.db_path_edit.setText(new_path)
|
self.db_path_edit.setText(new_path)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"DB 경로 변경",
|
tr('settings.db_path_label')[:-1],
|
||||||
f"새 경로가 저장되었습니다:\n{new_path}\n\n"
|
tr('settings.db_path_saved', path=new_path)
|
||||||
"기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n"
|
|
||||||
"프로그램을 재시작하세요."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def load_settings(self):
|
def load_settings(self):
|
||||||
@ -1068,7 +1056,7 @@ class SettingsView(QDialog):
|
|||||||
self.time_format_combo.setCurrentIndex(index)
|
self.time_format_combo.setCurrentIndex(index)
|
||||||
|
|
||||||
# 테마
|
# 테마
|
||||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
|
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
|
||||||
|
|
||||||
# 언어 선택 적용
|
# 언어 선택 적용
|
||||||
if hasattr(self, 'language_combo'):
|
if hasattr(self, 'language_combo'):
|
||||||
@ -1105,7 +1093,7 @@ class SettingsView(QDialog):
|
|||||||
def _import_csv(self):
|
def _import_csv(self):
|
||||||
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
self, "CSV 가져오기",
|
self, tr('settings.import_csv'),
|
||||||
os.path.expanduser("~"),
|
os.path.expanduser("~"),
|
||||||
"CSV files (*.csv);;All files (*.*)",
|
"CSV files (*.csv);;All files (*.*)",
|
||||||
)
|
)
|
||||||
@ -1115,19 +1103,17 @@ class SettingsView(QDialog):
|
|||||||
from utils.csv_importer import parse_csv, import_records
|
from utils.csv_importer import parse_csv, import_records
|
||||||
rows = parse_csv(path)
|
rows = parse_csv(path)
|
||||||
except (FileNotFoundError, ValueError) as e:
|
except (FileNotFoundError, ValueError) as e:
|
||||||
QMessageBox.critical(self, "파싱 실패", str(e))
|
QMessageBox.critical(self, tr('settings.parse_failed'), str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body'))
|
||||||
return
|
return
|
||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"충돌 처리",
|
tr('settings.conflict_title'),
|
||||||
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'),
|
||||||
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
|
||||||
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
|
||||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Cancel:
|
if reply == QMessageBox.Cancel:
|
||||||
@ -1137,12 +1123,12 @@ class SettingsView(QDialog):
|
|||||||
try:
|
try:
|
||||||
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "가져오기 실패", str(e))
|
QMessageBox.critical(self, tr('settings.import_failed'), str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self, "완료",
|
self, tr('settings.import_complete'),
|
||||||
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
tr('settings.import_result', added=added, updated=updated, skipped=skipped)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_updates(self):
|
def _check_updates(self):
|
||||||
@ -1235,8 +1221,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"저장 완료",
|
tr('settings.save_done'),
|
||||||
"설정이 저장되었습니다."
|
tr('settings.save_done_body')
|
||||||
)
|
)
|
||||||
|
|
||||||
# 부모 윈도우에 설정 변경 알림
|
# 부모 윈도우에 설정 변경 알림
|
||||||
@ -1252,9 +1238,8 @@ class SettingsView(QDialog):
|
|||||||
set_language_and_retranslate(new_lang)
|
set_language_and_retranslate(new_lang)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"재시작 / Restart",
|
tr('settings.restart_title'),
|
||||||
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
|
tr('settings.restart_body'),
|
||||||
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
|
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
@ -1286,10 +1271,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"기존 연장근무 설정",
|
tr('settings.initial_overtime_title'),
|
||||||
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
||||||
f"변경할 값: {hours}시간 {mins}분\n\n"
|
|
||||||
f"기존 연장근무 시간을 변경하시겠습니까?",
|
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1299,8 +1282,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"설정 완료",
|
tr('settings.initial_overtime_done'),
|
||||||
f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다."
|
tr('settings.initial_overtime_done_body', hours=hours, mins=mins)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 부모 윈도우 잔액 업데이트
|
# 부모 윈도우 잔액 업데이트
|
||||||
@ -1312,8 +1295,8 @@ class SettingsView(QDialog):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
"오류",
|
tr('settings.error'),
|
||||||
f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}"
|
tr('settings.initial_overtime_error', error=str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_overtime_balance_display(self):
|
def update_overtime_balance_display(self):
|
||||||
@ -1321,7 +1304,7 @@ class SettingsView(QDialog):
|
|||||||
balance_minutes = self.db.get_total_overtime_balance()
|
balance_minutes = self.db.get_total_overtime_balance()
|
||||||
hours = balance_minutes // 60
|
hours = balance_minutes // 60
|
||||||
minutes = balance_minutes % 60
|
minutes = balance_minutes % 60
|
||||||
self.current_overtime_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance_minutes}분)")
|
self.current_overtime_label.setText(tr('settings.current_overtime_balance', hours=hours, minutes=minutes, balance=balance_minutes))
|
||||||
|
|
||||||
def update_remaining_leave(self):
|
def update_remaining_leave(self):
|
||||||
"""남은 연차 계산 및 표시"""
|
"""남은 연차 계산 및 표시"""
|
||||||
@ -1347,7 +1330,7 @@ class SettingsView(QDialog):
|
|||||||
remaining = total_annual - total_used
|
remaining = total_annual - total_used
|
||||||
|
|
||||||
self.remaining_leave_label.setText(
|
self.remaining_leave_label.setText(
|
||||||
f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)"
|
tr('settings.remaining_leave_fmt', remaining=remaining, total=total_annual, used=total_used)
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_work_records(self):
|
def export_work_records(self):
|
||||||
@ -1358,14 +1341,14 @@ class SettingsView(QDialog):
|
|||||||
records = stats.get('records', [])
|
records = stats.get('records', [])
|
||||||
|
|
||||||
if not records:
|
if not records:
|
||||||
QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.")
|
QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records'))
|
||||||
return
|
return
|
||||||
|
|
||||||
# 파일 경로 선택
|
# 파일 경로 선택
|
||||||
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
|
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
"근무 기록 저장",
|
tr('settings.save_work_title'),
|
||||||
default_filename,
|
default_filename,
|
||||||
"CSV Files (*.csv)"
|
"CSV Files (*.csv)"
|
||||||
)
|
)
|
||||||
@ -1375,17 +1358,17 @@ class SettingsView(QDialog):
|
|||||||
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"내보내기 완료",
|
tr('settings.export_done'),
|
||||||
f"근무 기록이 저장되었습니다.\n{saved_path}"
|
tr('settings.work_exported', path=saved_path)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
||||||
|
|
||||||
def export_overtime_summary(self):
|
def export_overtime_summary(self):
|
||||||
"""연장근무 내역 내보내기"""
|
"""연장근무 내역 내보내기"""
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
"연장근무 내역 저장",
|
tr('settings.save_ot_title'),
|
||||||
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
||||||
"CSV Files (*.csv)"
|
"CSV Files (*.csv)"
|
||||||
)
|
)
|
||||||
@ -1395,18 +1378,18 @@ class SettingsView(QDialog):
|
|||||||
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
|
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"내보내기 완료",
|
tr('settings.export_done'),
|
||||||
f"연장근무 내역이 저장되었습니다.\n{saved_path}"
|
tr('settings.ot_exported', path=saved_path)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
||||||
|
|
||||||
def export_monthly_summary(self):
|
def export_monthly_summary(self):
|
||||||
"""월간 요약 내보내기"""
|
"""월간 요약 내보내기"""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
filename, _ = QFileDialog.getSaveFileName(
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
"월간 요약 저장",
|
tr('settings.save_monthly_title'),
|
||||||
f"monthly_summary_{now.year}{now.month:02d}.csv",
|
f"monthly_summary_{now.year}{now.month:02d}.csv",
|
||||||
"CSV Files (*.csv)"
|
"CSV Files (*.csv)"
|
||||||
)
|
)
|
||||||
@ -1416,11 +1399,11 @@ class SettingsView(QDialog):
|
|||||||
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
|
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"내보내기 완료",
|
tr('settings.export_done'),
|
||||||
f"월간 요약이 저장되었습니다.\n{saved_path}"
|
tr('settings.monthly_exported', path=saved_path)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
||||||
|
|
||||||
def apply_used_leave(self):
|
def apply_used_leave(self):
|
||||||
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
|
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
|
||||||
@ -1435,10 +1418,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"기존 사용 연차 설정",
|
tr('settings.initial_leave_title'),
|
||||||
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
||||||
f"변경할 값: {hours}시간 {mins}분\n\n"
|
|
||||||
f"기존 사용 연차를 변경하시겠습니까?",
|
|
||||||
QMessageBox.Yes | QMessageBox.No
|
QMessageBox.Yes | QMessageBox.No
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1448,8 +1429,8 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
"설정 완료",
|
tr('settings.initial_leave_done'),
|
||||||
f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다."
|
tr('settings.initial_leave_done_body', hours=hours, mins=mins)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 남은 연차 재계산
|
# 남은 연차 재계산
|
||||||
|
|||||||
100
ui/stats_view.py
100
ui/stats_view.py
@ -15,7 +15,7 @@ from core.i18n import tr
|
|||||||
from ui.styles import apply_dark_titlebar
|
from ui.styles import apply_dark_titlebar
|
||||||
from ui.dark_components import (
|
from ui.dark_components import (
|
||||||
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||||
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM,
|
transparent_label, tc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ class StatsView(QDialog):
|
|||||||
self.db = db if db else Database()
|
self.db = db if db else Database()
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.load_stats()
|
self.load_stats()
|
||||||
apply_dark_titlebar(self, dark=True)
|
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""UI 초기화"""
|
"""UI 초기화"""
|
||||||
@ -42,9 +42,9 @@ class StatsView(QDialog):
|
|||||||
layout.setContentsMargins(20, 16, 20, 14)
|
layout.setContentsMargins(20, 16, 20, 14)
|
||||||
|
|
||||||
# 다크 톤 타이틀
|
# 다크 톤 타이틀
|
||||||
title = QLabel(f"📊 {tr('stats.title')}")
|
title = QLabel(f"{tr('stats.title')}")
|
||||||
title.setStyleSheet(
|
title.setStyleSheet(
|
||||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
)
|
)
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
@ -93,14 +93,14 @@ class StatsView(QDialog):
|
|||||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||||
cards_row = QHBoxLayout()
|
cards_row = QHBoxLayout()
|
||||||
cards_row.setSpacing(10)
|
cards_row.setSpacing(10)
|
||||||
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
self.weekly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
||||||
theme='blue', icon='⏱️')
|
theme='blue', icon='clock')
|
||||||
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
self.weekly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_week'),
|
||||||
theme='cyan', icon='📅')
|
theme='cyan', icon='calendar')
|
||||||
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
self.weekly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
||||||
theme='green', icon='📊')
|
theme='green', icon='chart')
|
||||||
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
self.weekly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_week'),
|
||||||
theme='gold', icon='🔥')
|
theme='gold', icon='flame')
|
||||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||||
self.weekly_avg_card, self.weekly_ot_card):
|
self.weekly_avg_card, self.weekly_ot_card):
|
||||||
cards_row.addWidget(c, 1)
|
cards_row.addWidget(c, 1)
|
||||||
@ -109,8 +109,8 @@ class StatsView(QDialog):
|
|||||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.weekly_chart = make_chart_widget(widget)
|
self.weekly_chart = make_chart_widget(widget)
|
||||||
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart,
|
||||||
theme='gray', icon='📈')
|
theme='gray', icon='trending-up')
|
||||||
layout.addWidget(chart_card, 1)
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
@ -127,14 +127,14 @@ class StatsView(QDialog):
|
|||||||
# 카드 4개
|
# 카드 4개
|
||||||
cards_row = QHBoxLayout()
|
cards_row = QHBoxLayout()
|
||||||
cards_row.setSpacing(10)
|
cards_row.setSpacing(10)
|
||||||
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
self.monthly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
||||||
theme='blue', icon='⏱️')
|
theme='blue', icon='clock')
|
||||||
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
self.monthly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_month'),
|
||||||
theme='cyan', icon='📅')
|
theme='cyan', icon='calendar')
|
||||||
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
self.monthly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
||||||
theme='green', icon='📊')
|
theme='green', icon='chart')
|
||||||
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
self.monthly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_month'),
|
||||||
theme='gold', icon='🔥')
|
theme='gold', icon='flame')
|
||||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||||
self.monthly_avg_card, self.monthly_ot_card):
|
self.monthly_avg_card, self.monthly_ot_card):
|
||||||
cards_row.addWidget(c, 1)
|
cards_row.addWidget(c, 1)
|
||||||
@ -143,9 +143,9 @@ class StatsView(QDialog):
|
|||||||
# 추정 급여 (옵션 활성 시)
|
# 추정 급여 (옵션 활성 시)
|
||||||
self.salary_label = QLabel("")
|
self.salary_label = QLabel("")
|
||||||
self.salary_label.setStyleSheet(
|
self.salary_label.setStyleSheet(
|
||||||
f"background: rgba(74, 222, 128, 0.12); "
|
f"background: rgba(81, 207, 102, 0.12); "
|
||||||
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; "
|
f"border: 1px solid {tc('green')}; border-radius: 8px; "
|
||||||
f"color: {ACCENT_GREEN}; font-weight: bold; "
|
f"color: {tc('green')}; font-weight: bold; "
|
||||||
f"padding: 10px 14px; font-size: 11pt;"
|
f"padding: 10px 14px; font-size: 11pt;"
|
||||||
)
|
)
|
||||||
self.salary_label.setVisible(False)
|
self.salary_label.setVisible(False)
|
||||||
@ -159,8 +159,8 @@ class StatsView(QDialog):
|
|||||||
# 월간 차트
|
# 월간 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.monthly_chart = make_chart_widget(widget)
|
self.monthly_chart = make_chart_widget(widget)
|
||||||
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart,
|
||||||
theme='gray', icon='📊')
|
theme='gray', icon='chart')
|
||||||
layout.addWidget(chart_card, 1)
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
@ -179,17 +179,17 @@ class StatsView(QDialog):
|
|||||||
self.pattern_text.setWordWrap(True)
|
self.pattern_text.setWordWrap(True)
|
||||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||||
self.pattern_text.setStyleSheet(
|
self.pattern_text.setStyleSheet(
|
||||||
f"font-size: 11pt; color: {DARK_TEXT}; "
|
f"font-size: 11pt; color: {tc('text')}; "
|
||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
)
|
)
|
||||||
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
layout.addWidget(build_section_card(tr('stats.pattern_insights'), self.pattern_text,
|
||||||
theme='cyan', icon='🔍'))
|
theme='cyan', icon='search'))
|
||||||
|
|
||||||
# 출근 시각 분포 차트
|
# 출근 시각 분포 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.clock_in_chart = make_chart_widget(widget)
|
self.clock_in_chart = make_chart_widget(widget)
|
||||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
|
||||||
theme='gray', icon='⏰'), 1)
|
theme='gray', icon='clock'), 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
@ -225,15 +225,15 @@ class StatsView(QDialog):
|
|||||||
# 주간 통계
|
# 주간 통계
|
||||||
weekly_stats = self.db.get_weekly_stats()
|
weekly_stats = self.db.get_weekly_stats()
|
||||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||||
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
|
self._set_card_value(self.weekly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||||
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}일")
|
self._set_card_value(self.weekly_days_card, tr('stats.value_days', days=weekly_stats.get('work_days', 0)))
|
||||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||||
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간")
|
self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}"))
|
||||||
|
|
||||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||||
overtime_hours = overtime_minutes // 60
|
overtime_hours = overtime_minutes // 60
|
||||||
overtime_mins = overtime_minutes % 60
|
overtime_mins = overtime_minutes % 60
|
||||||
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
|
self._set_card_value(self.weekly_ot_card, tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
||||||
|
|
||||||
# 주간 차트
|
# 주간 차트
|
||||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||||
@ -251,21 +251,21 @@ class StatsView(QDialog):
|
|||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||||
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
|
self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||||
work_days = monthly_stats.get('work_days', 0) or 0
|
work_days = monthly_stats.get('work_days', 0) or 0
|
||||||
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days))
|
||||||
|
|
||||||
if work_days > 0:
|
if work_days > 0:
|
||||||
avg = total_hours / work_days
|
avg = total_hours / work_days
|
||||||
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
|
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}"))
|
||||||
else:
|
else:
|
||||||
self._set_card_value(self.monthly_avg_card, "0시간")
|
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0))
|
||||||
|
|
||||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||||
overtime_hours = overtime_minutes // 60
|
overtime_hours = overtime_minutes // 60
|
||||||
overtime_mins = overtime_minutes % 60
|
overtime_mins = overtime_minutes % 60
|
||||||
self._set_card_value(self.monthly_ot_card,
|
self._set_card_value(self.monthly_ot_card,
|
||||||
f"{overtime_hours}시간 {overtime_mins}분")
|
tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
||||||
|
|
||||||
# 월간 차트 (요일별 평균)
|
# 월간 차트 (요일별 평균)
|
||||||
if hasattr(self, 'monthly_chart'):
|
if hasattr(self, 'monthly_chart'):
|
||||||
@ -300,8 +300,10 @@ class StatsView(QDialog):
|
|||||||
from core.salary import estimate_pay, format_won
|
from core.salary import estimate_pay, format_won
|
||||||
result = estimate_pay(records, wage, rate)
|
result = estimate_pay(records, wage, rate)
|
||||||
self.salary_label.setText(
|
self.salary_label.setText(
|
||||||
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
tr('stats.salary_estimate',
|
||||||
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
total=format_won(result['total']),
|
||||||
|
base=format_won(result['base']),
|
||||||
|
overtime=format_won(result['overtime']))
|
||||||
)
|
)
|
||||||
self.salary_label.setVisible(True)
|
self.salary_label.setVisible(True)
|
||||||
|
|
||||||
@ -334,7 +336,7 @@ class StatsView(QDialog):
|
|||||||
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
||||||
avg_hour = int(avg_minutes // 60)
|
avg_hour = int(avg_minutes // 60)
|
||||||
avg_min = int(avg_minutes % 60)
|
avg_min = int(avg_minutes % 60)
|
||||||
insights.append(f"📌 평균 출근시간: {avg_hour:02d}:{avg_min:02d}")
|
insights.append(tr('stats.avg_clock_in', time=f"{avg_hour:02d}:{avg_min:02d}"))
|
||||||
|
|
||||||
# 연장근무 빈도
|
# 연장근무 빈도
|
||||||
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
||||||
@ -342,14 +344,14 @@ class StatsView(QDialog):
|
|||||||
|
|
||||||
if total_days > 0:
|
if total_days > 0:
|
||||||
overtime_rate = (overtime_days / total_days) * 100
|
overtime_rate = (overtime_days / total_days) * 100
|
||||||
insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)")
|
insights.append(tr('stats.overtime_frequency', rate=f"{overtime_rate:.0f}", days=overtime_days, total=total_days))
|
||||||
|
|
||||||
# 가장 긴 근무일
|
# 가장 긴 근무일
|
||||||
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
||||||
if records_with_hours:
|
if records_with_hours:
|
||||||
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
||||||
if longest_work.get('total_hours', 0) > 0:
|
if longest_work.get('total_hours', 0) > 0:
|
||||||
insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)")
|
insights.append(tr('stats.longest_work', date=longest_work['date'], hours=f"{longest_work['total_hours']:.1f}"))
|
||||||
|
|
||||||
# 건강 경고
|
# 건강 경고
|
||||||
recent_records = records[-7:] # 최근 7일
|
recent_records = records[-7:] # 최근 7일
|
||||||
@ -364,15 +366,15 @@ class StatsView(QDialog):
|
|||||||
consecutive_overtime = 0
|
consecutive_overtime = 0
|
||||||
|
|
||||||
if max_consecutive >= 3:
|
if max_consecutive >= 3:
|
||||||
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!")
|
insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive))
|
||||||
|
|
||||||
# 주 52시간 체크
|
# 주 52시간 체크
|
||||||
if len(recent_records) >= 7:
|
if len(recent_records) >= 7:
|
||||||
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
||||||
if week_total > 52:
|
if week_total > 52:
|
||||||
insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간")
|
insights.append(tr('stats.weekly_52_exceeded', hours=f"{week_total:.1f}"))
|
||||||
|
|
||||||
self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.")
|
self.pattern_text.setText("\n\n".join(insights) if insights else tr('stats.no_pattern_data'))
|
||||||
|
|
||||||
|
|
||||||
# 테스트 코드
|
# 테스트 코드
|
||||||
|
|||||||
282
ui/styles.py
282
ui/styles.py
@ -33,8 +33,8 @@ def _ensure_icons():
|
|||||||
for name, color_hex, points in [
|
for name, color_hex, points in [
|
||||||
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
||||||
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
||||||
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||||
]:
|
]:
|
||||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@ -78,6 +78,9 @@ LIGHT_COLORS = {
|
|||||||
'bg_primary': '#F5F5F7',
|
'bg_primary': '#F5F5F7',
|
||||||
'bg_secondary': '#FFFFFF',
|
'bg_secondary': '#FFFFFF',
|
||||||
'bg_tertiary': '#EDEDF0',
|
'bg_tertiary': '#EDEDF0',
|
||||||
|
# 인터랙션 표면
|
||||||
|
'surface_hover': '#E2E3E7',
|
||||||
|
'surface_pressed': '#D5D6DB',
|
||||||
# 텍스트 계층
|
# 텍스트 계층
|
||||||
'text_primary': '#1A1A2E',
|
'text_primary': '#1A1A2E',
|
||||||
'text_secondary': '#4A4A68',
|
'text_secondary': '#4A4A68',
|
||||||
@ -85,9 +88,15 @@ LIGHT_COLORS = {
|
|||||||
'text_inverse': '#FFFFFF',
|
'text_inverse': '#FFFFFF',
|
||||||
# 액센트
|
# 액센트
|
||||||
'accent_primary': '#3B82F6',
|
'accent_primary': '#3B82F6',
|
||||||
|
'accent_primary_hover': '#2F74EE',
|
||||||
|
'accent_primary_pressed': '#2563EB',
|
||||||
'accent_success': '#10B981',
|
'accent_success': '#10B981',
|
||||||
|
'accent_success_hover': '#0EA372',
|
||||||
|
'accent_success_pressed': '#0C8F63',
|
||||||
'accent_warning': '#F59E0B',
|
'accent_warning': '#F59E0B',
|
||||||
'accent_danger': '#EF4444',
|
'accent_danger': '#EF4444',
|
||||||
|
'accent_danger_hover': '#DC2626',
|
||||||
|
'accent_danger_pressed': '#B91C1C',
|
||||||
# 테두리
|
# 테두리
|
||||||
'border_subtle': '#E5E7EB',
|
'border_subtle': '#E5E7EB',
|
||||||
'border_default': '#D1D5DB',
|
'border_default': '#D1D5DB',
|
||||||
@ -120,40 +129,58 @@ LIGHT_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DARK_COLORS = {
|
DARK_COLORS = {
|
||||||
'bg_primary': '#111118',
|
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
|
||||||
'bg_secondary': '#1C1C2E',
|
'bg_primary': '#1A1B1E', # 앱 배경
|
||||||
'bg_tertiary': '#282842',
|
'bg_secondary': '#25262B', # 카드 / 패널
|
||||||
'text_primary': '#ECECF4',
|
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
|
||||||
'text_secondary': '#B0B0C8',
|
# 인터랙션 표면
|
||||||
'text_tertiary': '#808098',
|
'surface_hover': '#34363D',
|
||||||
|
'surface_pressed': '#3A3D44',
|
||||||
|
# 텍스트 계층
|
||||||
|
'text_primary': '#E9ECEF',
|
||||||
|
'text_secondary': '#909296',
|
||||||
|
'text_tertiary': '#6C6E73',
|
||||||
'text_inverse': '#FFFFFF',
|
'text_inverse': '#FFFFFF',
|
||||||
'accent_primary': '#6B9EFF',
|
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||||
'accent_success': '#4ADE80',
|
'accent_primary': '#4DABF7',
|
||||||
'accent_warning': '#FCD34D',
|
'accent_primary_hover': '#69B6F8',
|
||||||
'accent_danger': '#FB7185',
|
'accent_primary_pressed': '#3D97E0',
|
||||||
'border_subtle': '#32324E',
|
'accent_success': '#51CF66',
|
||||||
'border_default': '#44446A',
|
'accent_success_hover': '#69DB7C',
|
||||||
'border_focus': '#6B9EFF',
|
'accent_success_pressed': '#43B85A',
|
||||||
'badge_overtime_bg': '#3D2008',
|
'accent_warning': '#FAB005',
|
||||||
'badge_overtime_text': '#FDE68A',
|
'accent_danger': '#FA5252',
|
||||||
'badge_leave_bg': '#1E2D5F',
|
'accent_danger_hover': '#FF6B6B',
|
||||||
'badge_leave_text': '#A5D0FE',
|
'accent_danger_pressed': '#E64545',
|
||||||
'badge_total_bg': '#0A3324',
|
# 테두리
|
||||||
'badge_total_text': '#86EFAC',
|
'border_subtle': '#2C2E33',
|
||||||
'progress_bg': '#282842',
|
'border_default': '#373A40',
|
||||||
'progress_start': '#6B9EFF',
|
'border_focus': '#4DABF7',
|
||||||
'progress_end': '#4ADE80',
|
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
|
||||||
'status_overtime': '#FB7185',
|
'badge_overtime_bg': '#2C2E33',
|
||||||
'status_warning': '#FCD34D',
|
'badge_overtime_text': '#FAB005',
|
||||||
'status_normal': '#4ADE80',
|
'badge_leave_bg': '#2C2E33',
|
||||||
'status_break_active': '#FB7185',
|
'badge_leave_text': '#4DABF7',
|
||||||
'status_break_idle': '#808098',
|
'badge_total_bg': '#2C2E33',
|
||||||
'cal_normal': '#1A4D3A',
|
'badge_total_text': '#51CF66',
|
||||||
'cal_overtime': '#5C1A1A',
|
# 프로그레스 — 단일 accent 솔리드
|
||||||
'cal_incomplete': '#5C3A10',
|
'progress_bg': '#2C2E33',
|
||||||
'scrollbar_bg': '#111118',
|
'progress_start': '#4DABF7',
|
||||||
'scrollbar_handle': '#44446A',
|
'progress_end': '#4DABF7',
|
||||||
'scrollbar_hover': '#5A5A88',
|
# 상태 색상 (동적 텍스트 피드백)
|
||||||
|
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
|
||||||
|
'status_warning': '#FAB005',
|
||||||
|
'status_normal': '#51CF66',
|
||||||
|
'status_break_active': '#FA5252',
|
||||||
|
'status_break_idle': '#6C6E73',
|
||||||
|
# 캘린더 날짜 배경 — 미묘한 다크 틴트
|
||||||
|
'cal_normal': '#1E3A2A',
|
||||||
|
'cal_overtime': '#3A2122',
|
||||||
|
'cal_incomplete': '#3A331E',
|
||||||
|
# 스크롤바
|
||||||
|
'scrollbar_bg': '#1A1B1E',
|
||||||
|
'scrollbar_handle': '#373A40',
|
||||||
|
'scrollbar_hover': '#4DABF7',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -192,7 +219,7 @@ QMainWindow, QDialog {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
QWidget {{
|
QWidget {{
|
||||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
}}
|
}}
|
||||||
@ -206,14 +233,14 @@ QWidget#central_widget {{
|
|||||||
════════════════════════════════════════ */
|
════════════════════════════════════════ */
|
||||||
|
|
||||||
QLabel#app_title {{
|
QLabel#app_title {{
|
||||||
font-size: 12pt;
|
font-size: 13pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#date_label {{
|
QLabel#date_label {{
|
||||||
font-size: 9pt;
|
font-size: 9.5pt;
|
||||||
color: {c['text_secondary']};
|
color: {c['text_secondary']};
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}}
|
}}
|
||||||
@ -221,7 +248,7 @@ QLabel#date_label {{
|
|||||||
QLabel#section_title {{
|
QLabel#section_title {{
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_secondary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#field_label {{
|
QLabel#field_label {{
|
||||||
@ -229,29 +256,30 @@ QLabel#field_label {{
|
|||||||
color: {c['text_secondary']};
|
color: {c['text_secondary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||||
QLabel#time_value {{
|
QLabel#time_value {{
|
||||||
font-family: "Consolas", "D2Coding", monospace;
|
font-family: "Consolas", "D2Coding", monospace;
|
||||||
font-size: 11pt;
|
font-size: 15pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||||
QLabel#time_display {{
|
QLabel#time_display {{
|
||||||
font-family: "Consolas", "D2Coding", monospace;
|
font-family: "Consolas", "D2Coding", monospace;
|
||||||
font-size: 22pt;
|
font-size: 30pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
background: {c['bg_secondary']};
|
background: transparent;
|
||||||
border: 1px solid {c['border_subtle']};
|
border: none;
|
||||||
border-radius: 10px;
|
padding: 4px 0;
|
||||||
padding: 10px;
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#expected_time {{
|
QLabel#expected_time {{
|
||||||
font-size: 10pt;
|
font-size: 11.5pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_secondary']};
|
||||||
padding: 4px;
|
padding: 2px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#dialog_title {{
|
QLabel#dialog_title {{
|
||||||
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
|
|||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
background: {c['badge_overtime_bg']};
|
background: {c['badge_overtime_bg']};
|
||||||
color: {c['badge_overtime_text']};
|
color: {c['badge_overtime_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_leave {{
|
QLabel#badge_leave {{
|
||||||
@ -306,7 +334,7 @@ QLabel#badge_leave {{
|
|||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
background: {c['badge_leave_bg']};
|
background: {c['badge_leave_bg']};
|
||||||
color: {c['badge_leave_text']};
|
color: {c['badge_leave_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_total {{
|
QLabel#badge_total {{
|
||||||
@ -317,7 +345,7 @@ QLabel#badge_total {{
|
|||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
background: {c['badge_total_bg']};
|
background: {c['badge_total_bg']};
|
||||||
color: {c['badge_total_text']};
|
color: {c['badge_total_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_balance {{
|
QLabel#badge_balance {{
|
||||||
@ -326,7 +354,7 @@ QLabel#badge_balance {{
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_success {{
|
QLabel#badge_success {{
|
||||||
@ -335,7 +363,7 @@ QLabel#badge_success {{
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: {c['badge_total_bg']};
|
background: {c['badge_total_bg']};
|
||||||
color: {c['badge_total_text']};
|
color: {c['badge_total_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* ════════════════════════════════════════
|
/* ════════════════════════════════════════
|
||||||
@ -355,9 +383,9 @@ QLabel#separator {{
|
|||||||
QGroupBox {{
|
QGroupBox {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
padding-top: 28px;
|
padding-top: 28px;
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
@ -378,52 +406,55 @@ QGroupBox::title {{
|
|||||||
버튼
|
버튼
|
||||||
════════════════════════════════════════ */
|
════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 7px 14px;
|
padding: 8px 14px;
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background: {c['border_default']};
|
background: {c['surface_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:pressed {{
|
QPushButton:pressed {{
|
||||||
background: {c['border_subtle']};
|
background: {c['surface_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:disabled {{
|
QPushButton:disabled {{
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_secondary']};
|
||||||
color: {c['text_tertiary']};
|
color: {c['text_tertiary']};
|
||||||
border-color: {c['border_subtle']};
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:checked {{
|
QPushButton:checked {{
|
||||||
background: {c['accent_primary']};
|
background: {c['accent_primary']};
|
||||||
color: {c['text_inverse']};
|
color: {c['text_inverse']};
|
||||||
border-color: {c['accent_primary']};
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 퇴근 버튼 (primary action) */
|
QPushButton:focus {{
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||||
QPushButton#clock_out_button {{
|
QPushButton#clock_out_button {{
|
||||||
background: {c['accent_success']};
|
background: {c['accent_primary']};
|
||||||
color: {c['text_inverse']};
|
color: {c['text_inverse']};
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 8px;
|
padding: 11px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#clock_out_button:hover {{
|
QPushButton#clock_out_button:hover {{
|
||||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
background: {c['accent_primary_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#clock_out_button:pressed {{
|
QPushButton#clock_out_button:pressed {{
|
||||||
background: {'#0C8F63' if not is_dark else '#28A87A'};
|
background: {c['accent_primary_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 주요 액션 버튼 */
|
/* 주요 액션 버튼 */
|
||||||
@ -435,11 +466,11 @@ QPushButton#btn_primary {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_primary:hover {{
|
QPushButton#btn_primary:hover {{
|
||||||
background: {c['accent_primary']}DD;
|
background: {c['accent_primary_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_primary:pressed {{
|
QPushButton#btn_primary:pressed {{
|
||||||
background: {c['accent_primary']}BB;
|
background: {c['accent_primary_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 위험 버튼 */
|
/* 위험 버튼 */
|
||||||
@ -450,11 +481,11 @@ QPushButton#btn_danger {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_danger:hover {{
|
QPushButton#btn_danger:hover {{
|
||||||
background: {c['accent_danger']}DD;
|
background: {c['accent_danger_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_danger:pressed {{
|
QPushButton#btn_danger:pressed {{
|
||||||
background: {c['accent_danger']}BB;
|
background: {c['accent_danger_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 성공 버튼 */
|
/* 성공 버튼 */
|
||||||
@ -465,25 +496,44 @@ QPushButton#btn_success {{
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_success:hover {{
|
QPushButton#btn_success:hover {{
|
||||||
background: {c['accent_success']}DD;
|
background: {c['accent_success_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_success:pressed {{
|
QPushButton#btn_success:pressed {{
|
||||||
background: {c['accent_success']}BB;
|
background: {c['accent_success_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 작은 버튼 */
|
/* 작은 버튼 — 미묘한 표면 */
|
||||||
QPushButton#btn_small {{
|
QPushButton#btn_small {{
|
||||||
font-size: 8.5pt;
|
font-size: 8.5pt;
|
||||||
padding: 5px 10px;
|
padding: 6px 10px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_small:hover {{
|
QPushButton#btn_small:hover {{
|
||||||
background: {c['accent_primary']}20;
|
background: {c['surface_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_small:pressed {{
|
QPushButton#btn_small:pressed {{
|
||||||
background: {c['accent_primary']}35;
|
background: {c['surface_pressed']};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */
|
||||||
|
QPushButton#nav_btn {{
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
color: {c['text_secondary']};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QPushButton#nav_btn:hover {{
|
||||||
|
background: {c['surface_hover']};
|
||||||
|
color: {c['text_primary']};
|
||||||
|
}}
|
||||||
|
|
||||||
|
QPushButton#nav_btn:pressed {{
|
||||||
|
background: {c['surface_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* ════════════════════════════════════════
|
/* ════════════════════════════════════════
|
||||||
@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
|
|||||||
QLineEdit, QTextEdit, QComboBox {{
|
QLineEdit, QTextEdit, QComboBox {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: 1px solid {c['border_default']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
|
|||||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: 1px solid {c['border_default']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 6px 28px 6px 8px;
|
padding: 6px 28px 6px 8px;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||||
border: 2px solid {c['border_focus']};
|
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||||
padding: 5px 7px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||||
border: 2px solid {c['border_focus']};
|
border: 1px solid {c['border_focus']};
|
||||||
padding: 5px 27px 5px 7px;
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 비활성 입력 필드 */
|
/* 비활성 입력 필드 */
|
||||||
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
|
|||||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||||
QDateEdit::up-button, QTimeEdit::up-button {{
|
QDateEdit::up-button, QTimeEdit::up-button {{
|
||||||
subcontrol-position: top right;
|
subcontrol-position: top right;
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 7px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||||
subcontrol-position: bottom right;
|
subcontrol-position: bottom right;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 7px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||||
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
|
|||||||
QProgressBar {{
|
QProgressBar {{
|
||||||
border: none;
|
border: none;
|
||||||
background: {c['progress_bg']};
|
background: {c['progress_bg']};
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
height: 8px;
|
min-height: 6px;
|
||||||
|
max-height: 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QProgressBar::chunk {{
|
QProgressBar::chunk {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: {c['progress_start']};
|
||||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
border-radius: 3px;
|
||||||
border-radius: 4px;
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* ════════════════════════════════════════
|
/* ════════════════════════════════════════
|
||||||
@ -648,7 +694,7 @@ QProgressBar::chunk {{
|
|||||||
QTableWidget {{
|
QTableWidget {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
gridline-color: {c['border_subtle']};
|
gridline-color: {c['border_subtle']};
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
|
|||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
|
||||||
|
QHeaderView {{
|
||||||
|
background: {c['bg_secondary']};
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
|
||||||
QHeaderView::section {{
|
QHeaderView::section {{
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
color: {c['text_secondary']};
|
color: {c['text_secondary']};
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid {c['accent_primary']};
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
QHeaderView::section:horizontal {{
|
||||||
|
border-bottom: 2px solid {c['accent_primary']};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* 세로헤더(행번호) — accent 밑줄 없이 미묘하게 */
|
||||||
|
QHeaderView::section:vertical {{
|
||||||
|
border-right: 1px solid {c['border_subtle']};
|
||||||
|
color: {c['text_tertiary']};
|
||||||
|
font-weight: normal;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
|
||||||
|
QTableView QTableCornerButton::section {{
|
||||||
|
background: {c['bg_tertiary']};
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid {c['accent_primary']};
|
||||||
|
}}
|
||||||
|
|
||||||
/* ════════════════════════════════════════
|
/* ════════════════════════════════════════
|
||||||
탭 위젯
|
탭 위젯
|
||||||
════════════════════════════════════════ */
|
════════════════════════════════════════ */
|
||||||
|
|
||||||
QTabWidget::pane {{
|
QTabWidget::pane {{
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}}
|
}}
|
||||||
@ -694,8 +764,8 @@ QTabBar::tab {{
|
|||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-top-left-radius: 6px;
|
border-top-left-radius: 8px;
|
||||||
border-top-right-radius: 6px;
|
border-top-right-radius: 8px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
|||||||
QCalendarWidget {{
|
QCalendarWidget {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@ -902,7 +972,7 @@ QToolTip {{
|
|||||||
QMenu {{
|
QMenu {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: 1px solid {c['border_default']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
}}
|
}}
|
||||||
@ -916,6 +986,16 @@ QMenu::item:selected {{
|
|||||||
background: {c['accent_primary']};
|
background: {c['accent_primary']};
|
||||||
color: {c['text_inverse']};
|
color: {c['text_inverse']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
QMenu::separator {{
|
||||||
|
height: 1px;
|
||||||
|
background: {c['border_subtle']};
|
||||||
|
margin: 4px 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QMenu::icon {{
|
||||||
|
padding-left: 8px;
|
||||||
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,8 @@ from __future__ import annotations
|
|||||||
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
|
|
||||||
|
from core.i18n import tr
|
||||||
|
|
||||||
|
|
||||||
class TodaySummaryCard(QFrame):
|
class TodaySummaryCard(QFrame):
|
||||||
"""퇴근 처리 직후 표시되는 요약 카드."""
|
"""퇴근 처리 직후 표시되는 요약 카드."""
|
||||||
@ -16,12 +18,12 @@ class TodaySummaryCard(QFrame):
|
|||||||
self.setObjectName("today_summary_card")
|
self.setObjectName("today_summary_card")
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QFrame#today_summary_card {
|
QFrame#today_summary_card {
|
||||||
background-color: rgba(76, 175, 80, 0.08);
|
background-color: rgba(81, 207, 102, 0.08);
|
||||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
border: 1px solid rgba(81, 207, 102, 0.40);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
QLabel { padding: 1px; }
|
QLabel { padding: 1px; background: transparent; border: none; }
|
||||||
""")
|
""")
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
|
|
||||||
@ -30,7 +32,7 @@ class TodaySummaryCard(QFrame):
|
|||||||
layout.setSpacing(2)
|
layout.setSpacing(2)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel("📋 오늘의 요약")
|
title = QLabel(tr('today.title'))
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch()
|
header.addStretch()
|
||||||
@ -43,9 +45,9 @@ class TodaySummaryCard(QFrame):
|
|||||||
|
|
||||||
self.total_label = QLabel("")
|
self.total_label = QLabel("")
|
||||||
self.detail_label = QLabel("")
|
self.detail_label = QLabel("")
|
||||||
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
|
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||||
self.salary_label = QLabel("")
|
self.salary_label = QLabel("")
|
||||||
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||||
|
|
||||||
layout.addWidget(self.total_label)
|
layout.addWidget(self.total_label)
|
||||||
layout.addWidget(self.detail_label)
|
layout.addWidget(self.detail_label)
|
||||||
@ -70,22 +72,22 @@ class TodaySummaryCard(QFrame):
|
|||||||
"""
|
"""
|
||||||
h = int(total_hours)
|
h = int(total_hours)
|
||||||
m = int((total_hours - h) * 60)
|
m = int((total_hours - h) * 60)
|
||||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
self.total_label.setText(tr('today.total_work', hours=h, minutes=m))
|
||||||
|
|
||||||
details = []
|
details = []
|
||||||
if lunch_minutes > 0:
|
if lunch_minutes > 0:
|
||||||
details.append(f"점심 {lunch_minutes}분")
|
details.append(tr('today.detail_lunch', minutes=lunch_minutes))
|
||||||
if dinner_minutes > 0:
|
if dinner_minutes > 0:
|
||||||
details.append(f"저녁 {dinner_minutes}분")
|
details.append(tr('today.detail_dinner', minutes=dinner_minutes))
|
||||||
if break_minutes > 0:
|
if break_minutes > 0:
|
||||||
details.append(f"외출 {break_minutes}분")
|
details.append(tr('today.detail_break', minutes=break_minutes))
|
||||||
if overtime_actual > 0:
|
if overtime_actual > 0:
|
||||||
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
|
details.append(tr('today.detail_overtime', actual=overtime_actual, earned=overtime_earned))
|
||||||
self.detail_label.setText(" · ".join(details) if details else "")
|
self.detail_label.setText(" · ".join(details) if details else "")
|
||||||
self.detail_label.setVisible(bool(details))
|
self.detail_label.setVisible(bool(details))
|
||||||
|
|
||||||
if salary_text:
|
if salary_text:
|
||||||
self.salary_label.setText(f"💰 {salary_text}")
|
self.salary_label.setText(f"{salary_text}")
|
||||||
self.salary_label.setVisible(True)
|
self.salary_label.setVisible(True)
|
||||||
else:
|
else:
|
||||||
self.salary_label.setVisible(False)
|
self.salary_label.setVisible(False)
|
||||||
|
|||||||
@ -138,6 +138,7 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
|||||||
conn = db.get_connection()
|
conn = db.get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
try:
|
try:
|
||||||
|
cursor.execute("DELETE FROM overtime_usage WHERE date = ?", (row['date'],))
|
||||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
||||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||||
|
|||||||
@ -13,7 +13,9 @@ from typing import Optional, List
|
|||||||
|
|
||||||
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
||||||
# 일반 브라우저 UA로 위장해야 통과.
|
# 일반 브라우저 UA로 위장해야 통과.
|
||||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
|
# 버전은 core/version.py에서 동기화.
|
||||||
|
from core.version import __version__
|
||||||
|
USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/{__version__}'
|
||||||
|
|
||||||
# Discord embed 색상 (decimal)
|
# Discord embed 색상 (decimal)
|
||||||
COLOR_GREEN = 0x57F287
|
COLOR_GREEN = 0x57F287
|
||||||
|
|||||||
84
utils/font_loader.py
Normal file
84
utils/font_loader.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""번들 폰트(NanumSquare) 로딩.
|
||||||
|
|
||||||
|
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
|
||||||
|
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
|
||||||
|
등록 실패 시 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PyQt5.QtGui import QFontDatabase, QFont
|
||||||
|
|
||||||
|
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
|
||||||
|
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
|
||||||
|
_FONT_FILES = [
|
||||||
|
'NanumSquareL.ttf',
|
||||||
|
'NanumSquareR.ttf',
|
||||||
|
'NanumSquareB.ttf',
|
||||||
|
'NanumSquareEB.ttf',
|
||||||
|
'NanumSquare_acR.ttf',
|
||||||
|
'NanumSquare_acB.ttf',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _font_dir() -> str:
|
||||||
|
"""번들 font/ 디렉토리 절대 경로."""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
return os.path.join(base, 'font')
|
||||||
|
|
||||||
|
|
||||||
|
def load_bundled_fonts() -> list:
|
||||||
|
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
|
||||||
|
families: list = []
|
||||||
|
fdir = _font_dir()
|
||||||
|
if not os.path.isdir(fdir):
|
||||||
|
return families
|
||||||
|
for name in _FONT_FILES:
|
||||||
|
path = os.path.join(fdir, name)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
fid = QFontDatabase.addApplicationFont(path)
|
||||||
|
if fid == -1:
|
||||||
|
continue
|
||||||
|
for fam in QFontDatabase.applicationFontFamilies(fid):
|
||||||
|
if fam not in families:
|
||||||
|
families.append(fam)
|
||||||
|
return families
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_primary(families: list) -> str:
|
||||||
|
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
|
||||||
|
if 'NanumSquare' in families:
|
||||||
|
return 'NanumSquare'
|
||||||
|
for fam in families:
|
||||||
|
low = fam.lower()
|
||||||
|
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
|
||||||
|
return fam
|
||||||
|
return 'Malgun Gothic'
|
||||||
|
|
||||||
|
|
||||||
|
def apply_app_font(app, point_size: int = 9) -> str:
|
||||||
|
"""앱 전역 기본 폰트를 NanumSquare로 설정.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
실제 적용된 primary family 이름 (폴백 시 'Malgun Gothic').
|
||||||
|
"""
|
||||||
|
families = load_bundled_fonts()
|
||||||
|
primary = _pick_primary(families)
|
||||||
|
font = QFont(primary, point_size)
|
||||||
|
font.setStyleStrategy(QFont.PreferAntialias)
|
||||||
|
app.setFont(font)
|
||||||
|
return primary
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
_app = QApplication(sys.argv)
|
||||||
|
fams = load_bundled_fonts()
|
||||||
|
print('font dir:', _font_dir())
|
||||||
|
print('registered families:', fams)
|
||||||
|
print('picked primary:', _pick_primary(fams))
|
||||||
@ -14,15 +14,17 @@
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
# 공공데이터포털 특일정보 API 서비스 키.
|
||||||
|
# 소스코드/바이너리 노출 방지를 위해 환경변수에서 읽습니다.
|
||||||
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||||
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
_SERVICE_KEY = os.environ.get('CLOCKOUT_HOLIDAY_API_KEY', '')
|
||||||
|
|
||||||
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
_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)'
|
||||||
|
|||||||
@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
|
|||||||
return QIcon(pixmap)
|
return QIcon(pixmap)
|
||||||
|
|
||||||
def setup_menu(self):
|
def setup_menu(self):
|
||||||
"""트레이 메뉴 설정"""
|
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
|
||||||
menu = QMenu()
|
menu = QMenu()
|
||||||
|
|
||||||
show_action = QAction(tr('tray.open'), self)
|
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
|
||||||
show_action.triggered.connect(self.show_window)
|
self._icon_actions = []
|
||||||
menu.addAction(show_action)
|
|
||||||
|
|
||||||
mini_action = QAction(tr('tray.mini_widget'), self)
|
def add(text, slot, icon_name=None):
|
||||||
mini_action.triggered.connect(self._open_mini_widget)
|
action = QAction(text, self)
|
||||||
menu.addAction(mini_action)
|
action.triggered.connect(slot)
|
||||||
|
menu.addAction(action)
|
||||||
|
if icon_name:
|
||||||
|
self._icon_actions.append((action, icon_name))
|
||||||
|
return action
|
||||||
|
|
||||||
|
add(tr('tray.open'), self.show_window, 'home')
|
||||||
|
add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
lunch_action = QAction(tr('tray.toggle_lunch'), self)
|
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
|
||||||
lunch_action.triggered.connect(self._toggle_lunch)
|
add(tr('btn.break_out'), self._break_out)
|
||||||
menu.addAction(lunch_action)
|
add(tr('btn.break_in'), self._break_in)
|
||||||
|
|
||||||
break_out_action = QAction(tr('btn.break_out'), self)
|
|
||||||
break_out_action.triggered.connect(self._break_out)
|
|
||||||
menu.addAction(break_out_action)
|
|
||||||
|
|
||||||
break_in_action = QAction(tr('btn.break_in'), self)
|
|
||||||
break_in_action.triggered.connect(self._break_in)
|
|
||||||
menu.addAction(break_in_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
|
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
|
||||||
clock_out_action.triggered.connect(self.quick_clock_out)
|
|
||||||
menu.addAction(clock_out_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
stats_action = QAction("📊 " + tr('menu.stats'), self)
|
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
|
||||||
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
|
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
|
||||||
menu.addAction(stats_action)
|
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
|
||||||
|
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
|
||||||
cal_action = QAction("📅 " + tr('menu.calendar'), self)
|
|
||||||
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
|
||||||
menu.addAction(cal_action)
|
|
||||||
|
|
||||||
schedule_action = QAction("🗓️ 스케줄", self)
|
|
||||||
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
|
|
||||||
menu.addAction(schedule_action)
|
|
||||||
|
|
||||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
|
||||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
|
||||||
menu.addAction(help_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
quit_action = QAction(tr('tray.quit'), self)
|
add(tr('tray.quit'), self.quit_app)
|
||||||
quit_action.triggered.connect(self.quit_app)
|
|
||||||
menu.addAction(quit_action)
|
|
||||||
|
|
||||||
self.setContextMenu(menu)
|
self.setContextMenu(menu)
|
||||||
|
self.refresh_theme()
|
||||||
|
|
||||||
|
def refresh_theme(self):
|
||||||
|
"""트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
|
||||||
|
|
||||||
|
QMenu()는 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
|
||||||
|
명시적으로 적용한다. 테마 변경 시 main_window.apply_theme에서 호출.
|
||||||
|
"""
|
||||||
|
menu = self.contextMenu()
|
||||||
|
if menu is None:
|
||||||
|
return
|
||||||
|
# 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
|
||||||
|
qss = self.parent_window.styleSheet() if self.parent_window else ''
|
||||||
|
if not qss:
|
||||||
|
try:
|
||||||
|
from ui.styles import get_theme
|
||||||
|
qss = get_theme('dark')
|
||||||
|
except Exception:
|
||||||
|
qss = ''
|
||||||
|
if qss:
|
||||||
|
menu.setStyleSheet(qss)
|
||||||
|
# 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
|
||||||
|
try:
|
||||||
|
from ui.icons import get_icon
|
||||||
|
from ui.styles import ThemeColors
|
||||||
|
color = ThemeColors.get('text_primary')
|
||||||
|
for action, name in getattr(self, '_icon_actions', []):
|
||||||
|
action.setIcon(get_icon(name, color, 16))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _call_parent(self, method_name: str):
|
def _call_parent(self, method_name: str):
|
||||||
if self.parent_window and hasattr(self.parent_window, method_name):
|
if self.parent_window and hasattr(self.parent_window, method_name):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user