Compare commits
No commits in common. "main" and "v2.3.3" have entirely different histories.
501
AGENTS.md
501
AGENTS.md
@ -1,476 +1,67 @@
|
||||
# Clock-out Time Calculator — Agent Guide
|
||||
# Project Conventions and Operational Gotchas
|
||||
|
||||
> Last verified against the working tree at version **2.11.2** (`core/version.py`).
|
||||
> 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.
|
||||
## 🛠️ Setup & Execution
|
||||
- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature.
|
||||
- **Run:** `python main.py`
|
||||
- **Module-level tests:**
|
||||
- Event monitoring: `python core/event_monitor.py`
|
||||
- Time calculation: `python core/time_calculator.py`
|
||||
- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release.
|
||||
|
||||
---
|
||||
## 🗄️ Architecture Notes (Core Business Logic)
|
||||
- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs.
|
||||
- **`work_records.date` UNIQUE** — one row per workday.
|
||||
- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out.
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours).
|
||||
- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect.
|
||||
|
||||
## 1. Project Overview
|
||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||
|
||||
**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.
|
||||
### 1. Time-off subtraction order in `update_display()`
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
|
||||
|
||||
- **Primary language:** Python 3.9+
|
||||
- **GUI framework:** PyQt5
|
||||
- **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout
|
||||
- **Packaging:** PyInstaller (`main.exe` + `updater.exe`)
|
||||
- **Distribution:** Gitea Releases on a self-hosted instance
|
||||
- **Current version:** `2.11.2` (single source of truth: `core/version.py`)
|
||||
- **Repository:** `kindnick/Clock_out_Time_Calculator`
|
||||
### 2. Hot-path caching
|
||||
`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`.
|
||||
|
||||
The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
|
||||
### 3. Time format separation
|
||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
||||
|
||||
---
|
||||
### 4. Workday boundary
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover.
|
||||
|
||||
## 2. Technology Stack
|
||||
### 5. Migration idempotency
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Language | Python 3.9+ |
|
||||
| GUI | PyQt5 ≥ 5.15 |
|
||||
| Charts | matplotlib (QtAgg backend) |
|
||||
| Windows integration | pywin32 (event log), ctypes (screen-lock detection) |
|
||||
| Date / recurrence | python-dateutil |
|
||||
| Notifications | plyer (system toast) + PyQt signals |
|
||||
| Holidays | optional `holidays` package; government API + fixed-date fallback |
|
||||
| Packaging | PyInstaller 2-step build |
|
||||
| Testing | pytest + standalone integration/GUI smoke scripts |
|
||||
| Fonts | Bundled NanumSquare TTF/OTF files; Malgun Gothic fallback |
|
||||
|
||||
Dependencies are declared in `requirements.txt`:
|
||||
|
||||
```text
|
||||
PyQt5>=5.15.0
|
||||
pywin32>=305
|
||||
python-dateutil>=2.8.0
|
||||
matplotlib>=3.4.0
|
||||
plyer>=2.0.0
|
||||
holidays>=0.40
|
||||
```
|
||||
|
||||
Install with:
|
||||
## ⚙️ Build Process
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`pywin32` is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms.
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure and Module Map
|
||||
|
||||
```text
|
||||
Clock-out Time Calculator/
|
||||
├── main.py # Application entry point / bootstrap
|
||||
├── updater.py # Standalone update helper process (stdlib only)
|
||||
├── main.spec # PyInstaller spec for main.exe
|
||||
├── updater.spec # PyInstaller spec for updater.exe
|
||||
├── release.ps1 # One-shot release script (PowerShell)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── pytest.ini # pytest configuration
|
||||
├── run_as_admin.bat # Convenience launcher
|
||||
├── core/ # Business logic and data access
|
||||
│ ├── database.py # SQLite schema, migrations, CRUD
|
||||
│ ├── time_calculator.py # Pure time-math engine
|
||||
│ ├── event_monitor.py # Windows Event Log clock-in detection
|
||||
│ ├── notifier.py # Notification rule engine
|
||||
│ ├── salary.py # Optional pay estimation
|
||||
│ ├── i18n.py # Korean/English translation dictionaries
|
||||
│ ├── settings_keys.py # Setting key constants
|
||||
│ ├── achievements.py # 357 achievement definitions + evaluator
|
||||
│ ├── recurring_leaves.py # Recurring leave pattern expansion
|
||||
│ └── version.py # __version__ single source of truth
|
||||
├── ui/ # PyQt5 views and widgets
|
||||
│ ├── main_window.py # Central 1 Hz main window
|
||||
│ ├── styles.py # Theme colors and QSS
|
||||
│ ├── dark_components.py # Reusable dark-styled widgets
|
||||
│ ├── icons.py # Icon resource helpers
|
||||
│ ├── i18n_runtime.py # Runtime retranslation registry
|
||||
│ ├── settings_view.py # Settings dialog
|
||||
│ ├── stats_view.py # Weekly/monthly/pattern statistics
|
||||
│ ├── chart_widget.py # matplotlib QtAgg wrapper + fallback
|
||||
│ ├── calendar_view.py # Work-record calendar
|
||||
│ ├── leave_calendar_view.py # Color-coded leave calendar
|
||||
│ ├── break_view.py # Break history dialog
|
||||
│ ├── overtime_view.py # Overtime bank/usage dialog
|
||||
│ ├── leave_view.py # Leave management dialog
|
||||
│ ├── recurring_leave_dialog.py
|
||||
│ ├── schedule_view.py # Schedule view
|
||||
│ ├── clock_in_dialog.py # Manual clock-in dialog
|
||||
│ ├── meal_time_dialog.py # Lunch/dinner start-end input
|
||||
│ ├── past_record_dialog.py # Add past record dialog
|
||||
│ ├── achievements_view.py # Achievements browser
|
||||
│ ├── onboarding_view.py # 5-step first-run wizard
|
||||
│ ├── today_summary.py # Post-clock-out card
|
||||
│ ├── goal_widget.py # Monthly goal progress bars
|
||||
│ ├── mini_widget.py # Always-on-top frameless widget
|
||||
│ ├── help_view.py # 6-tab help dialog
|
||||
│ ├── accessibility.py # Font scale / high-contrast helpers
|
||||
│ └── controllers/ # Thin controllers split from MainWindow
|
||||
│ ├── lock_monitor.py # Screen-lock auto-break / unlock clock-in
|
||||
│ ├── auto_lunch.py # Auto-lunch after 4 hours
|
||||
│ ├── notification_orchestrator.py # 5-min-tick orchestrator
|
||||
│ └── meal_controller.py # Lunch/dinner toggle handling
|
||||
├── utils/ # Helpers and integrations
|
||||
│ ├── backup.py # Daily SQLite backup with 7-rotation
|
||||
│ ├── lock_detector.py # Win32 screen-lock detection
|
||||
│ ├── discord_webhook.py # Discord embed pushes
|
||||
│ ├── csv_importer.py # CSV import (standard format)
|
||||
│ ├── csv_exporter.py # CSV export
|
||||
│ ├── crash_handler.py # Global excepthook + optional Gitea report
|
||||
│ ├── updater_client.py # Gitea Releases API client
|
||||
│ ├── system_tray.py # System tray menu
|
||||
│ ├── time_format.py # format_hours_minutes helper
|
||||
│ ├── font_loader.py # NanumSquare font loading
|
||||
│ ├── resource_manager.py # PyInstaller _MEIPASS path resolver
|
||||
│ ├── debug_log.py # CLOCKOUT_DEBUG gated logging
|
||||
│ └── holiday_api.py # Korean holiday API client
|
||||
├── tests/ # pytest unit tests (13 files)
|
||||
├── font/ # Bundled NanumSquare fonts
|
||||
├── resources/ # Icons and resource links
|
||||
├── analysis/ # Currently empty (only __init__.py)
|
||||
├── build/ # Build staging directory
|
||||
└── dist/ # Built EXEs and release ZIPs
|
||||
```
|
||||
|
||||
> **Note:** `utils/http_api.py` is referenced in older documentation but is **not present** in the current working tree.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to Build, Run, and Smoke-Test
|
||||
|
||||
### Development run
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
`main.py` inserts the project root into `sys.path`, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches `MainWindow`.
|
||||
|
||||
A convenience batch file is provided:
|
||||
|
||||
```bash
|
||||
run_as_admin.bat
|
||||
```
|
||||
|
||||
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
|
||||
|
||||
### Module-level smoke tests
|
||||
|
||||
```bash
|
||||
python core/event_monitor.py
|
||||
python core/time_calculator.py
|
||||
```
|
||||
|
||||
These run lightweight self-tests when invoked as scripts.
|
||||
|
||||
### Production build (manual two-step)
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean updater.spec
|
||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||
python -m PyInstaller --clean main.spec
|
||||
```
|
||||
|
||||
- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
|
||||
- `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`).
|
||||
- The staging copy is critical: `main.spec --clean` wipes `dist/`, so without `build/staging/updater.exe` the updater would be deleted mid-build.
|
||||
- Output: `dist/main.exe` (console disabled, UPX compressed)
|
||||
- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`)
|
||||
- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first.
|
||||
- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env.
|
||||
|
||||
### Production build (one-shot)
|
||||
## 🚦 External Integrations
|
||||
|
||||
```bash
|
||||
.\release.ps1 v2.11.2
|
||||
```
|
||||
- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design.
|
||||
- **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).
|
||||
|
||||
The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`.
|
||||
## 🐞 Past Incidents
|
||||
|
||||
### Build gotchas
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce.
|
||||
- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling.
|
||||
- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters.
|
||||
|
||||
- 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`.
|
||||
## 🌐 i18n Coverage Status
|
||||
|
||||
---
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels.
|
||||
- **Partially translated**: settings_view sub-labels (입력란 placeholder 등), calendar_view detail labels.
|
||||
- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated.
|
||||
|
||||
## 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.
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`.
|
||||
|
||||
300
CHANGELOG.md
300
CHANGELOG.md
@ -4,306 +4,6 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.12.0] — 2026-06-16
|
||||
|
||||
### Added — 전체 i18n 키화 완료
|
||||
- **UI 전체 사용자 대면 문자열 i18n 키화** — `ui/` 디렉터리 22개 파일의 버튼/라벨/메시지박스/차트/온볼딩/설정/통계 등을 `tr()` 기반으로 전환, `core/i18n.py`에 ko/en 번역 추가.
|
||||
- **도전과제 메타데이터 i18n** — `core/achievements.py`의 모든 도전과제 이름/설명을 `achieve.{code}.name/desc` 키로 분리하고 영문 번역 추가.
|
||||
- **차트 위젯 라벨 키화** — matplotlib 차트의 축/툴팁/범례/빈 기록 메시지 등을 언어별로 표시.
|
||||
- **반복 연차 패턴 설명 키화** — `core/recurring_leaves.describe_pattern()`이 요일/주기 접두사를 i18n 키로 조합.
|
||||
|
||||
### Fixed
|
||||
- `ui/help_view.py`의 "온보팅 다시 보기" 버튼이 `tr()` 호출이 아닌 리터럴 문자열로 잘못 들어가던 버그 수정.
|
||||
- `core/i18n.py` 영문 achievement 번역 중 `Children's`/`Teachers'` 작은따옴표로 인한 SyntaxError 수정.
|
||||
- `_i18n_gui_test.py`에 `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 추가하여 백그라운드 휴일 동기화 스레드로 인한 세그멘테이션 폴트 방지.
|
||||
|
||||
### Changed
|
||||
- **공공데이터포털 공휴일 API 키 외부화** — `utils/holiday_api.py`가 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수를 사용하도록 변경.
|
||||
- `ui/main_window.py` 1Hz 핫패스 DB 호출 캐싱 추가.
|
||||
- `utils/csv_importer.py` overwrite 시 `overtime_usage`도 함께 삭제.
|
||||
- `ui/controllers/lock_monitor.py` 컨텍스트 매니저 적용 및 race condition 처리 개선.
|
||||
|
||||
## [2.11.2] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
|
||||
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
|
||||
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
|
||||
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
|
||||
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
|
||||
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
|
||||
|
||||
## [2.11.1] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
|
||||
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget`이 `backend_qt5agg`를
|
||||
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
|
||||
폴백) + 실패 원인 로깅, `main.spec`에 `backend_qtagg`/`PyQt5.sip` 명시.
|
||||
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제** — `dark_components`와
|
||||
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
|
||||
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
|
||||
|
||||
## [2.11.0] — 2026-06-04
|
||||
|
||||
### Changed — UI 전면 다크 리디자인
|
||||
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
|
||||
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
|
||||
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
|
||||
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
|
||||
`main.spec`에 동봉
|
||||
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
|
||||
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
|
||||
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
|
||||
|
||||
### Added
|
||||
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
|
||||
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함)
|
||||
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
|
||||
(`Database.delete_overtime_earned`)
|
||||
|
||||
### Fixed
|
||||
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
|
||||
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
|
||||
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
|
||||
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
|
||||
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
|
||||
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
|
||||
|
||||
### Tests
|
||||
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
|
||||
|
||||
## [2.10.2] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
|
||||
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
|
||||
그대로 표시에 사용 → 초 정보 소실
|
||||
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
|
||||
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
|
||||
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
|
||||
|
||||
## [2.10.1] — 2026-05-01
|
||||
|
||||
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
|
||||
- **`updater.spec`**: `console=True` → `console=False` (windowed 빌드).
|
||||
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
|
||||
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
|
||||
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
|
||||
에 타임스탬프 + 결과 기록.
|
||||
- **`updater.py launch()`**: `subprocess.Popen` 에 `CREATE_NO_WINDOW` 플래그 추가
|
||||
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
|
||||
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
|
||||
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
|
||||
|
||||
## [2.10.0] — 2026-05-01
|
||||
|
||||
### Added — 정부 공휴일 API 자동 동기화
|
||||
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
|
||||
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
|
||||
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
|
||||
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
|
||||
- 키는 dev 본인 계정의 특일정보 API 한정 키
|
||||
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
|
||||
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
|
||||
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
|
||||
- sentinel: `settings['holidays_synced_date']`
|
||||
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
|
||||
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
|
||||
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
|
||||
|
||||
### Changed
|
||||
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
|
||||
|
||||
### Tests
|
||||
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
|
||||
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
|
||||
- pytest: 175 → **189**
|
||||
|
||||
### 주의
|
||||
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
|
||||
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
|
||||
|
||||
## [2.9.0] — 2026-05-01
|
||||
|
||||
### Fixed — 휴일 hot-path 버그 (사용자 보고)
|
||||
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
|
||||
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
|
||||
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
|
||||
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
|
||||
- 진행바: 휴일은 100% 고정 (의미 없음)
|
||||
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
|
||||
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
|
||||
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
|
||||
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
|
||||
|
||||
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
|
||||
|
||||
#### Phase 1 — 연차 미리등록 + 자동 적용
|
||||
- **DB:** `get_leave_minutes_for(date)` / `has_full_day_leave(date)` /
|
||||
`get_leave_records_by_date(date)` / `get_leave_records_by_range(start, end)`
|
||||
- **TimeCalculator:** `effective_work_minutes(date_obj, db)` — 부분 연차만큼 정규 근무 차감
|
||||
- **종일 연차일 자동 처리:**
|
||||
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
|
||||
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
|
||||
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
|
||||
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
|
||||
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
|
||||
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
|
||||
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
|
||||
|
||||
#### Phase 2 — 통합 스케줄 + 반복 연차
|
||||
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
|
||||
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
|
||||
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
|
||||
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
|
||||
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
|
||||
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
|
||||
|
||||
### Changed
|
||||
- **근로자의 날(5/1) 자동 추가** — `holidays.KR` 패키지가 누락하는 노동자 휴일을
|
||||
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
|
||||
|
||||
### Tests
|
||||
- pytest: 122 → **175** (+53)
|
||||
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
|
||||
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
|
||||
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
|
||||
- 통합 시나리오: 48 → **53** (+5)
|
||||
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
|
||||
|
||||
## [2.8.0] — 2026-05-01
|
||||
|
||||
### Added — 도전과제 시스템 + 디자인 리뉴얼
|
||||
- **🏆 도전과제 시스템** (153개 자동 평가) — 출근·퇴근·연장·연차·식사·외출·계절·시간대·시크릿 등 16개 카테고리.
|
||||
- `core/achievements.py`: `Achievement` dataclass + `evaluate_all(db)` + `sync_definitions_to_db(db)`.
|
||||
- 5분 throttle로 자동 평가, 신규 잠금 해제 시 시스템 알림 + Discord embed push.
|
||||
- `achievements` 테이블 확장: `code`(UNIQUE), `category`, `tier`, `is_secret`, `progress`, `target`, `created_at` 컬럼 추가 (idempotent 마이그레이션).
|
||||
- 5단계 등급: 🥉 브론즈 / 🥈 실버 / 🥇 골드 / 💎 플래티넘 / 🌟 레전드.
|
||||
- 시크릿 9개 (회문, 잭팟 시각, 13일의 금요일, 7-7-7, 정확 8시간, π day, 피보나치 등).
|
||||
- 메타 도전과제 (도전과제 자체 달성 카운트).
|
||||
- **`ui/achievements_view.py`** — 4탭 다이얼로그 (전체 / 진행 중 / 완료 / 시크릿).
|
||||
- 등급별 그라디언트 카드, 진행 게이지, 카테고리 태그, 시크릿 ❓ 처리.
|
||||
- **자동 hire_date 추적** — 첫 `add_work_record` 호출 시 settings에 자동 기록 (1주년, 365일 후 출근 등 도전과제 활성화).
|
||||
- **뷰 진입 카운터** — `stat_*_view_count`, `calendar_view_count`, `daily_report_count` 등 8개 settings 키. 도전과제 + 사용 통계용.
|
||||
|
||||
### Changed — 다크 테마 디자인 리뉴얼
|
||||
- **`ui/dark_components.py`** 신설 — 재사용 가능한 다크 디자인 컴포넌트:
|
||||
- `dialog_qss()`, `tabs_qss()`, `scroll_qss()`, `button_qss(variant)`.
|
||||
- `build_gradient_header()`, `build_stat_card()`, `build_section_card()`.
|
||||
- `style_progressbar()`, `transparent_label()` — 글로벌 QSS 충돌 회피.
|
||||
- 카드 테마 7종: blue / cyan / green / gold / pink / red / gray.
|
||||
- **`ui/stats_view.py`** — 4분할 카드 + 차트 섹션 카드. 골드 강조 탭. ghost 닫기 버튼.
|
||||
- **`ui/help_view.py`** — 다크 톤 + HelpHTML 내부에 다크 CSS 주입 (h1/h2/h3, code, blockquote, table 컬러).
|
||||
- **`ui/chart_widget.py`** — 모든 matplotlib 차트 다크 테마 적용 (figure/axes facecolor, grid, ticks, legend).
|
||||
- **온보딩 위저드** — 저녁 분(minutes) 입력 옵션 + i18n화 (Korean/English 프리셋 라벨).
|
||||
|
||||
### Fixed — 안정성·일관성
|
||||
- **타임존 자정 경계 버그**: `has_notification_today`가 `CURRENT_TIMESTAMP`(UTC) vs `DATE('now', 'localtime')` mismatch로 KST 0~9시 사이 알림 중복 발송 가능 — `DATE(sent_at, 'localtime')` 양쪽 적용.
|
||||
- **DB 연결 누수 가드** — `_conn()` 컨텍스트 매니저 도입, 40+ 메서드 변환 (직접 `get_connection()` 호출 0건).
|
||||
- **DB 이중 부트스트랩 제거** — `MainWindow(db=None)` 옵션 인자 추가, main.py가 만든 db를 재사용.
|
||||
- **crash_handler 폴백** — DB 로깅/다이얼로그 단계 분리, 모두 실패해도 `~/.clockout_logs/crashes.log`에 기록.
|
||||
- **updater PID race window** — 지수 backoff 재시도 (0.3→4.8s, 총 ~9초). Windows Defender 락 해제 대기 충분.
|
||||
- **Discord URL 형식 검증** — Snowflake ID 17~20자리 + 50+자 토큰 정규식.
|
||||
- **MealTimeDialog** — 출근 시각 범위 검증 + 야간 출근자 자정 경계 자동 처리.
|
||||
- **CSV importer/exporter** — `dinner_minutes` 컬럼 round-trip 지원.
|
||||
- **일일 보고서** — 저녁 섹션 추가 (이전엔 점심만 표시), `break_type` 분리, 자정 경계 처리.
|
||||
- **저녁 알림** — `check_dinner_reminder()` 신규 (점심 알림과 대칭).
|
||||
- **알림 임계값 설정화** — 5개 하드코딩 값(점심/저녁 알림 시간, 연장 누적, 주간 한도, 연속 야근)을 settings로 노출.
|
||||
- **closeEvent 정리** — `aboutToQuit` 시그널로 timer/notifier/tray 정리.
|
||||
- **마이그레이션 일관성** — 12개 마이그레이션 모두 `_conn()` + try/finally 보장.
|
||||
|
||||
### DB 인덱스 (성능)
|
||||
- `idx_break_records_date_type`, `idx_break_records_date`.
|
||||
- `idx_overtime_bank_date`, `idx_overtime_usage_date`, `idx_leave_records_date`.
|
||||
|
||||
### Tests
|
||||
- pytest 116개 PASS, 통합 시나리오 44/48 PASS (PyQt 환경 4개 제외).
|
||||
- 도전과제 한 사이클 시나리오 검증 (30일 시뮬레이션 → 19개 자동 잠금 해제).
|
||||
|
||||
## [2.7.0] — 2026-04-30
|
||||
|
||||
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
|
||||
- **i18n 사전 100% 커버리지** — `break_view`, `overtime_view`, `leave_view`,
|
||||
`clock_in_dialog` 의 내부 라벨/메시지/플레이스홀더까지 전부 ko/en 키화.
|
||||
새 키 50+개 추가 (`view.break.*`, `view.overtime.*`, `view.leave.*`, `dlg.*`).
|
||||
- **i18n 런타임 재번역** — 재시작 없이 메인 화면 즉시 언어 전환.
|
||||
- `ui/i18n_runtime.py`: `register(widget, key)` + `set_language_and_retranslate(lang)`
|
||||
- 위젯은 weakref로 보관되어 삭제 시 자동 정리
|
||||
- 메인 윈도우 타이틀/하단 메뉴 5개 버튼 등록 완료 (점진 확대)
|
||||
- 일부 다이얼로그는 여전히 재시작 필요 (다음 릴리스에서 점진 등록)
|
||||
- **MealController 분리** — `main_window.py` 에서 점심/저녁 토글·라벨 갱신 로직을
|
||||
`ui/controllers/meal_controller.py` 로 추출. 기존 `LockMonitor`/`AutoLunch`/
|
||||
`NotificationOrchestrator` 패턴 준수.
|
||||
|
||||
### Tests
|
||||
- 통합 테스트 +15 시나리오 (S36–S52): 온보딩 신규/기존, salary 추정, CSV 가져오기
|
||||
(skip/overwrite/overtime 적립까지), notification_log dedupe, meal_record,
|
||||
crash_log, updater semver 비교, Discord 입력 검증, goal/accessibility 키 등.
|
||||
- 신규 pytest 모듈 4종 — `test_salary.py`, `test_csv_importer.py`,
|
||||
`test_discord_webhook.py`(network mocked), `test_crash_handler.py`.
|
||||
- `test_i18n_runtime.py` — register/retranslate/post-callback/dead-widget 정리 검증.
|
||||
- 총 pytest 케이스: 90 → **122**, 통합 시나리오: 33 → **48**.
|
||||
|
||||
### Docs
|
||||
- `README.md` v2.4–v2.6 신기능 섹션 정리, 재번호.
|
||||
- `CLAUDE.md` v2.6+ 아키텍처 — 컨트롤러 모듈, 35+ 설정 키, 릴리스 플로우 반영.
|
||||
- `INSTALL.md` 최신 단일파일 배포 + 온보딩 + 디스코드 + 환경변수 정리.
|
||||
- `AGENTS.md` 10+ DB 테이블, 컨트롤러 맵, Past Incidents 갱신.
|
||||
|
||||
## [2.6.0] — 2026-04-30
|
||||
|
||||
### Added — Phase 4 (3종)
|
||||
- **글꼴 크기 조절** (100% / 125% / 150%) — 설정에서 즉시 반영
|
||||
- **고대비 모드** — 검정 배경 + 노란 텍스트 (시각약자/야간)
|
||||
- **코드 서명 인프라** — `release.ps1`에 Authenticode 서명 단계 추가 (옵션)
|
||||
- `$env:CODE_SIGN_CERT` (`.pfx` 경로) + `$env:CODE_SIGN_PASS` 환경변수 설정 시 자동 서명
|
||||
- signtool.exe 없거나 cert 미설정 시 자동 스킵
|
||||
- 코드 서명 인증서 확보 후 활성화하면 SmartScreen 경고 제거 가능
|
||||
|
||||
### Settings (신규)
|
||||
- `font_scale`, `high_contrast`
|
||||
|
||||
## [2.5.0] — 2026-04-30
|
||||
|
||||
### Added — Phase 3 (4종)
|
||||
- **주간 자동 리포트** — 월요일 첫 출근 시 (또는 첫 5분 tick) 지난주 요약 발송
|
||||
- 시스템 알림 + Discord push (옵션) 동시
|
||||
- `notification_log`로 중복 발송 방지
|
||||
- 항목: 총 근무·일평균·연장근무·가장 긴 날
|
||||
- **matplotlib 차트 호버 디테일** — 막대 위에 마우스 올리면 정확한 수치 툴팁
|
||||
- 일별 근무 시간 차트(주간 탭)에 적용
|
||||
- **출근 시각 분포 차트** — 패턴 분석 탭에 30분 단위 히스토그램
|
||||
- 평균 출근 시각 빨간 점선으로 표시
|
||||
- **휴가 캘린더 시각화** — 연차 관리 → "📅 캘린더 보기"
|
||||
- 사용 일자에 종일/반차/반반차별 색상 표시
|
||||
- 날짜 클릭 → 사용 내역 표시
|
||||
|
||||
### Fixed
|
||||
- `leave_view.py` setLayout 들여쓰기 회귀 수정
|
||||
|
||||
## [2.4.0] — 2026-04-30
|
||||
|
||||
### Added — Phase 2 (5종)
|
||||
- **점심/저녁 실제 시간 입력** — 점심/저녁 버튼 우클릭 → 시작·종료 시각 입력 다이얼로그
|
||||
- 자동 60분 대신 정확한 분 단위 기록 (`break_records.break_type='lunch'/'dinner'`)
|
||||
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
|
||||
- 비어있는 날짜 우클릭: "기록 추가" — 출/퇴근/점심/메모 입력
|
||||
- 기록 있는 날짜 우클릭: "편집"/"삭제"
|
||||
- **월간 목표 설정 + 진행률**
|
||||
- 설정 → 월 연장근무 상한 (시간/분) + 일 평균 근무 목표 (시간)
|
||||
- 통계 → 월간 탭에 진행률 게이지 (60%/100% 임계 시 색상 변경)
|
||||
- 0=비활성 (비활성 시 위젯 자체 숨김)
|
||||
- **CSV 가져오기** — 표준 포맷 `date,clock_in,clock_out,lunch_minutes,memo`
|
||||
- 충돌 정책: 덮어쓰기/건너뛰기/취소
|
||||
- **자동 Crash Report (Gitea Issues)**
|
||||
- 전역 예외 후킹 → crash_log 저장 + 사용자에게 다이얼로그
|
||||
- "복사" / "Gitea에 보고" (PAT 옵션) — issue 자동 생성
|
||||
|
||||
### Settings (신규 4개)
|
||||
- `goal_overtime_max_monthly`, `goal_avg_hours_daily`
|
||||
- `gitea_feedback_token`, `gitea_feedback_enabled`
|
||||
|
||||
## [2.3.3] — 2026-04-30
|
||||
|
||||
### Fixed
|
||||
|
||||
165
CLAUDE.md
165
CLAUDE.md
@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Clock-out Time Calculator** (퇴근시간 계산기) — Windows desktop app: auto-detects clock-in via Windows Event Log or screen-unlock, calculates clock-out time, banks overtime in 30-min units, tracks leave/breaks, with Discord push, onboarding wizard, and self-updating via Gitea Releases.
|
||||
**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable).
|
||||
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`.
|
||||
**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`.
|
||||
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md).
|
||||
Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md).
|
||||
|
||||
## Build and Run
|
||||
|
||||
@ -20,80 +20,46 @@ python main.py
|
||||
python core/event_monitor.py
|
||||
python core/time_calculator.py
|
||||
|
||||
# Production build → dist/main.exe (78MB, embeds updater.exe)
|
||||
python -m PyInstaller --clean updater.spec # build first — main.spec datas references it
|
||||
# Production build → dist/main.exe
|
||||
python -m PyInstaller --clean main.spec
|
||||
|
||||
# Tests
|
||||
python _integration_test.py # business-logic scenarios
|
||||
python _i18n_gui_test.py # ko/en GUI verification
|
||||
python _gui_smoke_test.py # widget instantiation
|
||||
python -m pytest tests # unit tests
|
||||
|
||||
# Release (one-shot to Gitea)
|
||||
$env:GITEA_TOKEN = '<PAT>'
|
||||
.\release.ps1 v2.7.0
|
||||
# Integration tests (35 + 5 + view scenarios)
|
||||
python _integration_test.py
|
||||
python _i18n_gui_test.py
|
||||
python _gui_smoke_test.py
|
||||
```
|
||||
|
||||
PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build.
|
||||
|
||||
## Architecture
|
||||
|
||||
### core/
|
||||
- **[database.py](core/database.py)** — SQLite. 8+ tables: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`(+`break_type`), `settings`, `achievements`, `holidays`, `notification_log`, `crash_log`. Runtime migrations chained from `init_database()`. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`, `add_meal_record()`, `log_notification()`, `has_notification_today()`. WAL mode + 5s busy timeout for cloud-sync friendliness.
|
||||
- **[time_calculator.py](core/time_calculator.py)** — Internal `work_minutes: int`. `calculate_overtime(unit_minutes=30)` truncates to user-selectable unit (15/30/60). `work_hours` is read-only property.
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006.
|
||||
- **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold.
|
||||
- **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0).
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys.
|
||||
- **[version.py](core/version.py)** — `__version__` single source of truth.
|
||||
### Core (`core/`)
|
||||
- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`.
|
||||
- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`.
|
||||
- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required.
|
||||
- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en.
|
||||
- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback.
|
||||
- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`.
|
||||
- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings.
|
||||
|
||||
### ui/
|
||||
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz with hot-path caching. Thin delegating shell — heavy work split into controllers below. Single-instance via `QLocalServer "ClockOutCalculatorInstance"`. Inline edit on clock-in/out labels (click). Auto-extracts updater.exe from PyInstaller `_MEIPASS` on first run.
|
||||
- **[onboarding_view.py](ui/onboarding_view.py)** — 5-step wizard (welcome / work pattern / clock-in detection / leave+salary / discord). Forced on first launch (`ONBOARDING_COMPLETED=false`). Re-runnable from Help dialog.
|
||||
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hours+minutes spinboxes, language combo, font scale, high-contrast, DB path override, Discord webhook URL, Gitea feedback token, monthly goals, CSV import.
|
||||
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns). Salary card on monthly. Goal progress widget. matplotlib charts via `chart_widget.py`.
|
||||
- **[today_summary.py](ui/today_summary.py)** — Post-clockout card (hours/breaks/overtime/salary). Auto-hidden on next clock-in.
|
||||
- **[goal_widget.py](ui/goal_widget.py)** — Monthly overtime cap + daily avg progress bars. Hidden when both goals=0.
|
||||
- **[meal_time_dialog.py](ui/meal_time_dialog.py)** — Lunch/dinner real start-end input.
|
||||
- **[past_record_dialog.py](ui/past_record_dialog.py)** — Manual past-day entry (calendar right-click).
|
||||
- **[leave_calendar_view.py](ui/leave_calendar_view.py)** — Color-coded leave usage calendar.
|
||||
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless time display.
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button.
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`.
|
||||
- **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay.
|
||||
- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n).
|
||||
### UI (`ui/`)
|
||||
- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R).
|
||||
- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`.
|
||||
- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button.
|
||||
- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible.
|
||||
- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension).
|
||||
- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing.
|
||||
|
||||
### ui/controllers/
|
||||
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
|
||||
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
|
||||
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
|
||||
- **[meal_controller.py](ui/controllers/meal_controller.py)** — Lunch/dinner toggle + label refresh, extracted from `main_window.py` in v2.7.0. Same controller pattern as Lock/AutoLunch/Notification.
|
||||
|
||||
### ui/ (cross-cutting)
|
||||
- **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
|
||||
- **[styles.py](ui/styles.py)** — Shared QSS / color tokens.
|
||||
|
||||
### utils/
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
|
||||
- **[lock_detector.py](utils/lock_detector.py)** — `is_screen_locked()` via Win32 `OpenInputDesktop` + `GetUserObjectInformation`.
|
||||
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
|
||||
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
|
||||
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
|
||||
- **[csv_exporter.py](utils/csv_exporter.py)** — Same standard format as importer. Round-trips with `csv_importer`.
|
||||
- **[resource_manager.py](utils/resource_manager.py)** — PyInstaller `_MEIPASS`-aware path resolver for icons / assets.
|
||||
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
|
||||
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
|
||||
### Utils (`utils/`)
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy.
|
||||
- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked).
|
||||
- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`.
|
||||
- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production.
|
||||
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n.
|
||||
- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n.
|
||||
|
||||
### Top-level
|
||||
- **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow.
|
||||
- **[updater.py](updater.py)** — Standalone helper. `--pid <main_pid> --new <new_exe> --target <target_exe>`. Waits for main exit, replaces, relaunches. Backup `.bak` for rollback.
|
||||
- **[updater.spec](updater.spec)** — PyInstaller spec (~6MB, no PyQt deps).
|
||||
- **[main.spec](main.spec)** — Embeds `build/staging/updater.exe` as data (release.ps1 stages it).
|
||||
- **[release.ps1](release.ps1)** — One-shot release: bump version → tests → build both exe → tag push → Gitea Release + asset upload. Optional Authenticode signing via `$env:CODE_SIGN_CERT`.
|
||||
|
||||
## Time-off accounting in `update_display()`
|
||||
### Time-off accounting in `update_display()`
|
||||
|
||||
Critical invariant — preserve in any change:
|
||||
```python
|
||||
@ -106,56 +72,49 @@ remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_min
|
||||
remaining -= timedelta(minutes=total_time_off) # subtract AFTER, never via break_minutes mutation
|
||||
```
|
||||
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`.
|
||||
|
||||
### Workday rollover
|
||||
|
||||
`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006).
|
||||
|
||||
## Database invariants
|
||||
|
||||
- `work_records.date` UNIQUE.
|
||||
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations from settings; ACTUAL meal times via `break_records.break_type='lunch'/'dinner'`.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable. Don't filter `NOT NULL` — those are manual additions.
|
||||
- `leave_records.days` is FLOAT (1.0/0.5/0.25).
|
||||
- Balance: `SUM(bank.earned) - SUM(usage.used)`.
|
||||
- `notification_log` for daily dedupe (channel+event_type+date).
|
||||
- `crash_log` for unhandled exceptions.
|
||||
- `work_records.date` UNIQUE (one row/day).
|
||||
- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label.
|
||||
- `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25).
|
||||
- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code.
|
||||
|
||||
## Settings system
|
||||
|
||||
Stored as string key-value in `settings` table. Always import keys from [settings_keys.py](core/settings_keys.py). Auto-sync in `save_settings()`:
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor)
|
||||
Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`:
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only)
|
||||
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
|
||||
|
||||
Migration sentinels prevent re-running.
|
||||
Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup.
|
||||
|
||||
## i18n
|
||||
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장.
|
||||
`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal.
|
||||
|
||||
Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched.
|
||||
Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text).
|
||||
|
||||
## Conventions
|
||||
## Conventions and gotchas
|
||||
|
||||
- **Database.get_setting()** returns string. Use `get_setting_int/float/bool()` or `get_settings()` dict.
|
||||
- 24h `datetime` internal. 12h conversion only in `format_time()`.
|
||||
- 1Hz hot path: cache DB calls (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Health/weekly throttled to 5-min.
|
||||
- Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths.
|
||||
- main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py).
|
||||
- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types.
|
||||
- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`.
|
||||
- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover.
|
||||
- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min.
|
||||
- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture.
|
||||
- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests.
|
||||
- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon).
|
||||
|
||||
## Tests
|
||||
|
||||
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
|
||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
|
||||
- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets.
|
||||
|
||||
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
|
||||
|
||||
All should be green before any release.
|
||||
|
||||
## Release flow
|
||||
|
||||
```bash
|
||||
# Edit core/version.py + CHANGELOG.md
|
||||
git add -A && git commit -m "v2.X.Y: ..."
|
||||
.\release.ps1 v2.X.Y
|
||||
```
|
||||
|
||||
Auto-handles: version bump check, pytest+integration tests, two-exe build, ZIP, git tag push, Gitea Release create, asset upload (main.exe + updater.exe + ZIP).
|
||||
All three should be green before any release.
|
||||
|
||||
176
INSTALL.md
176
INSTALL.md
@ -1,45 +1,24 @@
|
||||
# 설치 가이드
|
||||
|
||||
## 일반 사용자 — 빌드된 .exe로 설치 (권장)
|
||||
|
||||
소스 빌드 없이 즉시 사용하려면 Gitea Releases에서 받으세요.
|
||||
|
||||
1. https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator/releases
|
||||
2. 최신 릴리스의 **main.exe** (단일 파일) 다운로드
|
||||
3. 원하는 폴더에 두고 더블클릭으로 실행
|
||||
4. 첫 실행 시 5단계 온보딩 위저드가 안내
|
||||
|
||||
`main.exe` 안에 `updater.exe`가 내장되어 있어 첫 실행 시 같은 폴더로 자동 추출됩니다.
|
||||
이후 새 버전이 올라오면 앱이 알림 → 동의 → 자동 다운로드·교체·재시작합니다.
|
||||
|
||||
> 옵션: 직접 실행하지 않고 ZIP을 받으면 `main.exe + updater.exe`가 같이 들어 있습니다.
|
||||
|
||||
## 개발자 — 소스에서 실행
|
||||
|
||||
### 1. Python 설치
|
||||
## 1. Python 설치
|
||||
|
||||
Python 3.9 이상이 필요합니다.
|
||||
|
||||
#### Windows
|
||||
### Windows
|
||||
1. https://www.python.org/downloads/ 방문
|
||||
2. "Download Python 3.x.x" 클릭
|
||||
3. 설치 시 **"Add Python to PATH"** 체크 필수
|
||||
3. 설치 시 **"Add Python to PATH"** 체크 필수!
|
||||
|
||||
확인:
|
||||
```bash
|
||||
python --version
|
||||
```
|
||||
|
||||
### 2. 프로젝트 다운로드
|
||||
## 2. 프로젝트 다운로드
|
||||
|
||||
```bash
|
||||
git clone https://kindnick-git.duckdns.org/kindnick/Clock_out_Time_Calculator.git
|
||||
cd Clock_out_Time_Calculator
|
||||
```
|
||||
프로젝트를 다운로드하거나 압축을 해제합니다.
|
||||
|
||||
또는 ZIP을 받아 압축 해제.
|
||||
|
||||
### 3. 패키지 설치
|
||||
## 3. 패키지 설치
|
||||
|
||||
프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다.
|
||||
|
||||
@ -47,26 +26,66 @@ cd Clock_out_Time_Calculator
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### 설치되는 패키지 (requirements.txt)
|
||||
1. **PyQt5** — GUI 프레임워크
|
||||
2. **pywin32** — Windows API (이벤트 뷰어, 화면 잠금 감지)
|
||||
3. **python-dateutil** — 날짜 계산
|
||||
4. **matplotlib** — 통계 차트
|
||||
5. **plyer** — 시스템 알림
|
||||
6. **holidays** — 한국 공휴일 자동 등록 (음력 명절 포함)
|
||||
### 설치되는 패키지:
|
||||
1. **PyQt5** - GUI 프레임워크
|
||||
2. **pywin32** - Windows API 접근
|
||||
3. **python-dateutil** - 날짜 계산
|
||||
4. **matplotlib** - 그래프
|
||||
5. **plyer** - 알림 시스템
|
||||
6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함)
|
||||
|
||||
### 4. 실행
|
||||
## 4. 리소스 다운로드 (선택)
|
||||
|
||||
리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다.
|
||||
|
||||
```bash
|
||||
python download_resources.py
|
||||
```
|
||||
|
||||
이 스크립트는 무료 리소스 다운로드 링크를 안내합니다.
|
||||
|
||||
### 수동 다운로드:
|
||||
|
||||
#### 아이콘
|
||||
다음 사이트에서 다운로드:
|
||||
- **Flaticon**: https://www.flaticon.com/
|
||||
- **Material Design Icons**: https://materialdesignicons.com/
|
||||
- **Icons8**: https://icons8.com/
|
||||
|
||||
다운로드 후 `resources/icons/` 폴더에 저장
|
||||
|
||||
필요한 아이콘:
|
||||
- `app_icon.ico` (512x512)
|
||||
- `clock.png`, `timer.png`, `lunch.png`
|
||||
- `calendar.png`, `statistics.png`, `vacation.png`
|
||||
- `settings.png`, `notification.png`
|
||||
|
||||
#### 사운드
|
||||
다음 사이트에서 다운로드:
|
||||
- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천)
|
||||
- **Freesound**: https://freesound.org/
|
||||
- **Zapsplat**: https://www.zapsplat.com/
|
||||
|
||||
다운로드 후 `resources/sounds/` 폴더에 저장
|
||||
|
||||
필요한 사운드:
|
||||
- `clock_out_alarm.wav` - 퇴근시간 알림
|
||||
- `notification.wav` - 일반 알림
|
||||
- `success.wav` - 성공 효과음
|
||||
|
||||
## 5. 실행
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
#### 관리자 권한으로 실행 (권장)
|
||||
### 관리자 권한으로 실행 (권장)
|
||||
|
||||
Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다.
|
||||
|
||||
1. **방법 1**: cmd를 관리자 권한으로 실행
|
||||
- Windows 키 + X → "터미널(관리자)" 또는 "PowerShell(관리자)"
|
||||
- Windows 키 + X
|
||||
- "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택
|
||||
- 프로젝트 폴더로 이동: `cd "경로"`
|
||||
- `python main.py` 실행
|
||||
|
||||
@ -74,39 +93,11 @@ Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있
|
||||
- `python main.py`를 실행하는 배치 파일(.bat) 생성
|
||||
- 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크
|
||||
|
||||
### 5. 첫 실행
|
||||
## 6. 첫 실행
|
||||
|
||||
1. 실행 시 자동으로 데이터베이스(`database.db`) 생성
|
||||
2. 5단계 온보딩 위저드 표시
|
||||
- 환영 → 근무패턴 → 출근 자동 감지 → 연차/시급 → Discord 웹훅(옵션)
|
||||
3. 위저드 완료 후 메인 화면 진입
|
||||
4. 이후 실행에서는 위저드 없이 바로 시작
|
||||
|
||||
### 6. 단축근무자 설정 (예: 7시간 30분)
|
||||
|
||||
온보딩에서 미설정한 경우:
|
||||
1. 설정(`Ctrl+,`) → 근무 시간 → **근무 패턴**
|
||||
2. "단축근무 7시간 30분 (점심 30분)" 또는 사용자 정의 선택
|
||||
3. 시·분 직접 입력 가능 (5분 단위)
|
||||
4. 저장 → 즉시 메인 화면 반영
|
||||
|
||||
## 클라우드 동기화 (여러 PC 공용)
|
||||
|
||||
OneDrive / Dropbox 폴더에 DB를 두면 자동 동기화됩니다 (WAL 모드).
|
||||
|
||||
1. OneDrive/Dropbox 안에 `database.db` 위치 결정
|
||||
2. 설정 → 데이터 관리 → DB 경로 → 변경
|
||||
3. 기존 DB를 새 위치로 복사 → 재시작
|
||||
4. 다른 PC에서도 같은 경로 지정하면 데이터 공유
|
||||
|
||||
## 환경 변수
|
||||
|
||||
| 변수 | 용도 |
|
||||
|------|------|
|
||||
| `CLOCKOUT_DEBUG=1` | `~/.clockout_logs/debug.log`에 디버그 로그 출력 |
|
||||
| `CLOCKOUT_DEBUG_DIR=경로` | 로그 저장 위치 변경 |
|
||||
| `GITEA_TOKEN` | 릴리스 발행 시 PAT (개발자용) |
|
||||
| `CODE_SIGN_CERT` / `CODE_SIGN_PASS` | Authenticode 인증서 경로/암호 (개발자용) |
|
||||
1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성
|
||||
2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지
|
||||
3. 감지된 시간이 출근시간으로 설정됨
|
||||
|
||||
## 문제 해결
|
||||
|
||||
@ -128,57 +119,42 @@ https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||
|
||||
### 이벤트 뷰어 접근 불가
|
||||
- 관리자 권한으로 실행
|
||||
- 또는 메인 화면 출근시각 옆 ✏️ 버튼으로 수동 입력
|
||||
|
||||
### Discord 웹훅 "실패" 표시
|
||||
- v2.3.3 이전 버전에서 발생 — Cloudflare가 Python User-Agent 차단
|
||||
- 최신 버전으로 업데이트하면 해결 (브라우저 UA로 우회)
|
||||
|
||||
### 온보딩 위저드를 다시 보고 싶음
|
||||
- 도움말(F1) → "🚀 온보딩 다시 보기"
|
||||
- 또는 설정에서 수동 입력 모드 사용
|
||||
|
||||
## 업그레이드
|
||||
|
||||
### .exe 사용자
|
||||
- 앱이 자동 감지 → 알림 → 동의 → 자동 처리
|
||||
- 수동 트리거: F5 또는 설정 → 데이터 관리 → "업데이트 확인"
|
||||
|
||||
### 소스 사용자
|
||||
```bash
|
||||
git pull
|
||||
pip install --upgrade -r requirements.txt
|
||||
```
|
||||
|
||||
## 제거
|
||||
|
||||
1. 프로젝트 폴더 삭제 (`main.exe` 또는 소스)
|
||||
2. 데이터 보존하려면 `database.db`만 별도 백업
|
||||
3. 자동 백업: `~/.clockout_backups/` 에 7개 회전 보관 (필요 시 삭제)
|
||||
1. 프로젝트 폴더 삭제
|
||||
2. 패키지 제거 (선택):
|
||||
```bash
|
||||
pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer
|
||||
```
|
||||
|
||||
## 프로덕션 빌드 (PyInstaller)
|
||||
|
||||
소스 없이 실행 파일만 배포하려면:
|
||||
|
||||
```bash
|
||||
# 자가 업데이터 먼저 빌드 (main.spec이 데이터로 임베드)
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
|
||||
# updater.exe를 staging 폴더로 복사 (main.spec --clean 시 보호)
|
||||
mkdir -p build/staging
|
||||
cp dist/updater.exe build/staging/
|
||||
|
||||
# 메인 앱 빌드 (updater.exe 임베드 포함)
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB)
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB)
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터)
|
||||
```
|
||||
|
||||
또는 [release.ps1](release.ps1) 한 번 실행으로 전체 자동화.
|
||||
배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요.
|
||||
자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다.
|
||||
|
||||
빌드 시 주의:
|
||||
- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행
|
||||
- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨
|
||||
- `main.exe` 단일 배포가 가능 (updater.exe는 첫 실행 시 자동 추출)
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력
|
||||
- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경
|
||||
|
||||
## 다음 단계
|
||||
|
||||
설치가 완료되었다면 [README.md](README.md) 와 도움말(F1)을 참고하세요.
|
||||
개발자는 [CLAUDE.md](CLAUDE.md) + [AGENTS.md](AGENTS.md) 도 함께 읽으세요.
|
||||
설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요!
|
||||
|
||||
36
README.md
36
README.md
@ -45,11 +45,9 @@
|
||||
|
||||
### 7. 통계·분석
|
||||
- 주간/월간 요약 + matplotlib 차트
|
||||
- 일별 근무시간 + 연장 누적 막대 그래프 (호버 시 정확한 수치 툴팁)
|
||||
- 일별 근무시간 + 연장 누적 막대 그래프
|
||||
- 요일별 평균 근무시간
|
||||
- 출근 시각 분포 히스토그램 (30분 단위 + 평균선)
|
||||
- 근무 패턴 인사이트
|
||||
- **시급 옵션 활성 시 추정 급여** (월간 + 오늘 요약)
|
||||
- 근무 패턴 인사이트 (정적 통계 요약)
|
||||
|
||||
### 8. 공휴일 관리
|
||||
- 한국 공휴일 자동 등록 (`holidays` 패키지)
|
||||
@ -71,35 +69,13 @@
|
||||
- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작
|
||||
- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거
|
||||
- 실패 시 자동 롤백
|
||||
- **main.exe 단독 배포** (updater.exe 내장, 첫 실행 시 자동 추출)
|
||||
|
||||
### 12. 첫 실행 온보딩 위저드
|
||||
- 신규 사용자: 5단계 (환영 → 근무패턴 → 출근 감지 → 연차/시급 → Discord) 강제 표시
|
||||
- 기존 사용자: 자동 완료 처리 + 도움말(F1) → "🚀 온보딩 다시 보기"
|
||||
|
||||
### 13. 사용자 친화 기능
|
||||
- **메인 화면 인라인 편집** — 출퇴근 시각 라벨 클릭으로 즉시 수정
|
||||
- **퇴근 후 "오늘 요약" 카드** — 총 근무·점심·외출·연장·추정급여 한눈에
|
||||
- **장시간 근무 휴식 권고** — 4시간 연속 근무 시 토스트 + Discord push
|
||||
- **점심/저녁 실제 시간 입력** — 버튼 우클릭 → 시작·종료 시각
|
||||
- **캘린더 우클릭 → 과거 일자 추가/편집/삭제**
|
||||
- **월간 목표** — 연장근무 상한 / 일평균 목표 + 진행률 게이지
|
||||
- **CSV 가져오기** — 표준 포맷으로 타 도구에서 마이그레이션
|
||||
- **자동 Crash Report** — 예외 발생 시 Gitea Issues 자동 등록 (옵션)
|
||||
- **주간 자동 리포트** — 월요일 첫 출근 시 지난주 요약 push
|
||||
- **휴가 캘린더** — 종일/반차/반반차 색상 구분
|
||||
- **Discord 웹훅 알림** — 출퇴근/휴식권고/주간리포트 모바일 push (옵션, 서버 0)
|
||||
|
||||
### 14. 접근성
|
||||
- 글꼴 크기 100% / 125% / 150%
|
||||
- 고대비 모드 (검정 배경 + 노란 텍스트)
|
||||
|
||||
### 16. 다국어 지원 (i18n)
|
||||
- 한국어 / English 전환 (재시작 또는 즉시 갱신)
|
||||
- 알림 메시지·UI 라벨 30+ 카테고리
|
||||
### 12. 다국어 지원 (i18n)
|
||||
- 한국어 / English 전환
|
||||
- 알림 메시지·UI 라벨 28개 카테고리
|
||||
- HelpView 6개 탭 ko/en HTML 콘텐츠
|
||||
|
||||
### 17. 단축키
|
||||
### 13. 단축키
|
||||
- `Ctrl+O` 출/퇴근 토글
|
||||
- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글
|
||||
- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말
|
||||
|
||||
@ -11,9 +11,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
|
||||
# 백그라운드 휴일 동기화 스레드 비활성화 (DB lock / segfault 방지)
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
@ -90,7 +87,7 @@ def test_stats_view():
|
||||
from ui.stats_view import StatsView
|
||||
dlg = StatsView(db=db)
|
||||
# 데이터 없어도 정상 로드
|
||||
assert dlg.weekly_total_card is not None
|
||||
assert dlg.weekly_total_hours.text() is not None
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
@ -104,25 +101,12 @@ def test_main_window_init():
|
||||
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
||||
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
||||
from ui.main_window import 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)
|
||||
w = MainWindow()
|
||||
# 기본 상태
|
||||
assert w.is_clocked_in == False # 퇴근 완료 기록이므로 False
|
||||
assert w.is_clocked_in == False
|
||||
assert w.lunch_break_enabled == False
|
||||
# auto_lunch 캐시 초기 None (AutoLunchManager 낶)
|
||||
assert w._auto_lunch._enabled_cache is None
|
||||
# auto_lunch 캐시 초기 None
|
||||
assert w._auto_lunch_enabled_cache is None
|
||||
# 단축키 7개 등록되었는지
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
shortcuts = w.findChildren(QShortcut)
|
||||
|
||||
@ -9,7 +9,6 @@ import sys
|
||||
import tempfile
|
||||
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
||||
|
||||
@ -15,11 +15,6 @@ from pathlib import Path
|
||||
|
||||
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 = []
|
||||
FAIL = []
|
||||
WARN = []
|
||||
@ -490,302 +485,6 @@ def s35_lock():
|
||||
# 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 시나리오 36-50: v2.3+ 신규 기능 (온보딩, Discord, 급여, 목표, CSV, 알림 dedupe 등)
|
||||
# ============================================================
|
||||
|
||||
@case("S36. 신규 DB는 onboarding_completed = 'false' (위저드 강제)")
|
||||
def s36_onboarding_new():
|
||||
db = fresh_db('s36')
|
||||
assert db.get_setting('onboarding_completed') == 'false'
|
||||
|
||||
|
||||
@case("S37. 기존 사용자 (work_records 있음) → 자동 완료")
|
||||
def s37_onboarding_existing():
|
||||
db = fresh_db('s37')
|
||||
# work_record 1건 추가 후 마이그레이션 재실행
|
||||
today = date.today().isoformat()
|
||||
db.add_work_record(today, '09:00:00', is_manual=True)
|
||||
# 다시 호출 (init_database에서 호출되는 헬퍼)
|
||||
db.migrate_v23_onboarding_for_existing()
|
||||
assert db.get_setting('onboarding_completed') == 'true'
|
||||
|
||||
|
||||
@case("S38. salary.estimate_pay: 시급 0원 → 모두 0")
|
||||
def s38_salary_zero_wage():
|
||||
from core.salary import estimate_pay
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
|
||||
assert out['base'] == 0 and out['overtime'] == 0 and out['total'] == 0
|
||||
|
||||
|
||||
@case("S39. salary.estimate_pay: 8h 근무 + 30분 연장, 시급 10000 → base 75000 + ot 7500")
|
||||
def s39_salary_basic():
|
||||
from core.salary import estimate_pay
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 8.0, 'overtime_minutes': 30}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=1.5,
|
||||
)
|
||||
# 정규 = 8 - 0.5 = 7.5h × 10000 = 75000
|
||||
# 연장 = 0.5h × 10000 × 1.5 = 7500
|
||||
assert abs(out['base'] - 75000) < 0.01, out['base']
|
||||
assert abs(out['overtime'] - 7500) < 0.01, out['overtime']
|
||||
assert abs(out['total'] - 82500) < 0.01
|
||||
|
||||
|
||||
@case("S40. salary.format_won: 콤마 + '원' 접미사")
|
||||
def s40_format_won():
|
||||
from core.salary import format_won
|
||||
assert format_won(0) == '0원'
|
||||
assert format_won(1234567) == '1,234,567원'
|
||||
assert format_won(82500.4) == '82,500원'
|
||||
|
||||
|
||||
@case("S41. CSV 가져오기: 표준 포맷 파싱")
|
||||
def s41_csv_parse():
|
||||
from utils.csv_importer import parse_csv
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_test.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,첫째날\n")
|
||||
f.write("2026-04-02,09:30:00,17:30:00,30,단축\n")
|
||||
rows = parse_csv(p)
|
||||
os.remove(p)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['clock_in'] == '09:00:00' # 정규화 (HH:MM → HH:MM:SS)
|
||||
assert rows[0]['lunch_minutes'] == 60
|
||||
assert rows[1]['lunch_minutes'] == 30
|
||||
assert rows[1]['memo'] == '단축'
|
||||
|
||||
|
||||
@case("S42. CSV 검증 실패: 잘못된 날짜 형식 → ValueError")
|
||||
def s42_csv_invalid():
|
||||
from utils.csv_importer import parse_csv
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_bad.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("not-a-date,09:00,18:00,60,\n")
|
||||
try:
|
||||
parse_csv(p)
|
||||
assert False, "should have raised"
|
||||
except ValueError:
|
||||
pass
|
||||
finally:
|
||||
os.remove(p)
|
||||
|
||||
|
||||
@case("S43. CSV import on_conflict='skip': 기존 일자는 건너뜀")
|
||||
def s43_csv_skip():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s43')
|
||||
# 기존 레코드 1건
|
||||
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
|
||||
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_dup.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,중복\n")
|
||||
f.write("2026-04-02,09:00,18:00,60,신규\n")
|
||||
|
||||
rows = parse_csv(p)
|
||||
added, updated, skipped = import_records(db, rows, on_conflict='skip')
|
||||
os.remove(p)
|
||||
assert added == 1 and updated == 0 and skipped == 1, (added, updated, skipped)
|
||||
# 기존 레코드 보존 확인 (08:30 그대로)
|
||||
rec = db.get_work_record('2026-04-01')
|
||||
assert rec['clock_in'] == '08:30:00'
|
||||
|
||||
|
||||
@case("S44. CSV import on_conflict='overwrite': 기존 일자 덮어씀")
|
||||
def s44_csv_overwrite():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s44')
|
||||
db.add_work_record('2026-04-01', '08:30:00', is_manual=True)
|
||||
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_ov.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
f.write("2026-04-01,09:00,18:00,60,덮어쓰기\n")
|
||||
|
||||
rows = parse_csv(p)
|
||||
added, updated, skipped = import_records(db, rows, on_conflict='overwrite')
|
||||
os.remove(p)
|
||||
assert updated == 1 and added == 0
|
||||
rec = db.get_work_record('2026-04-01')
|
||||
assert rec['clock_in'] == '09:00:00'
|
||||
|
||||
|
||||
@case("S45. notification_log dedupe: 같은 (channel, event_type) 같은 날 1회")
|
||||
def s45_notification_dedupe():
|
||||
db = fresh_db('s45')
|
||||
assert not db.has_notification_today('discord', 'weekly_report')
|
||||
db.log_notification('discord', 'weekly_report', payload='test', success=True)
|
||||
assert db.has_notification_today('discord', 'weekly_report')
|
||||
# 다른 event_type은 별개
|
||||
assert not db.has_notification_today('discord', 'clock_in')
|
||||
|
||||
|
||||
@case("S46. add_meal_record: 12:00-13:00 → 60분 누적")
|
||||
def s46_meal_record():
|
||||
db = fresh_db('s46')
|
||||
today = date.today().isoformat()
|
||||
# 오늘이 아닌 날짜로 add (work_record 미존재 OK)
|
||||
db.add_meal_record('2026-04-01', '12:00:00', '13:00:00', meal_type='lunch')
|
||||
db.add_meal_record('2026-04-01', '18:30:00', '19:00:00', meal_type='dinner')
|
||||
# get_meal_minutes_today은 오늘만 → 일반화된 검증은 SQL로
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='lunch'",
|
||||
('2026-04-01',))
|
||||
assert cur.fetchone()[0] == 60
|
||||
cur.execute("SELECT SUM(total_minutes) FROM break_records WHERE date = ? AND break_type='dinner'",
|
||||
('2026-04-01',))
|
||||
assert cur.fetchone()[0] == 30
|
||||
conn.close()
|
||||
|
||||
|
||||
@case("S47. crash_log 자동 생성 + 기록")
|
||||
def s47_crash_log():
|
||||
from utils.crash_handler import _log_crash
|
||||
db = fresh_db('s47')
|
||||
_log_crash(db, 'TestException', 'sample message', 'Traceback ...', 'v2.6.0')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM crash_log WHERE exception_type = 'TestException'")
|
||||
assert cur.fetchone()[0] == 1
|
||||
cur.execute("SELECT message, app_version FROM crash_log WHERE exception_type = 'TestException'")
|
||||
msg, ver = cur.fetchone()
|
||||
assert msg == 'sample message' and ver == 'v2.6.0'
|
||||
conn.close()
|
||||
|
||||
|
||||
@case("S48. updater_client.is_newer: 정확한 semver 비교")
|
||||
def s48_updater_compare():
|
||||
from utils.updater_client import is_newer
|
||||
assert is_newer('v2.7.0', '2.6.0')
|
||||
assert is_newer('2.6.1', 'v2.6.0')
|
||||
assert not is_newer('v2.6.0', 'v2.6.0')
|
||||
assert not is_newer('v2.5.0', 'v2.6.0')
|
||||
assert is_newer('v2.10.0', 'v2.9.99') # 자릿수 비교가 아니라 정수 비교
|
||||
|
||||
|
||||
@case("S49. Discord webhook URL 비어있으면 silent False (네트워크 안 탐)")
|
||||
def s49_discord_empty():
|
||||
from utils.discord_webhook import send, send_test
|
||||
assert send('', 't', 'd') is False
|
||||
assert send('http://invalid', 't', 'd') is False # https:// 아님
|
||||
assert send_test('') is False
|
||||
|
||||
|
||||
@case("S50. Goal 설정: 0 = 비활성 / 양수 = 활성")
|
||||
def s50_goals():
|
||||
db = fresh_db('s50')
|
||||
# 기본값 확인 (0)
|
||||
assert db.get_setting_int('goal_overtime_max_monthly', 0) == 0
|
||||
assert db.get_setting_int('goal_avg_hours_daily', 0) == 0
|
||||
# 활성화
|
||||
db.save_settings({'goal_overtime_max_monthly': 1200, 'goal_avg_hours_daily': 8})
|
||||
assert db.get_setting_int('goal_overtime_max_monthly') == 1200
|
||||
assert db.get_setting_int('goal_avg_hours_daily') == 8
|
||||
|
||||
|
||||
@case("S51. 글꼴 크기 / 고대비 설정 키")
|
||||
def s51_accessibility_keys():
|
||||
db = fresh_db('s51')
|
||||
# 기본값
|
||||
assert db.get_setting_float('font_scale', 1.0) == 1.0
|
||||
assert db.get_setting_bool('high_contrast', False) is False
|
||||
# 변경
|
||||
db.save_settings({'font_scale': 1.5, 'high_contrast': True})
|
||||
assert db.get_setting_float('font_scale') == 1.5
|
||||
assert db.get_setting_bool('high_contrast') is True
|
||||
|
||||
|
||||
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
|
||||
def s52b_planned_leave():
|
||||
db = fresh_db('s52b')
|
||||
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||
assert db.has_full_day_leave('2026-05-15')
|
||||
assert db.get_leave_minutes_for('2026-05-15') == 480
|
||||
# 다른 날엔 영향 없음
|
||||
assert not db.has_full_day_leave('2026-05-16')
|
||||
|
||||
|
||||
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
|
||||
def s52c_recurring_leave():
|
||||
db = fresh_db('s52c')
|
||||
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
# 2026-05-01 = Friday
|
||||
assert db.get_leave_minutes_for('2026-05-01') == 240
|
||||
# Monday
|
||||
assert db.get_leave_minutes_for('2026-05-04') == 0
|
||||
# 종일 아님
|
||||
assert not db.has_full_day_leave('2026-05-01')
|
||||
|
||||
|
||||
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
|
||||
def s52d_effective():
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52d')
|
||||
db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||
calc = TimeCalculator(work_minutes=480)
|
||||
target = datetime(2026, 5, 15)
|
||||
assert calc.effective_work_minutes(target, db) == 240
|
||||
# 다른 날엔 변화 없음
|
||||
other = datetime(2026, 5, 16)
|
||||
assert calc.effective_work_minutes(other, db) == 480
|
||||
|
||||
|
||||
@case("S52E. effective_work_minutes: 종일 연차 시 0")
|
||||
def s52e_full_day():
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52e')
|
||||
db.add_leave_record('2026-05-15', '연차', 1.0)
|
||||
calc = TimeCalculator(work_minutes=480)
|
||||
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
|
||||
|
||||
|
||||
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
|
||||
def s52a_holiday_hotpath():
|
||||
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52a')
|
||||
holiday_date = '2026-05-01' # 근로자의 날
|
||||
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
|
||||
|
||||
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
# 휴일 인식
|
||||
assert calc.is_non_working_day(ci, db)
|
||||
assert calc.get_day_type(ci, db) == 'holiday'
|
||||
|
||||
# 출근 1분 후: 적립 0 (30분 단위 절삭)
|
||||
now1 = ci + timedelta(minutes=1)
|
||||
actual, earned = calc.calculate_holiday_overtime(ci, now1)
|
||||
assert actual == 1 and earned == 0
|
||||
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
|
||||
now30 = ci + timedelta(minutes=30)
|
||||
actual, earned = calc.calculate_holiday_overtime(ci, now30)
|
||||
assert actual == 30 and earned == 30
|
||||
|
||||
|
||||
@case("S52. CSV import + overtime 적립까지 정상 동작")
|
||||
def s52_csv_overtime():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
db = fresh_db('s52')
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_ot.csv')
|
||||
with open(p, 'w', encoding='utf-8') as f:
|
||||
f.write("date,clock_in,clock_out,lunch_minutes,memo\n")
|
||||
# 8h 근무 + 점심 60m + 90분 연장 → 90분 적립 예상
|
||||
f.write("2026-04-01,09:00,19:30,60,연장근무\n")
|
||||
rows = parse_csv(p)
|
||||
added, _, _ = import_records(db, rows, on_conflict='skip')
|
||||
os.remove(p)
|
||||
assert added == 1
|
||||
bal = db.get_total_overtime_balance()
|
||||
assert bal == 90, f"overtime balance: {bal}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Run all
|
||||
# ============================================================
|
||||
@ -821,28 +520,6 @@ def main():
|
||||
s33_short_overtime()
|
||||
s34_format()
|
||||
s35_lock()
|
||||
s36_onboarding_new()
|
||||
s37_onboarding_existing()
|
||||
s38_salary_zero_wage()
|
||||
s39_salary_basic()
|
||||
s40_format_won()
|
||||
s41_csv_parse()
|
||||
s42_csv_invalid()
|
||||
s43_csv_skip()
|
||||
s44_csv_overwrite()
|
||||
s45_notification_dedupe()
|
||||
s46_meal_record()
|
||||
s47_crash_log()
|
||||
s48_updater_compare()
|
||||
s49_discord_empty()
|
||||
s50_goals()
|
||||
s51_accessibility_keys()
|
||||
s52a_holiday_hotpath()
|
||||
s52b_planned_leave()
|
||||
s52c_recurring_leave()
|
||||
s52d_effective()
|
||||
s52e_full_day()
|
||||
s52_csv_overtime()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
|
||||
1217
core/achievements.py
1217
core/achievements.py
File diff suppressed because it is too large
Load Diff
2113
core/database.py
2113
core/database.py
File diff suppressed because it is too large
Load Diff
2132
core/i18n.py
2132
core/i18n.py
File diff suppressed because it is too large
Load Diff
@ -7,25 +7,11 @@ from typing import Optional
|
||||
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
|
||||
|
||||
from core.settings_keys import (
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
|
||||
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
|
||||
OVERTIME_UNIT,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
)
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
def _get_int_setting(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||
"""db에서 정수 설정값을 안전하게 읽어 [lo, hi]로 클램프."""
|
||||
if db is None:
|
||||
return default
|
||||
try:
|
||||
v = int(db.get_setting(key, str(default)) or default)
|
||||
except (ValueError, TypeError):
|
||||
v = default
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
class Notifier(QObject):
|
||||
"""알림 시스템 클래스"""
|
||||
|
||||
@ -41,7 +27,6 @@ class Notifier(QObject):
|
||||
# 알림 상태 추적
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_dinner = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
@ -100,52 +85,46 @@ class Notifier(QObject):
|
||||
|
||||
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
|
||||
current_time: Optional[datetime] = None):
|
||||
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
|
||||
"""
|
||||
점심시간 등록 알림
|
||||
Args:
|
||||
clock_in_time: 출근 시간
|
||||
lunch_enabled: 점심시간 등록 여부
|
||||
current_time: 현재 시간
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
if not self._enabled(NOTIF_LUNCH):
|
||||
return
|
||||
|
||||
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
|
||||
if lunch_enabled or self.notified_lunch:
|
||||
return
|
||||
|
||||
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
|
||||
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||
# 출근 후 4시간 경과 (점심시간으로 추정)
|
||||
time_since_clock_in = current_time - clock_in_time
|
||||
if time_since_clock_in.total_seconds() >= 4 * 3600:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.lunch_reminder.title'),
|
||||
tr('notif.lunch_reminder.body'),
|
||||
)
|
||||
self.notified_lunch = True
|
||||
|
||||
def check_dinner_reminder(self, clock_in_time: datetime, dinner_enabled: bool,
|
||||
current_time: Optional[datetime] = None):
|
||||
"""저녁시간 등록 알림. 출근 후 DINNER_REMINDER_HOURS 경과 시.
|
||||
|
||||
야근(연장근무) 사용자가 저녁을 깜빡 잊는 패턴 대응.
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
if not self._enabled(NOTIF_DINNER):
|
||||
return
|
||||
if dinner_enabled or self.notified_dinner:
|
||||
return
|
||||
|
||||
threshold_hours = _get_int_setting(self.db, DINNER_REMINDER_HOURS, 8, 1, 16)
|
||||
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.dinner_reminder.title'),
|
||||
tr('notif.dinner_reminder.body'),
|
||||
)
|
||||
self.notified_dinner = True
|
||||
|
||||
def check_overtime_earning(self, overtime_minutes: int):
|
||||
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
|
||||
"""
|
||||
연장근무 적립 알림
|
||||
Args:
|
||||
overtime_minutes: 예상 연장근무 시간 (분)
|
||||
"""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
|
||||
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
|
||||
if overtime_minutes >= unit and not self.notified_overtime:
|
||||
if overtime_minutes >= 30 and not self.notified_overtime:
|
||||
hours = overtime_minutes // 60
|
||||
mins = overtime_minutes % 60
|
||||
|
||||
from utils.time_format import format_hours_minutes
|
||||
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
|
||||
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_earning.title'),
|
||||
tr('notif.overtime_earning.body', time_str=time_str),
|
||||
@ -153,11 +132,10 @@ class Notifier(QObject):
|
||||
self.notified_overtime = True
|
||||
|
||||
def notify_overtime_threshold(self, total_overtime_hours: float):
|
||||
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
|
||||
"""연장근무 누적 알림 (20시간 이상)"""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
threshold = _get_int_setting(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||
if total_overtime_hours >= threshold and not self.notified_threshold:
|
||||
if total_overtime_hours >= 20 and not self.notified_threshold:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_threshold.title'),
|
||||
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
|
||||
@ -165,11 +143,10 @@ class Notifier(QObject):
|
||||
self.notified_threshold = True
|
||||
|
||||
def notify_health_warning(self, consecutive_overtime_days: int):
|
||||
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
|
||||
"""건강 경고 (연속 연장근무 일수)"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
return
|
||||
threshold = _get_int_setting(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||
if consecutive_overtime_days >= threshold and not self.notified_health:
|
||||
if consecutive_overtime_days >= 3 and not self.notified_health:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.health.title'),
|
||||
tr('notif.health.body', days=consecutive_overtime_days),
|
||||
@ -177,11 +154,10 @@ class Notifier(QObject):
|
||||
self.notified_health = True
|
||||
|
||||
def notify_weekly_hours(self, total_hours: float):
|
||||
"""주 X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
|
||||
"""주 52시간 경고"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
return
|
||||
threshold = _get_int_setting(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||
if total_hours > threshold and not self.notified_weekly:
|
||||
if total_hours > 52 and not self.notified_weekly:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.weekly_52.title'),
|
||||
tr('notif.weekly_52.body', hours=total_hours),
|
||||
@ -191,7 +167,7 @@ class Notifier(QObject):
|
||||
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
|
||||
"""장시간 연속 근무 휴식 알림.
|
||||
|
||||
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
|
||||
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
|
||||
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
|
||||
"""
|
||||
if current_time is None:
|
||||
@ -202,12 +178,16 @@ class Notifier(QObject):
|
||||
return
|
||||
if self.db.has_notification_today('system', 'health_break'):
|
||||
return
|
||||
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
|
||||
try:
|
||||
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
|
||||
except (ValueError, TypeError):
|
||||
threshold_hours = 4
|
||||
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
|
||||
if elapsed_sec >= threshold_hours * 3600:
|
||||
from core.i18n import tr
|
||||
self.notification_signal.emit(
|
||||
tr('notif.health_break.title'),
|
||||
tr('notif.health_break.body', hours=threshold_hours),
|
||||
tr('notif.health_break.title') if False else "🌿 휴식 권고",
|
||||
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
|
||||
)
|
||||
self.db.log_notification('system', 'health_break')
|
||||
|
||||
@ -215,7 +195,6 @@ class Notifier(QObject):
|
||||
"""알림 상태 리셋 (날짜 변경 시)"""
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_dinner = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
"""
|
||||
반복 연차 패턴 파싱 + 일자 확장.
|
||||
|
||||
지원 패턴:
|
||||
- 'weekly:friday' — 매주 금요일
|
||||
- 'weekly:mon,wed,fri' — 매주 월·수·금
|
||||
- 'biweekly:friday' — 격주 금요일 (start_date 기준)
|
||||
- 'monthly:15' — 매월 15일 (해당 월에 그 일이 없으면 스킵)
|
||||
|
||||
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range()로 펼친다.
|
||||
같은 날짜에 leave_records(구체 인스턴스)가 이미 있으면 호출자가 합산 로직 책임.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
'mon': 0, 'monday': 0,
|
||||
'tue': 1, 'tuesday': 1,
|
||||
'wed': 2, 'wednesday': 2,
|
||||
'thu': 3, 'thursday': 3,
|
||||
'fri': 4, 'friday': 4,
|
||||
'sat': 5, 'saturday': 5,
|
||||
'sun': 6, 'sunday': 6,
|
||||
}
|
||||
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
|
||||
@dataclass
|
||||
class Occurrence:
|
||||
"""반복 패턴이 펼친 한 인스턴스."""
|
||||
date: date
|
||||
leave_type: str
|
||||
days: float
|
||||
pattern: str
|
||||
memo: str = ''
|
||||
recurring_id: Optional[int] = None
|
||||
|
||||
|
||||
def _parse_pattern(pattern: str):
|
||||
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
|
||||
if not pattern or ':' not in pattern:
|
||||
return None
|
||||
kind, rest = pattern.split(':', 1)
|
||||
kind = kind.strip().lower()
|
||||
rest = rest.strip().lower()
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
days = [d.strip() for d in rest.split(',') if d.strip()]
|
||||
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
|
||||
if not weekdays:
|
||||
return None
|
||||
return (kind, weekdays)
|
||||
if kind == 'monthly':
|
||||
try:
|
||||
day_of_month = int(rest)
|
||||
if 1 <= day_of_month <= 31:
|
||||
return (kind, day_of_month)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def matches(rec: Dict, target_date: date) -> bool:
|
||||
"""단일 패턴 rec이 target_date에 매치되는지."""
|
||||
start = _parse_date(rec.get('start_date'))
|
||||
end = _parse_date(rec.get('end_date'))
|
||||
if start is None:
|
||||
return False
|
||||
if target_date < start:
|
||||
return False
|
||||
if end is not None and target_date > end:
|
||||
return False
|
||||
|
||||
parsed = _parse_pattern(rec.get('pattern', ''))
|
||||
if parsed is None:
|
||||
return False
|
||||
kind, info = parsed
|
||||
|
||||
if kind == 'weekly':
|
||||
return target_date.weekday() in info
|
||||
|
||||
if kind == 'biweekly':
|
||||
if target_date.weekday() not in info:
|
||||
return False
|
||||
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
|
||||
weeks = (target_date - start).days // 7
|
||||
return weeks % 2 == 0
|
||||
|
||||
if kind == 'monthly':
|
||||
return target_date.day == info
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
|
||||
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
|
||||
|
||||
반환은 날짜 오름차순. 같은 날 여러 패턴이 매치되면 모두 포함.
|
||||
"""
|
||||
out: List[Occurrence] = []
|
||||
if start > end:
|
||||
return out
|
||||
cur = start
|
||||
while cur <= end:
|
||||
for r in records:
|
||||
try:
|
||||
if matches(r, cur):
|
||||
out.append(Occurrence(
|
||||
date=cur,
|
||||
leave_type=r.get('leave_type') or '연차',
|
||||
days=float(r.get('days') or 0),
|
||||
pattern=r.get('pattern', ''),
|
||||
memo=r.get('memo') or '',
|
||||
recurring_id=r.get('id'),
|
||||
))
|
||||
except Exception:
|
||||
# 잘못된 패턴 1개가 전체를 망치지 않도록
|
||||
continue
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
|
||||
"""단일 날짜에 매치되는 인스턴스만."""
|
||||
return expand_for_range(records, target_date, target_date)
|
||||
|
||||
|
||||
def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def describe_pattern(pattern: str) -> str:
|
||||
"""사용자에게 보여줄 패턴 설명."""
|
||||
parsed = _parse_pattern(pattern)
|
||||
if parsed is None:
|
||||
return pattern
|
||||
kind, info = parsed
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info]
|
||||
prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly')
|
||||
return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names))
|
||||
if kind == 'monthly':
|
||||
return tr('recurring.pattern_monthly', day=info)
|
||||
return pattern
|
||||
@ -22,17 +22,9 @@ CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로
|
||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||
NOTIF_LUNCH = 'notification_lunch'
|
||||
NOTIF_DINNER = 'notification_dinner'
|
||||
NOTIF_OVERTIME = 'notification_overtime'
|
||||
NOTIF_HEALTH = 'notification_health'
|
||||
|
||||
# 알림 임계값 (설정화 — 이전엔 하드코딩이던 것)
|
||||
LUNCH_REMINDER_HOURS = 'lunch_reminder_hours' # 출근 후 N시간 경과 시 점심 미등록 알림 (기본 4)
|
||||
DINNER_REMINDER_HOURS = 'dinner_reminder_hours' # 출근 후 N시간 경과 시 저녁 미등록 알림 (기본 8)
|
||||
OVERTIME_THRESHOLD_HOURS = 'overtime_threshold_hours' # 누적 적립 알림 시간 (기본 20)
|
||||
WEEKLY_HOURS_THRESHOLD = 'weekly_hours_threshold' # 주 X시간 경고 (기본 52, 한국 노동법)
|
||||
HEALTH_CONSECUTIVE_OT_DAYS = 'health_consecutive_ot_days' # 연속 연장근무 일수 경고 (기본 3)
|
||||
|
||||
# 연차
|
||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||
@ -45,8 +37,6 @@ THEME = 'theme'
|
||||
TIME_FORMAT = 'time_format'
|
||||
LANGUAGE = 'language'
|
||||
OVERTIME_UNIT = 'overtime_unit'
|
||||
FONT_SCALE = 'font_scale' # '1.0' / '1.25' / '1.5'
|
||||
HIGH_CONTRAST = 'high_contrast'
|
||||
|
||||
# 통합/외부
|
||||
DB_PATH_OVERRIDE = 'db_path_override'
|
||||
@ -54,10 +44,6 @@ DB_PATH_OVERRIDE = 'db_path_override'
|
||||
# 백업
|
||||
LAST_BACKUP_DATE = 'last_backup_date'
|
||||
|
||||
# Crash Report (Gitea Issues 통합 — 옵션)
|
||||
GITEA_FEEDBACK_TOKEN = 'gitea_feedback_token' # PAT (저장소 issue 쓰기 권한)
|
||||
GITEA_FEEDBACK_ENABLED = 'gitea_feedback_enabled'
|
||||
|
||||
# === v2.3.0 신규 ===
|
||||
# 온보딩
|
||||
ONBOARDING_COMPLETED = 'onboarding_completed'
|
||||
@ -71,10 +57,6 @@ OVERTIME_RATE = 'overtime_rate' # 1.5
|
||||
HEALTH_BREAK_ENABLED = 'health_break_enabled'
|
||||
HEALTH_BREAK_HOURS = 'health_break_hours' # 기본 4
|
||||
|
||||
# 목표
|
||||
GOAL_OVERTIME_MAX_MONTHLY = 'goal_overtime_max_monthly' # 월 연장근무 상한 (분)
|
||||
GOAL_AVG_HOURS_DAILY = 'goal_avg_hours_daily' # 일평균 목표 (시간, float)
|
||||
|
||||
# Discord 웹훅
|
||||
DISCORD_WEBHOOK_URL = 'discord_webhook_url'
|
||||
DISCORD_NOTIF_CLOCK_IN = 'discord_notif_clock_in'
|
||||
@ -84,22 +66,3 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
|
||||
# 마이그레이션 sentinel
|
||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
||||
|
||||
# === v2.8.0 도전과제 시스템 ===
|
||||
# 사용자 메타
|
||||
BIRTHDAY = 'birthday' # MM-DD 형식, 빈 문자열이면 비활성
|
||||
HIRE_DATE = 'hire_date' # YYYY-MM-DD, 첫 work_records 자동 기록
|
||||
|
||||
# 뷰 진입 카운터 (도전과제 + 사용 통계용)
|
||||
STAT_WEEKLY_VIEW_COUNT = 'stat_weekly_view_count'
|
||||
STAT_MONTHLY_VIEW_COUNT = 'stat_monthly_view_count'
|
||||
STAT_PATTERN_VIEW_COUNT = 'stat_pattern_view_count'
|
||||
CALENDAR_VIEW_COUNT = 'calendar_view_count'
|
||||
LEAVE_CALENDAR_VIEW_COUNT = 'leave_calendar_view_count'
|
||||
DAILY_REPORT_COUNT = 'daily_report_count'
|
||||
ACHIEVEMENTS_VIEW_COUNT = 'achievements_view_count'
|
||||
CHART_HOVER_DISCOVERED = 'chart_hover_discovered'
|
||||
|
||||
# 도전과제 알림
|
||||
NOTIF_ACHIEVEMENT = 'notification_achievement'
|
||||
DISCORD_NOTIF_ACHIEVEMENT = 'discord_notif_achievement'
|
||||
|
||||
@ -21,8 +21,7 @@ class TimeCalculator:
|
||||
if work_minutes is not None:
|
||||
self.work_minutes = int(work_minutes)
|
||||
elif work_hours is not None:
|
||||
# 은행 반올림(banker's rounding) 회피: 6.5시간 → 390분이 되도록 명시적 반올림
|
||||
self.work_minutes = int(float(work_hours) * 60 + 0.5)
|
||||
self.work_minutes = int(round(float(work_hours) * 60))
|
||||
else:
|
||||
self.work_minutes = 480
|
||||
|
||||
@ -237,55 +236,6 @@ class TimeCalculator:
|
||||
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
||||
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
||||
|
||||
def effective_work_minutes(self, date_obj: datetime, db) -> int:
|
||||
"""해당 날짜의 실효 근무 시간(분).
|
||||
|
||||
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
|
||||
종일 연차(>= work_minutes)면 0 반환.
|
||||
|
||||
Args:
|
||||
date_obj: 기준 날짜 (datetime; 시각은 무시)
|
||||
db: Database 인스턴스 (None이면 차감 없음)
|
||||
|
||||
Returns:
|
||||
실효 work_minutes (>= 0)
|
||||
"""
|
||||
if db is None:
|
||||
return self.work_minutes
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
leave_min = db.get_leave_minutes_for(date_str)
|
||||
return max(0, self.work_minutes - leave_min)
|
||||
|
||||
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
break_minutes: int = 0,
|
||||
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||
"""
|
||||
휴일/주말 근무: 모든 시간을 연장근무로 계산.
|
||||
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
current_time: 현재(또는 퇴근) 시간
|
||||
include_lunch/dinner: 식사 시간 차감 여부
|
||||
break_minutes: 외출 시간 (분) — 연장근무에서 제외
|
||||
unit_minutes: 적립 단위 (15/30/60)
|
||||
|
||||
Returns:
|
||||
(실제 연장 분, 적립 분) — 둘 다 0 이상.
|
||||
"""
|
||||
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
|
||||
if include_lunch:
|
||||
elapsed_minutes -= self.lunch_duration_minutes
|
||||
if include_dinner:
|
||||
elapsed_minutes -= self.dinner_duration_minutes
|
||||
elapsed_minutes -= break_minutes
|
||||
elapsed_minutes = max(0, elapsed_minutes)
|
||||
|
||||
unit = unit_minutes if unit_minutes > 0 else 30
|
||||
earned = (elapsed_minutes // unit) * unit
|
||||
return elapsed_minutes, earned
|
||||
|
||||
def is_weekend(self, date_obj: datetime) -> bool:
|
||||
"""
|
||||
주말 여부 확인
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.12.0'
|
||||
__version__ = '2.3.3'
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
18
main.py
18
main.py
@ -96,9 +96,8 @@ def main():
|
||||
)
|
||||
return 1
|
||||
|
||||
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
|
||||
from utils.font_loader import apply_app_font
|
||||
apply_app_font(app, 9)
|
||||
# 폰트 설정
|
||||
app.setFont(QFont("Segoe UI", 9))
|
||||
|
||||
# 필수 패키지 확인
|
||||
if not check_requirements():
|
||||
@ -129,15 +128,6 @@ def main():
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"backup failed: {e}")
|
||||
|
||||
# 전역 예외 후킹 (crash report)
|
||||
try:
|
||||
from utils.crash_handler import install_global_handler
|
||||
from core.version import __version__
|
||||
install_global_handler(db, app_version=__version__)
|
||||
except Exception as e:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"crash handler install failed: {e}")
|
||||
|
||||
# 첫 실행 온보딩 (강제) — ONBOARDING_COMPLETED=true 가 아니면 표시
|
||||
try:
|
||||
from ui.onboarding_view import maybe_show_onboarding
|
||||
@ -146,9 +136,9 @@ def main():
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"onboarding skipped: {e}")
|
||||
|
||||
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
|
||||
# 메인 윈도우 생성 및 표시
|
||||
try:
|
||||
window = MainWindow(db=db)
|
||||
window = MainWindow()
|
||||
|
||||
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
|
||||
def on_new_connection():
|
||||
|
||||
19
main.spec
19
main.spec
@ -14,35 +14,20 @@ if os.path.exists(_staged):
|
||||
elif os.path.exists(_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(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas,
|
||||
hiddenimports=[
|
||||
'holidays', 'holidays.countries.south_korea',
|
||||
'win32evtlog', 'win32evtlogutil',
|
||||
'matplotlib.backends.backend_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
|
||||
'matplotlib.backends.backend_qt5agg',
|
||||
'PyQt5.QtSvg',
|
||||
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
|
||||
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
|
||||
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
|
||||
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
25
release.ps1
25
release.ps1
@ -86,12 +86,12 @@ OkMsg "All checks passed (Version: $Version)"
|
||||
# ====== 1. Bump version.py ======
|
||||
Step "1/7 Bump core/version.py"
|
||||
$verFile = 'core/version.py'
|
||||
$verContent = [System.IO.File]::ReadAllText((Resolve-Path $verFile).Path, [System.Text.Encoding]::UTF8)
|
||||
$verContent = Get-Content $verFile -Raw
|
||||
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
|
||||
if ($verContent -eq $newContent) {
|
||||
Info "Already at $VersionRaw (no change)"
|
||||
} else {
|
||||
if (-not $DryRun) { [System.IO.File]::WriteAllText((Resolve-Path $verFile).Path, $newContent, [System.Text.Encoding]::UTF8) }
|
||||
if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline }
|
||||
OkMsg "$verFile -> $VersionRaw"
|
||||
}
|
||||
|
||||
@ -141,27 +141,6 @@ $mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB)
|
||||
$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB)
|
||||
OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)"
|
||||
|
||||
# Optional: Authenticode signing if cert available (env vars)
|
||||
# Set CODE_SIGN_CERT (path to .pfx) and CODE_SIGN_PASS to enable
|
||||
if ($env:CODE_SIGN_CERT -and (Test-Path $env:CODE_SIGN_CERT)) {
|
||||
Info "Code signing exes (Authenticode)..."
|
||||
$signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue
|
||||
if (-not $signtool) {
|
||||
Info " signtool.exe not found in PATH — skipping signature"
|
||||
} else {
|
||||
$tsUrl = if ($env:CODE_SIGN_TIMESTAMP) { $env:CODE_SIGN_TIMESTAMP } else { 'http://timestamp.digicert.com' }
|
||||
foreach ($exe in 'dist/main.exe', 'dist/updater.exe') {
|
||||
$args = @('sign', '/f', $env:CODE_SIGN_CERT)
|
||||
if ($env:CODE_SIGN_PASS) { $args += '/p'; $args += $env:CODE_SIGN_PASS }
|
||||
$args += '/tr'; $args += $tsUrl; $args += '/td'; $args += 'sha256'
|
||||
$args += '/fd'; $args += 'sha256'; $args += $exe
|
||||
$rc = Invoke-Native signtool $args
|
||||
if ($rc -ne 0) { Info " WARN: sign failed for $exe (exit $rc)" }
|
||||
else { OkMsg " signed $exe" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ====== 4. ZIP ======
|
||||
Step "4/7 ZIP packaging"
|
||||
$zipPath = "dist/ClockOutCalculator-$Version.zip"
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
"""
|
||||
pytest 공통 설정.
|
||||
|
||||
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
@ -1,110 +0,0 @@
|
||||
"""
|
||||
utils.crash_handler 단위 테스트.
|
||||
|
||||
GUI 다이얼로그는 호출하지 않음 (테스트 주체는 _log_crash + _send_to_gitea).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from utils.crash_handler import _log_crash, _send_to_gitea
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
p = os.path.join(tempfile.gettempdir(), 'clockout_crash_ut.db')
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
d = Database(p)
|
||||
yield d
|
||||
try:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class TestLogCrash:
|
||||
def test_creates_table_and_inserts(self, db):
|
||||
_log_crash(db, 'TestExc', 'msg', 'Traceback ...', 'v2.6.0')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT exception_type, message, app_version FROM crash_log")
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 'TestExc'
|
||||
assert row[1] == 'msg'
|
||||
assert row[2] == 'v2.6.0'
|
||||
|
||||
def test_table_idempotent_creation(self, db):
|
||||
# 두 번 호출해도 두 행이 들어가야 (CREATE TABLE IF NOT EXISTS)
|
||||
_log_crash(db, 'A', 'a', 't', 'v1')
|
||||
_log_crash(db, 'B', 'b', 't', 'v1')
|
||||
conn = db.get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM crash_log")
|
||||
count = cur.fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 2
|
||||
|
||||
def test_silent_on_db_error(self, db):
|
||||
# 잘못된 DB 객체를 줘도 예외 전파 안 됨 (안 그러면 후킹 자체가 죽음)
|
||||
broken = MagicMock()
|
||||
broken.get_connection.side_effect = RuntimeError('boom')
|
||||
# raise되면 안 됨
|
||||
_log_crash(broken, 'X', 'x', 'tb', 'v')
|
||||
|
||||
|
||||
class TestSendToGitea:
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 201
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = _send_to_gitea('fake_token', 'title', 'body')
|
||||
assert ok is True
|
||||
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
# PAT 헤더 확인
|
||||
assert req.headers.get('Authorization') == 'token fake_token'
|
||||
# User-Agent 위장
|
||||
assert 'Mozilla' in req.headers.get('User-agent', '')
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_4xx_returns_false(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 401
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
assert _send_to_gitea('bad', 't', 'b') is False
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_network_error(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert _send_to_gitea('t', 't', 'b') is False
|
||||
|
||||
@patch('utils.crash_handler.urllib.request.urlopen')
|
||||
def test_payload_json(self, mock_urlopen):
|
||||
import json as _json
|
||||
resp = MagicMock()
|
||||
resp.status = 201
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
_send_to_gitea('tok', 'TITLE', 'BODY')
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = _json.loads(req.data.decode('utf-8'))
|
||||
assert body['title'] == 'TITLE'
|
||||
assert body['body'] == 'BODY'
|
||||
@ -1,180 +0,0 @@
|
||||
"""
|
||||
utils.csv_importer 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records
|
||||
from core.database import Database
|
||||
|
||||
|
||||
class TestNormalizeTime:
|
||||
def test_hh_mm_to_hh_mm_ss(self):
|
||||
assert _normalize_time('09:00', 'clock_in') == '09:00:00'
|
||||
|
||||
def test_hh_mm_ss_unchanged(self):
|
||||
assert _normalize_time('09:00:00', 'clock_in') == '09:00:00'
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('', 'clock_in')
|
||||
|
||||
def test_invalid_format_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('foo', 'clock_in')
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_time('25:00', 'clock_in')
|
||||
|
||||
|
||||
class TestNormalizeRow:
|
||||
def test_basic_row(self):
|
||||
row = {
|
||||
'date': '2026-04-01',
|
||||
'clock_in': '09:00',
|
||||
'clock_out': '18:00',
|
||||
'lunch_minutes': '60',
|
||||
'memo': '메모',
|
||||
}
|
||||
out = _normalize_row(row)
|
||||
assert out['date'] == '2026-04-01'
|
||||
assert out['clock_in'] == '09:00:00'
|
||||
assert out['clock_out'] == '18:00:00'
|
||||
assert out['lunch_minutes'] == 60
|
||||
assert out['memo'] == '메모'
|
||||
|
||||
def test_optional_clock_out(self):
|
||||
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '0', 'memo': ''}
|
||||
out = _normalize_row(row)
|
||||
assert out['clock_out'] is None
|
||||
|
||||
def test_invalid_date(self):
|
||||
row = {'date': 'not-a-date', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '0', 'memo': ''}
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_row(row)
|
||||
|
||||
def test_negative_lunch_minutes(self):
|
||||
row = {'date': '2026-04-01', 'clock_in': '09:00', 'clock_out': '',
|
||||
'lunch_minutes': '-30', 'memo': ''}
|
||||
with pytest.raises(ValueError):
|
||||
_normalize_row(row)
|
||||
|
||||
|
||||
class TestParseCsv:
|
||||
def _write(self, content: str) -> str:
|
||||
f = tempfile.NamedTemporaryFile('w', encoding='utf-8',
|
||||
delete=False, suffix='.csv', newline='')
|
||||
f.write(content)
|
||||
f.close()
|
||||
return f.name
|
||||
|
||||
def test_valid_csv(self):
|
||||
path = self._write(
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,첫째날\n"
|
||||
"2026-04-02,09:30:00,17:30:00,30,단축\n"
|
||||
)
|
||||
try:
|
||||
rows = parse_csv(path)
|
||||
assert len(rows) == 2
|
||||
assert rows[0]['lunch_minutes'] == 60
|
||||
assert rows[1]['memo'] == '단축'
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_utf8_bom(self):
|
||||
# 엑셀 저장본 호환
|
||||
path = self._write('\ufeff' +
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,첫째날\n"
|
||||
)
|
||||
try:
|
||||
rows = parse_csv(path)
|
||||
assert len(rows) == 1
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_missing_required_header(self):
|
||||
path = self._write("date,memo\n2026-04-01,foo\n")
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_csv(path)
|
||||
assert 'clock_in' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
def test_file_not_found(self):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_csv('/nonexistent/file.csv')
|
||||
|
||||
def test_line_number_in_error(self):
|
||||
path = self._write(
|
||||
"date,clock_in,clock_out,lunch_minutes,memo\n"
|
||||
"2026-04-01,09:00,18:00,60,ok\n"
|
||||
"bad-date,09:00,18:00,60,broken\n"
|
||||
)
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
parse_csv(path)
|
||||
assert '줄 3' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
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
|
||||
@ -108,97 +108,3 @@ class TestConsecutiveOvertimeDays:
|
||||
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
||||
overtime_minutes=120, overtime_earned=120)
|
||||
assert fresh_db.get_consecutive_overtime_days() == 3
|
||||
|
||||
|
||||
class TestLeaveQueriesByDate:
|
||||
def test_get_leave_minutes_for_no_records(self, fresh_db):
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
|
||||
|
||||
def test_full_day_leave_detected(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||
|
||||
def test_half_day_not_full(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
|
||||
assert not fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
|
||||
|
||||
def test_two_halves_become_full(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
|
||||
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||
|
||||
def test_records_by_date(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
|
||||
recs = fresh_db.get_leave_records_by_date('2026-05-15')
|
||||
assert len(recs) == 1
|
||||
assert recs[0]['leave_type'] == '연차'
|
||||
assert recs[0]['memo'] == '메모'
|
||||
|
||||
def test_records_by_range(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
|
||||
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
|
||||
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
|
||||
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
|
||||
assert len(recs) == 2
|
||||
# 날짜 정렬
|
||||
assert recs[0]['date'] == '2026-05-01'
|
||||
assert recs[1]['date'] == '2026-05-10'
|
||||
|
||||
|
||||
class TestRecurringLeavesDB:
|
||||
def test_add_and_list(self, fresh_db):
|
||||
rid = fresh_db.add_recurring_leave(
|
||||
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
|
||||
)
|
||||
assert rid > 0
|
||||
recs = fresh_db.get_recurring_leaves()
|
||||
assert len(recs) == 1
|
||||
assert recs[0]['pattern'] == 'weekly:friday'
|
||||
assert recs[0]['memo'] == '단축'
|
||||
|
||||
def test_active_on_filter(self, fresh_db):
|
||||
# 종료일이 지난 패턴
|
||||
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||
'2025-01-01', '2025-12-31')
|
||||
# 아직 시작 안 한 패턴
|
||||
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
|
||||
'2027-01-01', None)
|
||||
# 현재 활성 패턴
|
||||
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
|
||||
'2026-01-01', None)
|
||||
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
|
||||
assert len(active) == 1
|
||||
assert active[0]['pattern'] == 'monthly:15'
|
||||
|
||||
def test_delete(self, fresh_db):
|
||||
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||
'2026-01-01')
|
||||
assert len(fresh_db.get_recurring_leaves()) == 1
|
||||
fresh_db.delete_recurring_leave(rid)
|
||||
assert fresh_db.get_recurring_leaves() == []
|
||||
|
||||
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
|
||||
# 매주 금요일 반차
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
|
||||
'2026-01-01')
|
||||
# 2026-05-01 = Friday → 240분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
|
||||
# 2026-05-04 = Monday → 0분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
|
||||
|
||||
def test_concrete_plus_recurring_sum(self, fresh_db):
|
||||
# 매주 금요일 반차 + 그날 별도 반반차 추가
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
|
||||
# 0.5 + 0.25 = 0.75일 = 360분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
|
||||
assert not fresh_db.has_full_day_leave('2026-05-01')
|
||||
|
||||
def test_concrete_plus_recurring_full_day(self, fresh_db):
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
|
||||
# 0.5 + 0.5 = 1.0일
|
||||
assert fresh_db.has_full_day_leave('2026-05-01')
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
"""
|
||||
utils.discord_webhook 단위 테스트.
|
||||
|
||||
네트워크 호출은 mock — 실제 Discord API는 절대 건드리지 않음.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.discord_webhook import (
|
||||
send, send_test, send_clock_in, send_clock_out, send_health_warning,
|
||||
USER_AGENT, COLOR_GREEN, COLOR_BLUE,
|
||||
)
|
||||
|
||||
|
||||
class TestSendInputValidation:
|
||||
def test_empty_url_returns_false(self):
|
||||
assert send('', 'title', 'desc') is False
|
||||
|
||||
def test_non_https_url_returns_false(self):
|
||||
# 보안상 http:// 거부
|
||||
assert send('http://example.com/webhook', 'title', 'desc') is False
|
||||
assert send('ftp://example.com', 'title', 'desc') is False
|
||||
|
||||
def test_none_url_returns_false(self):
|
||||
assert send(None, 'title', 'desc') is False
|
||||
|
||||
|
||||
class TestSendNetwork:
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 204
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc')
|
||||
assert ok is True
|
||||
|
||||
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.headers.get('User-agent') == USER_AGENT
|
||||
assert 'Mozilla' in USER_AGENT # 봇으로 인식 안 되도록
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_4xx_returns_false(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.status = 403
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_network_error_returns_false(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_timeout_returns_false(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = TimeoutError('slow')
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
|
||||
class TestPayloadShape:
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_payload_contains_embed(self, mock_urlopen):
|
||||
import json as _json
|
||||
resp = MagicMock()
|
||||
resp.status = 204
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 'TITLE', 'DESC',
|
||||
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = _json.loads(req.data.decode('utf-8'))
|
||||
assert 'embeds' in body
|
||||
assert body['embeds'][0]['title'] == 'TITLE'
|
||||
assert body['embeds'][0]['description'] == 'DESC'
|
||||
assert body['embeds'][0]['color'] == COLOR_GREEN
|
||||
assert body['embeds'][0]['fields'] == [{"name": "f", "value": "v"}]
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_test(self, mock_send):
|
||||
mock_send.return_value = True
|
||||
send_test('https://discord.com/x')
|
||||
assert mock_send.called
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert kwargs.get('color') == COLOR_GREEN
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_clock_in(self, mock_send):
|
||||
send_clock_in('https://discord.com/x', '09:00')
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert '09:00' in kwargs.get('description', '')
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_clock_out_no_overtime(self, mock_send):
|
||||
send_clock_out('https://discord.com/x', '18:00', 8.0, 0, 0)
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
# 연장 없음 → 파란색
|
||||
assert kwargs.get('color') == COLOR_BLUE
|
||||
# 필드: 총 근무시간만
|
||||
assert len(kwargs.get('fields', [])) == 1
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_clock_out_with_overtime(self, mock_send):
|
||||
send_clock_out('https://discord.com/x', '19:30', 9.5, 90, 90)
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
# 연장 있음 → 노란색
|
||||
assert kwargs.get('color') != COLOR_BLUE
|
||||
assert len(kwargs.get('fields', [])) == 2
|
||||
|
||||
@patch('utils.discord_webhook.send')
|
||||
def test_send_health_warning(self, mock_send):
|
||||
send_health_warning('https://discord.com/x', 4.5)
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert '4.5' in kwargs.get('description', '')
|
||||
@ -1,173 +0,0 @@
|
||||
"""
|
||||
utils.holiday_api 단위 테스트.
|
||||
|
||||
실제 정부 API는 호출하지 않음 — 모두 urlopen mock.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.holiday_api import (
|
||||
fetch_korean_holidays, _parse_response, is_configured,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response(items):
|
||||
"""API 정상 응답 형식 빌드."""
|
||||
return {
|
||||
'response': {
|
||||
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
|
||||
'body': {
|
||||
'items': {'item': items} if items else {'item': []},
|
||||
'numOfRows': 100,
|
||||
'pageNo': 1,
|
||||
'totalCount': len(items) if isinstance(items, list) else 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestParseResponse:
|
||||
def test_multiple_items(self):
|
||||
items = [
|
||||
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
|
||||
'locdate': 20260501, 'seq': 1},
|
||||
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
|
||||
'locdate': 20260505, 'seq': 1},
|
||||
]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 2
|
||||
assert out[0]['date'] == '2026-05-01'
|
||||
assert out[0]['name'] == '근로자의 날'
|
||||
assert out[0]['is_holiday'] is True
|
||||
assert out[1]['date'] == '2026-05-05'
|
||||
|
||||
def test_single_item_as_dict(self):
|
||||
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
|
||||
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
|
||||
data = {
|
||||
'response': {
|
||||
'header': {'resultCode': '00'},
|
||||
'body': {'items': {'item': item}, 'totalCount': 1},
|
||||
}
|
||||
}
|
||||
out = _parse_response(data)
|
||||
assert len(out) == 1
|
||||
assert out[0]['name'] == '근로자의 날'
|
||||
|
||||
def test_empty_year(self):
|
||||
# totalCount=0 같은 정상 빈 응답
|
||||
data = {
|
||||
'response': {
|
||||
'header': {'resultCode': '00'},
|
||||
'body': {'items': '', 'totalCount': 0},
|
||||
}
|
||||
}
|
||||
assert _parse_response(data) == []
|
||||
|
||||
def test_error_result_code(self):
|
||||
data = {
|
||||
'response': {
|
||||
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
|
||||
'body': {},
|
||||
}
|
||||
}
|
||||
assert _parse_response(data) is None
|
||||
|
||||
def test_isholiday_n_filtered_at_caller_level(self):
|
||||
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
|
||||
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
|
||||
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 1
|
||||
assert out[0]['is_holiday'] is False
|
||||
|
||||
def test_locdate_str_form(self):
|
||||
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert out[0]['date'] == '2026-05-01'
|
||||
|
||||
def test_invalid_locdate_skipped(self):
|
||||
items = [
|
||||
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
|
||||
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
|
||||
]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 1 # 정상 1개만
|
||||
assert out[0]['name'] == '근로자의 날'
|
||||
|
||||
def test_missing_required_fields_skipped(self):
|
||||
items = [
|
||||
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
|
||||
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
|
||||
]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 1
|
||||
|
||||
def test_malformed_response_returns_none(self):
|
||||
# response 구조 자체가 깨진 경우
|
||||
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
|
||||
# 위는 implementation-dependent — 둘 다 합리적
|
||||
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
|
||||
assert _parse_response({}) is None
|
||||
|
||||
|
||||
class TestFetchNetwork:
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
out = fetch_korean_holidays(2026)
|
||||
assert out is not None
|
||||
assert len(out) == 1
|
||||
assert out[0]['date'] == '2026-05-01'
|
||||
|
||||
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert 'serviceKey=' in req.full_url
|
||||
assert 'solYear=2026' in req.full_url
|
||||
assert '_type=json' in req.full_url
|
||||
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_network_error_returns_none(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert fetch_korean_holidays(2026) is None
|
||||
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_timeout_returns_none(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = TimeoutError('slow')
|
||||
assert fetch_korean_holidays(2026) is None
|
||||
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_invalid_json_returns_none(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b'<html>error</html>'
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
assert fetch_korean_holidays(2026) is None
|
||||
|
||||
|
||||
class TestConfigured:
|
||||
def test_key_set(self, monkeypatch):
|
||||
import utils.holiday_api as _ha
|
||||
monkeypatch.setattr(_ha, '_SERVICE_KEY', 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93')
|
||||
assert _ha.is_configured() is True
|
||||
|
||||
def test_key_empty(self, monkeypatch):
|
||||
import utils.holiday_api as _ha
|
||||
monkeypatch.setattr(_ha, '_SERVICE_KEY', '')
|
||||
assert _ha.is_configured() is False
|
||||
@ -1,106 +0,0 @@
|
||||
"""
|
||||
ui.i18n_runtime 단위 테스트.
|
||||
|
||||
QApplication이 필요해서 offscreen으로.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen')
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def qapp():
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def i18n():
|
||||
from core import i18n
|
||||
from ui import i18n_runtime
|
||||
saved_lang = i18n.get_language()
|
||||
yield i18n_runtime
|
||||
i18n_runtime.clear()
|
||||
i18n.set_language(saved_lang)
|
||||
|
||||
|
||||
def test_register_applies_initial_text(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save')
|
||||
assert label.text() == '저장'
|
||||
|
||||
|
||||
def test_retranslate_after_language_change(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.close')
|
||||
assert label.text() == '닫기'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == 'Close'
|
||||
|
||||
i18n.set_language_and_retranslate('ko')
|
||||
assert label.text() == '닫기'
|
||||
|
||||
|
||||
def test_setter_kwarg_for_window_title(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QDialog
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
dlg = QDialog()
|
||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||
assert dlg.windowTitle() == '설정'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert dlg.windowTitle() == 'Settings'
|
||||
|
||||
|
||||
def test_post_callback_applied(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||
assert label.text() == '[저장]'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == '[Save]'
|
||||
|
||||
|
||||
def test_dead_widget_pruned(qapp, i18n):
|
||||
"""삭제된 위젯은 retranslate에서 자동 제외 (RuntimeError 안 남)."""
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.cancel')
|
||||
label.deleteLater()
|
||||
label = None # weakref 끊기
|
||||
|
||||
# Qt 이벤트 처리 한 번 강제로 (deleteLater 처리)
|
||||
qapp.processEvents()
|
||||
|
||||
# 죽은 위젯이 있어도 예외 없이 실행돼야 함
|
||||
i18n.set_language_and_retranslate('en')
|
||||
i18n.set_language_and_retranslate('ko')
|
||||
|
||||
|
||||
def test_kwargs_format(qapp, i18n):
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
from core.i18n import set_language
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
# 'tray.tooltip_remaining': '퇴근까지: {time}'
|
||||
i18n.register(label, 'tray.tooltip_remaining', kwargs={'time': '01:23'})
|
||||
assert '01:23' in label.text()
|
||||
@ -1,72 +0,0 @@
|
||||
"""연장근무 자동 적립 가드 테스트.
|
||||
|
||||
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
|
||||
@ -1,153 +0,0 @@
|
||||
"""
|
||||
core.recurring_leaves 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.recurring_leaves import (
|
||||
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
|
||||
)
|
||||
|
||||
|
||||
class TestParsePattern:
|
||||
@pytest.mark.parametrize("pattern,expected_kind", [
|
||||
('weekly:friday', 'weekly'),
|
||||
('weekly:fri', 'weekly'),
|
||||
('weekly:mon,wed,fri', 'weekly'),
|
||||
('biweekly:friday', 'biweekly'),
|
||||
('monthly:15', 'monthly'),
|
||||
('monthly:1', 'monthly'),
|
||||
])
|
||||
def test_valid(self, pattern, expected_kind):
|
||||
result = _parse_pattern(pattern)
|
||||
assert result is not None
|
||||
assert result[0] == expected_kind
|
||||
|
||||
@pytest.mark.parametrize("pattern", [
|
||||
'', 'weekly', 'weekly:', 'weekly:xyz',
|
||||
'monthly:0', 'monthly:32', 'monthly:abc',
|
||||
'unknown:fri', None,
|
||||
])
|
||||
def test_invalid(self, pattern):
|
||||
assert _parse_pattern(pattern) is None
|
||||
|
||||
|
||||
class TestMatches:
|
||||
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
|
||||
return {
|
||||
'pattern': pattern,
|
||||
'start_date': start,
|
||||
'end_date': end,
|
||||
'days': days,
|
||||
'leave_type': leave_type,
|
||||
}
|
||||
|
||||
def test_weekly_single_day(self):
|
||||
rec = self._rec('weekly:friday')
|
||||
assert matches(rec, date(2026, 5, 1)) # Fri
|
||||
assert not matches(rec, date(2026, 5, 2)) # Sat
|
||||
assert not matches(rec, date(2026, 5, 4)) # Mon
|
||||
|
||||
def test_weekly_multiple_days(self):
|
||||
rec = self._rec('weekly:mon,wed,fri')
|
||||
assert matches(rec, date(2026, 5, 4)) # Mon
|
||||
assert matches(rec, date(2026, 5, 6)) # Wed
|
||||
assert matches(rec, date(2026, 5, 8)) # Fri
|
||||
assert not matches(rec, date(2026, 5, 5)) # Tue
|
||||
assert not matches(rec, date(2026, 5, 7)) # Thu
|
||||
|
||||
def test_biweekly_alignment(self):
|
||||
# start_date 2026-01-02 = Friday (week 0)
|
||||
rec = self._rec('biweekly:friday', start='2026-01-02')
|
||||
assert matches(rec, date(2026, 1, 2)) # week 0
|
||||
assert not matches(rec, date(2026, 1, 9)) # week 1
|
||||
assert matches(rec, date(2026, 1, 16)) # week 2
|
||||
|
||||
def test_monthly(self):
|
||||
rec = self._rec('monthly:15')
|
||||
assert matches(rec, date(2026, 1, 15))
|
||||
assert matches(rec, date(2026, 5, 15))
|
||||
assert not matches(rec, date(2026, 5, 14))
|
||||
assert not matches(rec, date(2026, 5, 16))
|
||||
|
||||
def test_monthly_skipped_in_short_month(self):
|
||||
# 31일은 30일 달에는 매치되지 않음
|
||||
rec = self._rec('monthly:31')
|
||||
assert matches(rec, date(2026, 1, 31))
|
||||
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
|
||||
|
||||
def test_before_start(self):
|
||||
rec = self._rec('weekly:friday', start='2026-05-01')
|
||||
assert matches(rec, date(2026, 5, 1))
|
||||
assert not matches(rec, date(2026, 4, 24)) # 시작 전
|
||||
|
||||
def test_after_end(self):
|
||||
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
|
||||
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
|
||||
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
|
||||
|
||||
def test_no_end_means_forever(self):
|
||||
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
|
||||
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
|
||||
|
||||
def test_invalid_pattern_returns_false(self):
|
||||
rec = self._rec('garbage:xyz')
|
||||
assert not matches(rec, date(2026, 5, 1))
|
||||
|
||||
|
||||
class TestExpandRange:
|
||||
def _rec(self, pattern, start='2026-01-01'):
|
||||
return {
|
||||
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
|
||||
'days': 0.5, 'leave_type': '반차', 'memo': '',
|
||||
}
|
||||
|
||||
def test_expand_weekly_one_month(self):
|
||||
rec = self._rec('weekly:friday')
|
||||
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
|
||||
assert len(occs) == 5
|
||||
assert all(o.date.weekday() == 4 for o in occs)
|
||||
|
||||
def test_expand_empty_when_outside(self):
|
||||
rec = self._rec('weekly:friday', start='2027-01-01')
|
||||
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||
assert occs == []
|
||||
|
||||
def test_expand_invalid_range(self):
|
||||
# start > end
|
||||
rec = self._rec('weekly:friday')
|
||||
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
|
||||
assert occs == []
|
||||
|
||||
def test_expand_multiple_recs(self):
|
||||
rec_fri = self._rec('weekly:friday')
|
||||
rec_mon = self._rec('weekly:monday')
|
||||
rec_mon['id'] = 2
|
||||
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
|
||||
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
|
||||
assert len(occs) == 2
|
||||
|
||||
def test_expand_for_date_single(self):
|
||||
rec = self._rec('monthly:15')
|
||||
occs = expand_for_date([rec], date(2026, 5, 15))
|
||||
assert len(occs) == 1
|
||||
assert occs[0].date == date(2026, 5, 15)
|
||||
|
||||
|
||||
class TestDescribePattern:
|
||||
def test_weekly_korean(self):
|
||||
assert '매주' in describe_pattern('weekly:friday')
|
||||
assert '금' in describe_pattern('weekly:friday')
|
||||
|
||||
def test_biweekly(self):
|
||||
assert '격주' in describe_pattern('biweekly:friday')
|
||||
|
||||
def test_monthly(self):
|
||||
assert '매월' in describe_pattern('monthly:15')
|
||||
assert '15' in describe_pattern('monthly:15')
|
||||
@ -1,98 +0,0 @@
|
||||
"""
|
||||
core.salary 단위 테스트 — 포괄임금제 외 시급 추정.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.salary import estimate_pay, format_won
|
||||
|
||||
|
||||
class TestEstimatePay:
|
||||
def test_zero_wage_returns_zero(self):
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 30}], 0)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_negative_wage_returns_zero(self):
|
||||
out = estimate_pay([{'total_hours': 8, 'overtime_minutes': 0}], -1000)
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_empty_records(self):
|
||||
out = estimate_pay([], 10000)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_basic_8h_no_overtime(self):
|
||||
# 8h 정규 × 10000 = 80000
|
||||
out = estimate_pay([{'total_hours': 8.0, 'overtime_minutes': 0}], 10000)
|
||||
assert out['base'] == 80000
|
||||
assert out['overtime'] == 0
|
||||
assert out['total'] == 80000
|
||||
|
||||
def test_8h_with_30min_overtime(self):
|
||||
# 정규 = 7.5h × 10000 = 75000
|
||||
# 연장 = 0.5h × 10000 × 1.5 = 7500
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 8.0, 'overtime_minutes': 30}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=1.5,
|
||||
)
|
||||
assert out['base'] == pytest.approx(75000)
|
||||
assert out['overtime'] == pytest.approx(7500)
|
||||
assert out['total'] == pytest.approx(82500)
|
||||
|
||||
def test_custom_overtime_rate(self):
|
||||
# 연장 = 1h × 10000 × 2.0 = 20000
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 9.0, 'overtime_minutes': 60}],
|
||||
hourly_wage=10000,
|
||||
overtime_rate=2.0,
|
||||
)
|
||||
assert out['overtime'] == pytest.approx(20000)
|
||||
assert out['base'] == pytest.approx(80000)
|
||||
|
||||
def test_aggregated_multiple_records(self):
|
||||
records = [
|
||||
{'total_hours': 8.0, 'overtime_minutes': 0},
|
||||
{'total_hours': 9.0, 'overtime_minutes': 60},
|
||||
{'total_hours': 8.5, 'overtime_minutes': 30},
|
||||
]
|
||||
out = estimate_pay(records, hourly_wage=10000)
|
||||
# base_hours = 8 + 8 + 8 = 24h
|
||||
# overtime_hours = 0 + 1 + 0.5 = 1.5h
|
||||
assert out['base_hours'] == pytest.approx(24.0)
|
||||
assert out['overtime_hours'] == pytest.approx(1.5)
|
||||
assert out['base'] == pytest.approx(240000)
|
||||
assert out['overtime'] == pytest.approx(22500) # 1.5 * 10000 * 1.5
|
||||
|
||||
def test_missing_keys_default_zero(self):
|
||||
out = estimate_pay([{}], 10000)
|
||||
assert out['total'] == 0
|
||||
|
||||
def test_overtime_minutes_zero_when_negative_total(self):
|
||||
# total - overtime이 음수가 되면 base는 0으로 클램프
|
||||
out = estimate_pay(
|
||||
[{'total_hours': 0.3, 'overtime_minutes': 60}], # 0.3h - 1h = -0.7
|
||||
hourly_wage=10000,
|
||||
)
|
||||
assert out['base'] == 0
|
||||
assert out['overtime'] == pytest.approx(15000)
|
||||
|
||||
|
||||
class TestFormatWon:
|
||||
@pytest.mark.parametrize("amount,expected", [
|
||||
(0, '0원'),
|
||||
(1000, '1,000원'),
|
||||
(1234567, '1,234,567원'),
|
||||
(999, '999원'),
|
||||
(82500.4, '82,500원'), # round
|
||||
(82500.6, '82,501원'),
|
||||
])
|
||||
def test_format(self, amount, expected):
|
||||
assert format_won(amount) == expected
|
||||
@ -98,77 +98,3 @@ class TestDayType:
|
||||
mon = datetime(2026, 5, 4)
|
||||
assert not calc.is_weekend(mon)
|
||||
assert calc.get_day_type(mon) == 'normal'
|
||||
|
||||
|
||||
class TestHolidayOvertime:
|
||||
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
|
||||
|
||||
def test_zero_elapsed_returns_zero(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
def test_one_minute_elapsed_no_lunch(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=1)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 1
|
||||
assert earned == 0 # 30분 단위 절삭
|
||||
|
||||
def test_30min_elapsed_truncates_to_30(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=30)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 30 and earned == 30
|
||||
|
||||
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=29)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 29 and earned == 0
|
||||
|
||||
def test_lunch_subtracted(self, calc_8h):
|
||||
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=9)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, include_lunch=True
|
||||
)
|
||||
assert actual == 8 * 60
|
||||
assert earned == 8 * 60
|
||||
|
||||
def test_break_minutes_subtracted(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=2)
|
||||
# 외출 30분 → 90분 적립
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, break_minutes=30
|
||||
)
|
||||
assert actual == 90 and earned == 90
|
||||
|
||||
def test_unit_minutes_15(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=44)
|
||||
# 44분 → 30분 적립 (15분 단위)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, unit_minutes=15
|
||||
)
|
||||
assert actual == 44 and earned == 30
|
||||
|
||||
def test_unit_minutes_60(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=119)
|
||||
# 119분 → 60분 적립 (60분 단위)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, unit_minutes=60
|
||||
)
|
||||
assert actual == 119 and earned == 60
|
||||
|
||||
def test_negative_clamped_to_zero(self, calc_8h):
|
||||
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=1)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, include_lunch=True, include_dinner=True
|
||||
)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
@ -1,82 +0,0 @@
|
||||
"""
|
||||
접근성 — 글꼴 크기 / 고대비 모드 적용.
|
||||
|
||||
QApplication 글로벌 폰트 + 추가 QSS 오버레이.
|
||||
설정 변경 즉시 반영 (재시작 불필요).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
|
||||
# 고대비 QSS — 검정 배경 + 노란 텍스트 + 굵은 테두리
|
||||
HIGH_CONTRAST_QSS = """
|
||||
* {
|
||||
background-color: #000000;
|
||||
color: #FFEB3B;
|
||||
border-color: #FFEB3B;
|
||||
}
|
||||
QPushButton, QLineEdit, QSpinBox, QComboBox, QTextEdit, QTableWidget, QGroupBox {
|
||||
border: 2px solid #FFEB3B;
|
||||
background-color: #000000;
|
||||
color: #FFEB3B;
|
||||
}
|
||||
QPushButton:hover { background-color: #333333; }
|
||||
QPushButton:pressed { background-color: #FFEB3B; color: #000000; }
|
||||
QPushButton:disabled { color: #888; border-color: #888; }
|
||||
QGroupBox::title { color: #FFEB3B; padding: 0 4px; }
|
||||
QProgressBar { border: 2px solid #FFEB3B; }
|
||||
QProgressBar::chunk { background-color: #FFEB3B; }
|
||||
QToolTip { background-color: #000; color: #FFEB3B; border: 2px solid #FFEB3B; }
|
||||
"""
|
||||
|
||||
|
||||
def apply_font_scale(scale: float) -> None:
|
||||
"""전역 글꼴 크기 배율 적용 (1.0 = 기본, 1.25 = 125%, 1.5 = 150%)."""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
base = app.font()
|
||||
if base.pointSize() > 0:
|
||||
# 기존 배율 무시하고 새 배율로 (기본 9pt 가정)
|
||||
base_pt = 9
|
||||
base.setPointSize(int(round(base_pt * scale)))
|
||||
else:
|
||||
base_px = 12
|
||||
base.setPixelSize(int(round(base_px * scale)))
|
||||
app.setFont(base)
|
||||
|
||||
|
||||
def apply_high_contrast(enabled: bool, base_qss: str = "") -> None:
|
||||
"""고대비 모드 ON/OFF. base_qss는 평소 테마 QSS (OFF 시 복원용)."""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
if enabled:
|
||||
app.setStyleSheet(base_qss + "\n" + HIGH_CONTRAST_QSS)
|
||||
else:
|
||||
app.setStyleSheet(base_qss)
|
||||
|
||||
|
||||
def apply_from_settings(db) -> None:
|
||||
"""db에서 font_scale + high_contrast 읽어 적용."""
|
||||
try:
|
||||
scale = float(db.get_setting('font_scale', '1.0') or 1.0)
|
||||
except (ValueError, TypeError):
|
||||
scale = 1.0
|
||||
scale = max(0.8, min(2.0, scale))
|
||||
apply_font_scale(scale)
|
||||
|
||||
enabled = db.get_setting('high_contrast', 'false').lower() == 'true'
|
||||
# base_qss는 main_window에서 apply_theme() 호출 직후 적용되므로,
|
||||
# 여기서는 현재 styleSheet 그대로 두고 high_contrast만 추가.
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
current = app.styleSheet() or ""
|
||||
# 기존에 추가된 HIGH_CONTRAST_QSS 제거
|
||||
base = current.replace(HIGH_CONTRAST_QSS, "").rstrip() + "\n"
|
||||
if enabled:
|
||||
app.setStyleSheet(base + HIGH_CONTRAST_QSS)
|
||||
else:
|
||||
app.setStyleSheet(base.rstrip())
|
||||
@ -1,439 +0,0 @@
|
||||
"""
|
||||
도전과제 뷰 — 4탭 (전체 / 진행 중 / 완료 / 시크릿).
|
||||
|
||||
디자인 원칙:
|
||||
- 카드 = 등급별 그라디언트 배경 + 외곽선 빛 (획득 시 강한 색)
|
||||
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
|
||||
- 진행 게이지 = 두꺼운 색상 막대 (등급 색)
|
||||
- 카테고리 = 작은 인라인 태그
|
||||
- 시크릿 미발견 = ❓ 처리
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTabWidget, QWidget, QScrollArea,
|
||||
QProgressBar, QFrame, QGridLayout, QSizePolicy)
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
from core.achievements import get_all_with_status, get_stats
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||
|
||||
|
||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||
TIER_THEMES = {
|
||||
'bronze': {
|
||||
'border': '#cd7f32',
|
||||
'border_strong': '#e09947',
|
||||
'bg_top': '#3a2a18',
|
||||
'bg_bot': '#241810',
|
||||
'text': '#ffd9a8',
|
||||
'label': '🥉',
|
||||
},
|
||||
'silver': {
|
||||
'border': '#a8a8a8',
|
||||
'border_strong': '#d0d0d0',
|
||||
'bg_top': '#2e2e36',
|
||||
'bg_bot': '#1c1c22',
|
||||
'text': '#e8e8f0',
|
||||
'label': '🥈',
|
||||
},
|
||||
'gold': {
|
||||
'border': '#ffb700',
|
||||
'border_strong': '#ffd24a',
|
||||
'bg_top': '#3a2e10',
|
||||
'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0',
|
||||
'label': '🥇',
|
||||
},
|
||||
'platinum': {
|
||||
'border': '#7fdbff',
|
||||
'border_strong': '#a8e8ff',
|
||||
'bg_top': '#1a3340',
|
||||
'bg_bot': '#0e1f28',
|
||||
'text': '#c5ecff',
|
||||
'label': '💎',
|
||||
},
|
||||
'legend': {
|
||||
'border': '#ff6b9d',
|
||||
'border_strong': '#ff90b8',
|
||||
'bg_top': '#3a1a2a',
|
||||
'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4',
|
||||
'label': '🌟',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AchievementsView(QDialog):
|
||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('achieve.title'))
|
||||
self.setMinimumSize(960, 720)
|
||||
self.resize(1100, 800)
|
||||
self._increment_view_count()
|
||||
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
def _increment_view_count(self) -> None:
|
||||
try:
|
||||
cur = self.db.get_setting_int('achievements_view_count', 0)
|
||||
self.db.set_setting('achievements_view_count', str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 20, 20, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
stats = get_stats(self.db)
|
||||
|
||||
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
|
||||
layout.addWidget(self._build_header(stats))
|
||||
|
||||
# === 탭 ===
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setStyleSheet(self._tabs_qss())
|
||||
|
||||
all_items = get_all_with_status(self.db)
|
||||
earned_items = [a for a in all_items if a['earned_date'] is not None]
|
||||
in_progress = [a for a in all_items
|
||||
if a['earned_date'] is None and not a['is_secret']]
|
||||
secret_items = [a for a in all_items if a['is_secret']]
|
||||
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
|
||||
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||
tr('achieve.tab_in_progress', count=len(in_progress)))
|
||||
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||
tr('achieve.tab_completed', count=len(earned_items)))
|
||||
self.tabs.addTab(
|
||||
self._build_grid_tab(secret_items, secret_mode=True),
|
||||
tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total'])
|
||||
)
|
||||
layout.addWidget(self.tabs, 1)
|
||||
|
||||
# === 닫기 버튼 ===
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.setMinimumWidth(100)
|
||||
close_btn.setStyleSheet(button_qss('default'))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# ----- 헤더 -----
|
||||
def _build_header(self, stats: dict) -> QWidget:
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {tc('panel')};
|
||||
border: 1px solid {tc('border')};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
|
||||
|
||||
# 큰 숫자 행
|
||||
num_row = QHBoxLayout()
|
||||
num_row.setSpacing(24)
|
||||
|
||||
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
|
||||
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)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet(f"color: {tc('border')};")
|
||||
num_row.addWidget(spacer)
|
||||
|
||||
secret_lbl = QLabel(
|
||||
f"<div style='line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||
f"</div>"
|
||||
)
|
||||
secret_lbl.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(secret_lbl)
|
||||
|
||||
num_row.addStretch()
|
||||
|
||||
pct_lbl = QLabel(
|
||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||
f"{pct:.1f}%</span></div>"
|
||||
)
|
||||
pct_lbl.setTextFormat(Qt.RichText)
|
||||
pct_lbl.setAlignment(Qt.AlignRight)
|
||||
num_row.addWidget(pct_lbl)
|
||||
|
||||
layout.addLayout(num_row)
|
||||
|
||||
# 진행 바
|
||||
bar = QProgressBar()
|
||||
bar.setMaximum(max(stats['total'], 1))
|
||||
bar.setValue(stats['earned'])
|
||||
bar.setTextVisible(False)
|
||||
bar.setMinimumHeight(8)
|
||||
bar.setMaximumHeight(8)
|
||||
bar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {tc('panel2')};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
layout.addWidget(bar)
|
||||
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
# ----- 탭 그리드 -----
|
||||
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet(scroll_qss())
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(12)
|
||||
grid.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
if not items:
|
||||
empty = QLabel(tr('achieve.empty'))
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
)
|
||||
grid.addWidget(empty, 0, 0)
|
||||
else:
|
||||
cols = 3
|
||||
for i, item in enumerate(items):
|
||||
card = self._build_card(item, secret_mode=secret_mode)
|
||||
grid.addWidget(card, i // cols, i % cols)
|
||||
# 빈 컬럼 stretch 방지
|
||||
for c in range(cols):
|
||||
grid.setColumnStretch(c, 1)
|
||||
|
||||
container.setLayout(grid)
|
||||
scroll.setWidget(container)
|
||||
return scroll
|
||||
|
||||
# ----- 단일 카드 -----
|
||||
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
|
||||
is_earned = item['earned_date'] is not None
|
||||
is_locked_secret = item['is_secret'] and not is_earned
|
||||
tier = item['tier'] or 'bronze'
|
||||
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
||||
|
||||
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
|
||||
light = not _is_dark()
|
||||
if is_locked_secret:
|
||||
if light:
|
||||
bg_top = bg_bot = tc('panel'); border = tc('border')
|
||||
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:
|
||||
bg_top = theme['bg_top']
|
||||
bg_bot = theme['bg_bot']
|
||||
border = theme['border_strong'] if is_earned else theme['border']
|
||||
text_color = theme['text'] if is_earned else '#c0c0d0'
|
||||
|
||||
# 외곽선 강도: 획득 시 2px + 더 진한 색
|
||||
border_width = 2 if is_earned else 1
|
||||
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
|
||||
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
card.setMinimumHeight(150)
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {bg_top}, stop:1 {bg_bot});
|
||||
border: {border_width}px solid {border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: {text_color};
|
||||
}}
|
||||
""")
|
||||
|
||||
outer = QVBoxLayout()
|
||||
outer.setContentsMargins(14, 12, 14, 12)
|
||||
outer.setSpacing(8)
|
||||
|
||||
# 1행: 이모지 + 이름 + 등급 라벨
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setSpacing(10)
|
||||
|
||||
if is_locked_secret:
|
||||
icon_text = "❓"
|
||||
else:
|
||||
icon_text = item['badge_icon'] or '🏆'
|
||||
icon = QLabel(icon_text)
|
||||
icon.setStyleSheet(
|
||||
f"font-size: 32pt; background: transparent; border: none; "
|
||||
f"color: {text_color};"
|
||||
)
|
||||
icon.setMinimumWidth(48)
|
||||
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
|
||||
top_row.addWidget(icon)
|
||||
|
||||
# 이름 + 카테고리 (세로 스택)
|
||||
name_box = QVBoxLayout()
|
||||
name_box.setSpacing(2)
|
||||
name_box.setContentsMargins(0, 4, 0, 0)
|
||||
|
||||
name_text = "???" if is_locked_secret else (item['name'] or '')
|
||||
name = QLabel(name_text)
|
||||
name.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; "
|
||||
f"color: {tc('text') if is_earned else tc('text_dim')}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
name.setWordWrap(True)
|
||||
name_box.addWidget(name)
|
||||
|
||||
cat_text = tr(f"achieve.cat_{item['category']}")
|
||||
if not is_locked_secret:
|
||||
cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} · {cat_text} ")
|
||||
cat_label.setStyleSheet(
|
||||
f"font-size: 8.5pt; "
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
||||
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 8px; "
|
||||
f"padding: 1px 4px;"
|
||||
)
|
||||
cat_label.setMaximumHeight(20)
|
||||
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
cat_wrap = QHBoxLayout()
|
||||
cat_wrap.setContentsMargins(0, 0, 0, 0)
|
||||
cat_wrap.addWidget(cat_label)
|
||||
cat_wrap.addStretch()
|
||||
name_box.addLayout(cat_wrap)
|
||||
|
||||
top_row.addLayout(name_box, 1)
|
||||
outer.addLayout(top_row)
|
||||
|
||||
# 2행: 설명
|
||||
if is_locked_secret:
|
||||
desc_text = tr('achieve.secret_locked')
|
||||
else:
|
||||
desc_text = item['description'] or ''
|
||||
desc = QLabel(desc_text)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet(
|
||||
f"color: {tc('text_dim')}; font-size: 9.5pt; "
|
||||
f"background: transparent; border: none; padding: 0;"
|
||||
)
|
||||
outer.addWidget(desc)
|
||||
|
||||
# 3행: 진행 게이지 또는 획득 일자
|
||||
if is_earned:
|
||||
earned = QLabel(tr('achieve.earned_date', date=item['earned_date']))
|
||||
earned.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 6px; padding: 4px 8px;"
|
||||
)
|
||||
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(earned)
|
||||
row.addStretch()
|
||||
outer.addLayout(row)
|
||||
elif not is_locked_secret:
|
||||
target = max(1, item.get('target') or 1)
|
||||
progress = item.get('progress') or 0
|
||||
pct = (progress / target * 100) if target else 0
|
||||
|
||||
# 게이지 + 숫자 라벨
|
||||
gauge_row = QHBoxLayout()
|
||||
gauge_row.setSpacing(8)
|
||||
|
||||
pb = QProgressBar()
|
||||
pb.setMaximum(target)
|
||||
pb.setValue(min(progress, target))
|
||||
pb.setTextVisible(False)
|
||||
pb.setMinimumHeight(10)
|
||||
pb.setMaximumHeight(10)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
|
||||
border-radius: 5px;
|
||||
}}
|
||||
""")
|
||||
gauge_row.addWidget(pb, 1)
|
||||
|
||||
num = QLabel(f"{progress} / {target}")
|
||||
num.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
|
||||
f"font-weight: bold; background: transparent; border: none;"
|
||||
)
|
||||
num.setMinimumWidth(60)
|
||||
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
gauge_row.addWidget(num)
|
||||
|
||||
outer.addLayout(gauge_row)
|
||||
else:
|
||||
# 시크릿 잠금 — 회색 점선 placeholder
|
||||
placeholder = QLabel("· · · · · · · · · ·")
|
||||
placeholder.setStyleSheet(
|
||||
"color: #444; font-size: 12pt; letter-spacing: 4px; "
|
||||
"background: transparent; border: none;"
|
||||
)
|
||||
placeholder.setAlignment(Qt.AlignCenter)
|
||||
outer.addWidget(placeholder)
|
||||
|
||||
outer.addStretch(1)
|
||||
card.setLayout(outer)
|
||||
return card
|
||||
|
||||
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||
def _tabs_qss(self) -> str:
|
||||
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
|
||||
return tabs_qss(ACCENT_GOLD)
|
||||
@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('dlg.break.edit_title'))
|
||||
self.setWindowTitle("외출 기록 수정")
|
||||
self.setFixedSize(380, 180)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 외출 시간
|
||||
out_layout = QHBoxLayout()
|
||||
out_label = QLabel(tr('dlg.break.out_label'))
|
||||
out_label = QLabel("외출 시간:")
|
||||
out_label.setFixedWidth(80)
|
||||
self.out_time_edit = QTimeEdit()
|
||||
self.out_time_edit.setDisplayFormat("HH:mm:ss")
|
||||
@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 복귀 시간
|
||||
in_layout = QHBoxLayout()
|
||||
in_label = QLabel(tr('dlg.break.in_label'))
|
||||
in_label = QLabel("복귀 시간:")
|
||||
in_label.setFixedWidth(80)
|
||||
self.in_time_edit = QTimeEdit()
|
||||
self.in_time_edit.setDisplayFormat("HH:mm:ss")
|
||||
@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
reason_layout = QHBoxLayout()
|
||||
reason_label = QLabel(tr('dlg.break.reason_label'))
|
||||
reason_label = QLabel("사유:")
|
||||
reason_label.setFixedWidth(80)
|
||||
self.reason_edit = QLineEdit()
|
||||
reason_layout.addWidget(reason_label)
|
||||
@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
save_button = QPushButton("저장")
|
||||
cancel_button = QPushButton("취소")
|
||||
|
||||
save_button.clicked.connect(self.accept)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
@ -128,7 +128,7 @@ class BreakView(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(tr('view.break.today_title'))
|
||||
title = QLabel("오늘의 외출 기록")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
@ -136,13 +136,7 @@ class BreakView(QDialog):
|
||||
# 외출 리스트 테이블
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr('view.break.col_out'),
|
||||
tr('view.break.col_in'),
|
||||
tr('view.break.col_duration'),
|
||||
tr('view.break.col_reason'),
|
||||
"",
|
||||
])
|
||||
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""])
|
||||
|
||||
# 테이블 설정
|
||||
header = self.table.horizontalHeader()
|
||||
@ -158,7 +152,7 @@ class BreakView(QDialog):
|
||||
layout.addWidget(self.table)
|
||||
|
||||
# 총 외출 시간 표시
|
||||
self.total_label = QLabel(tr('view.break.total_zero'))
|
||||
self.total_label = QLabel("총 외출 시간: 0분")
|
||||
self.total_label.setObjectName("section_title")
|
||||
self.total_label.setAlignment(Qt.AlignRight)
|
||||
layout.addWidget(self.total_label)
|
||||
@ -166,8 +160,8 @@ class BreakView(QDialog):
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.refresh_button = QPushButton(tr('btn.refresh'))
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
self.refresh_button = QPushButton("새로고침")
|
||||
close_button = QPushButton("닫기")
|
||||
|
||||
self.refresh_button.clicked.connect(self.load_break_records)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@ -196,7 +190,7 @@ class BreakView(QDialog):
|
||||
if break_in:
|
||||
self.table.setItem(i, 1, QTableWidgetItem(break_in))
|
||||
else:
|
||||
item = QTableWidgetItem(tr('view.break.in_progress'))
|
||||
item = QTableWidgetItem("진행중")
|
||||
item.setForeground(Qt.red)
|
||||
self.table.setItem(i, 1, item)
|
||||
|
||||
@ -205,10 +199,7 @@ class BreakView(QDialog):
|
||||
if total_minutes:
|
||||
hours = total_minutes // 60
|
||||
minutes = total_minutes % 60
|
||||
if hours > 0:
|
||||
duration_str = tr('view.break.duration_fmt', h=hours, m=minutes)
|
||||
else:
|
||||
duration_str = tr('view.break.duration_min_only', m=minutes)
|
||||
duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분"
|
||||
self.table.setItem(i, 2, QTableWidgetItem(duration_str))
|
||||
else:
|
||||
self.table.setItem(i, 2, QTableWidgetItem("-"))
|
||||
@ -223,8 +214,8 @@ class BreakView(QDialog):
|
||||
action_layout.setContentsMargins(0, 0, 0, 0)
|
||||
action_layout.setSpacing(5)
|
||||
|
||||
edit_button = QPushButton(tr('btn.edit_short'))
|
||||
delete_button = QPushButton(tr('btn.delete_short'))
|
||||
edit_button = QPushButton("수정")
|
||||
delete_button = QPushButton("삭제")
|
||||
|
||||
edit_button.setFixedSize(50, 25)
|
||||
delete_button.setFixedSize(50, 25)
|
||||
@ -246,9 +237,9 @@ class BreakView(QDialog):
|
||||
minutes = total_minutes % 60
|
||||
|
||||
if hours > 0:
|
||||
self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
|
||||
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분")
|
||||
else:
|
||||
self.total_label.setText(tr('view.break.total_min_only', m=minutes))
|
||||
self.total_label.setText(f"총 외출 시간: {minutes}분")
|
||||
|
||||
def edit_record(self, record_id):
|
||||
"""외출 기록 수정"""
|
||||
@ -286,8 +277,8 @@ class BreakView(QDialog):
|
||||
"""외출 기록 삭제"""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.break.delete_confirm'),
|
||||
"삭제 확인",
|
||||
"이 외출 기록을 삭제하시겠습니까?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
|
||||
@ -12,7 +12,6 @@ import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.i18n import tr
|
||||
from ui.styles import ThemeColors, apply_dark_titlebar
|
||||
|
||||
|
||||
@ -38,7 +37,7 @@ class CalendarView(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(tr('cal.dialog_title'))
|
||||
title = QLabel("월간 근무 기록")
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
@ -48,24 +47,20 @@ class CalendarView(QDialog):
|
||||
self.calendar.setMinimumHeight(280)
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self.date_selected)
|
||||
# 우클릭 컨텍스트 메뉴 (과거 일자 수동 추가)
|
||||
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.calendar.customContextMenuRequested.connect(self._show_date_context)
|
||||
layout.addWidget(self.calendar, 1)
|
||||
|
||||
# 범례
|
||||
legend_layout = QHBoxLayout()
|
||||
legend_layout.setSpacing(12)
|
||||
for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')),
|
||||
('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]:
|
||||
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
_item.setTextFormat(Qt.RichText)
|
||||
legend_layout.addWidget(_item)
|
||||
legend_layout.addWidget(QLabel("🟢 정상"))
|
||||
legend_layout.addWidget(QLabel("🔴 연장"))
|
||||
legend_layout.addWidget(QLabel("🟡 휴가"))
|
||||
legend_layout.addWidget(QLabel("⚪ 없음"))
|
||||
legend_layout.addStretch()
|
||||
layout.addLayout(legend_layout)
|
||||
|
||||
# 선택된 날짜 상세 정보
|
||||
detail_group = QGroupBox(tr('cal.detail_group_title'))
|
||||
detail_group = QGroupBox("선택된 날짜 정보")
|
||||
detail_layout = QVBoxLayout()
|
||||
detail_layout.setSpacing(6)
|
||||
detail_layout.setContentsMargins(10, 20, 10, 8)
|
||||
@ -79,13 +74,13 @@ class CalendarView(QDialog):
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6)
|
||||
|
||||
self.edit_time_button = QPushButton(tr('cal.edit_time'))
|
||||
self.edit_time_button = QPushButton("✏️ 시간 수정")
|
||||
self.edit_time_button.setObjectName("btn_primary")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||
button_layout.addWidget(self.edit_time_button)
|
||||
|
||||
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
||||
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
|
||||
self.delete_record_button.setObjectName("btn_danger")
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||
@ -96,17 +91,17 @@ class CalendarView(QDialog):
|
||||
layout.addWidget(detail_group)
|
||||
|
||||
# 메모 그룹
|
||||
memo_group = QGroupBox(tr('cal.memo_group'))
|
||||
memo_group = QGroupBox("메모")
|
||||
memo_layout = QVBoxLayout()
|
||||
memo_layout.setSpacing(6)
|
||||
memo_layout.setContentsMargins(10, 20, 10, 8)
|
||||
|
||||
self.memo_edit = QTextEdit()
|
||||
self.memo_edit.setMaximumHeight(70)
|
||||
self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder'))
|
||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
|
||||
self.save_memo_button = QPushButton(tr('cal.save_memo'))
|
||||
self.save_memo_button = QPushButton("💾 메모 저장")
|
||||
self.save_memo_button.setObjectName("btn_primary")
|
||||
self.save_memo_button.setEnabled(False)
|
||||
self.save_memo_button.clicked.connect(self.save_memo)
|
||||
@ -157,109 +152,6 @@ class CalendarView(QDialog):
|
||||
|
||||
self.calendar.setDateTextFormat(qdate, fmt)
|
||||
|
||||
def _show_date_context(self, pos):
|
||||
"""캘린더 우클릭 메뉴 — 과거 일자 추가/편집/삭제."""
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
qdate = self.calendar.selectedDate()
|
||||
date_str = qdate.toString('yyyy-MM-dd')
|
||||
existing = self.db.get_work_record(date_str)
|
||||
|
||||
menu = QMenu(self)
|
||||
edit_action = delete_action = add_action = None
|
||||
if existing:
|
||||
edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
|
||||
delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
|
||||
else:
|
||||
add_action = menu.addAction(tr('cal.context_add', date=date_str))
|
||||
|
||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||
if action is None:
|
||||
return
|
||||
|
||||
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||
if action == edit_action:
|
||||
self._open_edit_dialog(date_str)
|
||||
elif action == delete_action:
|
||||
self._delete_record(date_str)
|
||||
elif action == add_action:
|
||||
self._add_past_record(date_str)
|
||||
|
||||
def _add_past_record(self, date_str: str):
|
||||
"""과거 일자 수동 추가."""
|
||||
from ui.past_record_dialog import PastRecordDialog
|
||||
dialog = PastRecordDialog(self, date_str)
|
||||
if dialog.exec_() != QDialog.Accepted:
|
||||
return
|
||||
data = dialog.get_data()
|
||||
if not data:
|
||||
return
|
||||
try:
|
||||
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
|
||||
if data.get('clock_out'):
|
||||
# 총 시간/연장근무 계산
|
||||
from datetime import datetime as _dt
|
||||
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||
from core.time_calculator import TimeCalculator
|
||||
wm = self.db.get_work_minutes()
|
||||
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
|
||||
calc = TimeCalculator(work_minutes=wm, lunch_duration_minutes=lunch)
|
||||
total = (co - ci).total_seconds() / 3600
|
||||
ot_actual, ot_earned = calc.calculate_overtime(
|
||||
ci, co,
|
||||
include_lunch=data.get('lunch', False),
|
||||
include_dinner=data.get('dinner', False),
|
||||
)
|
||||
self.db.update_clock_out(date_str, data['clock_out'], total, ot_actual, ot_earned)
|
||||
if data.get('lunch'):
|
||||
self.db.update_lunch_break(date_str, True)
|
||||
if data.get('dinner'):
|
||||
self.db.update_dinner_break(date_str, True)
|
||||
if ot_earned > 0:
|
||||
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||
self._refresh_calendar()
|
||||
QMessageBox.information(self, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e))
|
||||
|
||||
def _open_edit_dialog(self, date_str: str):
|
||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||
from PyQt5.QtCore import QDate
|
||||
y, m, d = date_str.split('-')
|
||||
self.calendar.setSelectedDate(QDate(int(y), int(m), int(d)))
|
||||
self.date_selected(self.calendar.selectedDate())
|
||||
# 사용자가 화면 하단에 표시된 "✏️ 시간 수정" 버튼 클릭하면 편집
|
||||
|
||||
def _delete_record(self, date_str: str):
|
||||
reply = QMessageBox.question(
|
||||
tr('cal.delete_confirm_title'),
|
||||
tr('cal.delete_confirm_body', date=date_str),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (date_str,))
|
||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (date_str,))
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||
conn.commit()
|
||||
self._refresh_calendar()
|
||||
QMessageBox.information(self, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str))
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _refresh_calendar(self):
|
||||
"""캘린더 마킹 갱신."""
|
||||
if hasattr(self, 'load_calendar_data'):
|
||||
self.load_calendar_data()
|
||||
elif hasattr(self, 'load_records'):
|
||||
self.load_records()
|
||||
|
||||
def date_selected(self, qdate):
|
||||
"""날짜 선택 시"""
|
||||
selected_date = qdate.toPyDate()
|
||||
@ -271,33 +163,33 @@ class CalendarView(QDialog):
|
||||
|
||||
if record:
|
||||
# 상세 정보 표시
|
||||
detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
|
||||
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
||||
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail += f"출근: {record['clock_in']}\n"
|
||||
|
||||
if record.get('clock_out'):
|
||||
detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
|
||||
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
||||
detail += f"퇴근: {record['clock_out']}\n"
|
||||
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
|
||||
|
||||
if record.get('lunch_break'):
|
||||
detail += tr('cal.detail_lunch_used') + '\n'
|
||||
detail += f"점심시간: 사용함\n"
|
||||
else:
|
||||
detail += tr('cal.detail_lunch_unused') + '\n'
|
||||
detail += f"점심시간: 미사용\n"
|
||||
|
||||
if record.get('dinner_break'):
|
||||
detail += tr('cal.detail_dinner_used') + '\n'
|
||||
detail += f"저녁시간: 사용함\n"
|
||||
else:
|
||||
detail += tr('cal.detail_dinner_unused') + '\n'
|
||||
detail += f"저녁시간: 미사용\n"
|
||||
|
||||
if record.get('overtime_earned', 0) > 0:
|
||||
earned_min = record['overtime_earned']
|
||||
earned_hours = earned_min // 60
|
||||
earned_mins = earned_min % 60
|
||||
detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n'
|
||||
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n"
|
||||
else:
|
||||
detail += tr('cal.detail_clock_out_none') + '\n'
|
||||
detail += f"퇴근: 미기록\n"
|
||||
|
||||
if record.get('memo'):
|
||||
detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n'
|
||||
detail += f"\n메모: {record['memo']}\n"
|
||||
|
||||
self.detail_text.setText(detail)
|
||||
self.edit_time_button.setEnabled(True)
|
||||
@ -307,7 +199,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||
self.save_memo_button.setEnabled(True)
|
||||
else:
|
||||
self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
|
||||
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.memo_edit.setPlainText('')
|
||||
@ -320,8 +212,10 @@ class CalendarView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('cal.delete_selected_title'),
|
||||
tr('cal.delete_selected_body', date=self.selected_date_str),
|
||||
"출근 기록 삭제",
|
||||
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
|
||||
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
|
||||
f"※ 이 작업은 되돌릴 수 없습니다.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
@ -331,8 +225,8 @@ class CalendarView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('cal.delete_done_title'),
|
||||
tr('cal.delete_done_body', date=self.selected_date_str)
|
||||
"삭제 완료",
|
||||
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
|
||||
)
|
||||
|
||||
# 캘린더 새로고침
|
||||
@ -352,8 +246,8 @@ class CalendarView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('cal.save_memo_title'),
|
||||
tr('cal.save_memo_body', date=self.selected_date_str)
|
||||
"메모 저장",
|
||||
f"{self.selected_date_str}의 메모가 저장되었습니다."
|
||||
)
|
||||
|
||||
# 상세 정보 새로고침
|
||||
@ -399,7 +293,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
from PyQt5.QtWidgets import QTimeEdit
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
self.setWindowTitle(tr('cal.edit_dialog_title'))
|
||||
self.setWindowTitle("출퇴근 시간 수정")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
@ -408,19 +302,19 @@ class EditWorkTimeDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str))
|
||||
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 출근 시간
|
||||
clock_in_layout = QHBoxLayout()
|
||||
clock_in_layout.setSpacing(4)
|
||||
clock_in_label = QLabel(tr('cal.label_clock_in'))
|
||||
clock_in_label = QLabel("출근:")
|
||||
clock_in_label.setObjectName("field_label")
|
||||
clock_in_label.setFixedWidth(40)
|
||||
clock_in_layout.addWidget(clock_in_label)
|
||||
|
||||
clock_in_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
||||
clock_in_minus_btn = QPushButton("-30분")
|
||||
clock_in_minus_btn.setFixedWidth(55)
|
||||
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
||||
clock_in_layout.addWidget(clock_in_minus_btn)
|
||||
@ -431,7 +325,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
self.clock_in_edit.setTime(clock_in_time)
|
||||
clock_in_layout.addWidget(self.clock_in_edit)
|
||||
|
||||
clock_in_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||
clock_in_plus_btn = QPushButton("+30분")
|
||||
clock_in_plus_btn.setFixedWidth(55)
|
||||
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
||||
clock_in_layout.addWidget(clock_in_plus_btn)
|
||||
@ -440,12 +334,12 @@ class EditWorkTimeDialog(QDialog):
|
||||
# 퇴근 시간
|
||||
clock_out_layout = QHBoxLayout()
|
||||
clock_out_layout.setSpacing(4)
|
||||
clock_out_label = QLabel(tr('cal.label_clock_out'))
|
||||
clock_out_label = QLabel("퇴근:")
|
||||
clock_out_label.setObjectName("field_label")
|
||||
clock_out_label.setFixedWidth(40)
|
||||
clock_out_layout.addWidget(clock_out_label)
|
||||
|
||||
clock_out_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
||||
clock_out_minus_btn = QPushButton("-30분")
|
||||
clock_out_minus_btn.setFixedWidth(55)
|
||||
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
||||
clock_out_layout.addWidget(clock_out_minus_btn)
|
||||
@ -457,7 +351,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
self.clock_out_edit.setTime(clock_out_time)
|
||||
clock_out_layout.addWidget(self.clock_out_edit)
|
||||
|
||||
clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||
clock_out_plus_btn = QPushButton("+30분")
|
||||
clock_out_plus_btn.setFixedWidth(55)
|
||||
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
||||
clock_out_layout.addWidget(clock_out_plus_btn)
|
||||
@ -466,27 +360,27 @@ class EditWorkTimeDialog(QDialog):
|
||||
# 점심/저녁 체크박스 - 한 줄에
|
||||
from PyQt5.QtWidgets import QCheckBox
|
||||
check_layout = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox(tr('cal.check_lunch_1h'))
|
||||
self.lunch_check = QCheckBox("점심 (1시간)")
|
||||
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
||||
check_layout.addWidget(self.lunch_check)
|
||||
|
||||
self.dinner_check = QCheckBox(tr('cal.check_dinner_1h'))
|
||||
self.dinner_check = QCheckBox("저녁 (1시간)")
|
||||
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
||||
check_layout.addWidget(self.dinner_check)
|
||||
layout.addLayout(check_layout)
|
||||
|
||||
# 안내 메시지
|
||||
note = QLabel(tr('cal.edit_note'))
|
||||
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
|
||||
note.setObjectName("note_text")
|
||||
layout.addWidget(note)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button = QPushButton("저장")
|
||||
save_button.setObjectName("btn_success")
|
||||
save_button.clicked.connect(self.save_changes)
|
||||
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(save_button)
|
||||
@ -512,8 +406,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
if clock_out <= clock_in:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('cal.time_error_title'),
|
||||
tr('cal.time_error_body')
|
||||
"시간 오류",
|
||||
"퇴근 시간은 출근 시간보다 늦어야 합니다."
|
||||
)
|
||||
return
|
||||
|
||||
@ -596,12 +490,15 @@ class EditWorkTimeDialog(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('cal.edit_done_title'),
|
||||
tr('cal.edit_done_body',
|
||||
date=self.date_str, clock_in=clock_in, clock_out=clock_out,
|
||||
lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
|
||||
dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
|
||||
break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
|
||||
"수정 완료",
|
||||
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
|
||||
f"출근: {clock_in}\n"
|
||||
f"퇴근: {clock_out}\n"
|
||||
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
|
||||
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
|
||||
f"외출시간: {break_minutes}분\n"
|
||||
f"총 근무시간: {total_hours:.1f}시간\n"
|
||||
f"연장근무: {overtime_earned}분 적립"
|
||||
)
|
||||
|
||||
self.accept()
|
||||
@ -609,8 +506,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
tr('cal.edit_error_title'),
|
||||
tr('cal.edit_error_body', error=str(e))
|
||||
"오류",
|
||||
f"수정 중 오류가 발생했습니다:\n{str(e)}"
|
||||
)
|
||||
finally:
|
||||
if conn:
|
||||
|
||||
@ -9,70 +9,15 @@ from typing import List, Tuple
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
from matplotlib.figure import Figure
|
||||
# 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']
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
import matplotlib
|
||||
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
_MPL = True
|
||||
except Exception as _mpl_err:
|
||||
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
|
||||
except ImportError:
|
||||
_MPL = False
|
||||
try:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
|
||||
# 막대/선은 데이터 구분용 고정 색.
|
||||
_CHART_BG = '#25262B'
|
||||
_CHART_GRID = '#2C2E33'
|
||||
_CHART_TEXT = '#909296'
|
||||
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
|
||||
_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:
|
||||
"""차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경."""
|
||||
ax.set_facecolor(_CHART_BG)
|
||||
ax.tick_params(axis='both', colors=_CHART_TEXT)
|
||||
ax.xaxis.label.set_color(_CHART_TEXT)
|
||||
ax.yaxis.label.set_color(_CHART_TEXT)
|
||||
ax.title.set_color(_CHART_TEXT)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color(_CHART_GRID)
|
||||
ax.grid(axis='y', alpha=0.25, color=_CHART_GRID)
|
||||
|
||||
|
||||
def _apply_dark_figure(fig) -> None:
|
||||
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
|
||||
_refresh_chart_colors()
|
||||
fig.patch.set_facecolor(_CHART_BG)
|
||||
|
||||
|
||||
class _Fallback(QWidget):
|
||||
@ -83,7 +28,7 @@ class _Fallback(QWidget):
|
||||
label = QLabel(message)
|
||||
label.setAlignment(Qt.AlignCenter)
|
||||
label.setWordWrap(True)
|
||||
label.setStyleSheet("color: #909296; padding: 20px;")
|
||||
label.setStyleSheet("color: #888; padding: 20px;")
|
||||
layout.addWidget(label)
|
||||
self.setLayout(layout)
|
||||
|
||||
@ -91,15 +36,12 @@ class _Fallback(QWidget):
|
||||
def make_chart_widget(parent=None) -> QWidget:
|
||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||
if not _MPL:
|
||||
return _Fallback(tr('chart.need_matplotlib'))
|
||||
_refresh_chart_colors()
|
||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||
widget = QWidget(parent)
|
||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG)
|
||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
|
||||
canvas = FigureCanvas(fig)
|
||||
canvas.setStyleSheet(f"background: {_CHART_BG};")
|
||||
layout.addWidget(canvas)
|
||||
widget.setLayout(layout)
|
||||
widget._figure = fig
|
||||
@ -108,136 +50,34 @@ def make_chart_widget(parent=None) -> QWidget:
|
||||
|
||||
|
||||
def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
"""일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁)."""
|
||||
"""일별 근무시간 막대 그래프.
|
||||
|
||||
Args:
|
||||
widget: make_chart_widget()로 만든 위젯
|
||||
records: [{date, total_hours, overtime_minutes}, ...]
|
||||
"""
|
||||
if not getattr(widget, '_figure', None):
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
dates = [r['date'][5:] for r in records] # MM-DD만
|
||||
full_dates = [r['date'] for r in records]
|
||||
hours = [r.get('total_hours', 0) or 0 for r in records]
|
||||
overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records]
|
||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'),
|
||||
color=_CHART_BAR_OVERTIME)
|
||||
ax.set_ylabel(tr('chart.ylabel_hours'))
|
||||
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
ax.bar(dates, base, label='정상', color='#4a90e2')
|
||||
ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
||||
ax.set_ylabel('시간')
|
||||
ax.legend(loc='upper left', fontsize=8)
|
||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||
_apply_dark_axes(ax)
|
||||
|
||||
# 호버 annotation 설정
|
||||
annot = ax.annotate(
|
||||
"", xy=(0, 0), xytext=(15, 15),
|
||||
textcoords="offset points",
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
|
||||
alpha=0.95),
|
||||
color="white", fontsize=9,
|
||||
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
|
||||
)
|
||||
annot.set_visible(False)
|
||||
|
||||
def on_hover(event):
|
||||
if event.inaxes != ax:
|
||||
if annot.get_visible():
|
||||
annot.set_visible(False)
|
||||
widget._canvas.draw_idle()
|
||||
return
|
||||
for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')):
|
||||
for i, bar in enumerate(bars):
|
||||
if bar.contains(event)[0]:
|
||||
h = hours[i]; ot = overtimes[i]
|
||||
text = tr('chart.hover_text',
|
||||
date=full_dates[i], hours=f"{h:.1f}")
|
||||
if ot > 0:
|
||||
text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}")
|
||||
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
||||
annot.set_text(text)
|
||||
annot.set_visible(True)
|
||||
widget._canvas.draw_idle()
|
||||
# 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록
|
||||
db = getattr(widget, '_achievement_db', None)
|
||||
if db is not None:
|
||||
try:
|
||||
if db.get_setting('chart_hover_discovered', 'false').lower() != 'true':
|
||||
db.set_setting('chart_hover_discovered', 'true')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if annot.get_visible():
|
||||
annot.set_visible(False)
|
||||
widget._canvas.draw_idle()
|
||||
|
||||
widget._canvas.mpl_connect("motion_notify_event", on_hover)
|
||||
widget._canvas.draw()
|
||||
|
||||
|
||||
def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
"""출근 시각 분포 히스토그램 (30분 빈)."""
|
||||
if not getattr(widget, '_figure', None):
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
minutes_list = []
|
||||
for r in records:
|
||||
ci = r.get('clock_in')
|
||||
if not ci:
|
||||
continue
|
||||
parts = ci.split(':')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
minutes_list.append(int(parts[0]) * 60 + int(parts[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if not minutes_list:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
bin_size = 30
|
||||
min_m = (min(minutes_list) // bin_size) * bin_size
|
||||
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
|
||||
bins = list(range(min_m, max_m + bin_size, bin_size))
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||
edgecolor=_CHART_BG, linewidth=1)
|
||||
avg = sum(minutes_list) / len(minutes_list)
|
||||
avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}"
|
||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||
label=tr('chart.avg_line', time=avg_time))
|
||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||
rotation=45, fontsize=8)
|
||||
ax.set_ylabel(tr('chart.ylabel_days'))
|
||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
_apply_dark_axes(ax)
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
widget._canvas.draw()
|
||||
|
||||
|
||||
@ -247,7 +87,6 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
|
||||
from datetime import datetime as _dt
|
||||
weekday_totals = [0.0] * 7
|
||||
@ -261,13 +100,12 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
weekday_counts[d.weekday()] += 1
|
||||
|
||||
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
||||
labels = [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')]
|
||||
labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
|
||||
ax.bar(labels, avg, color=colors)
|
||||
ax.set_ylabel(tr('chart.ylabel_avg_hours'))
|
||||
_apply_dark_axes(ax)
|
||||
ax.set_ylabel('평균 시간')
|
||||
ax.set_title('요일별 평균 근무시간')
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
widget._canvas.draw()
|
||||
|
||||
@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 안내 문구
|
||||
info_label = QLabel(tr('dlg.clock_in.prompt'))
|
||||
info_label = QLabel("오늘의 출근시간을 입력해주세요")
|
||||
info_label.setObjectName("field_label")
|
||||
info_label.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# 시간 입력
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel(tr('dlg.clock_in.label'))
|
||||
time_label = QLabel("출근시간:")
|
||||
time_label.setObjectName("field_label")
|
||||
|
||||
self.time_edit = QTimeEdit()
|
||||
@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
|
||||
|
||||
# 빠른 선택 버튼
|
||||
quick_layout = QHBoxLayout()
|
||||
quick_label = QLabel(tr('dlg.clock_in.quick'))
|
||||
quick_label = QLabel("빠른 선택:")
|
||||
quick_label.setObjectName("field_label")
|
||||
|
||||
btn_8am = QPushButton("08:00")
|
||||
btn_9am = QPushButton("09:00")
|
||||
btn_10am = QPushButton("10:00")
|
||||
btn_now = QPushButton(tr('dlg.clock_in.btn_now'))
|
||||
btn_now = QPushButton("현재")
|
||||
|
||||
for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
|
||||
btn.setMinimumHeight(30)
|
||||
@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
ok_button = QPushButton(tr('btn.confirm'))
|
||||
ok_button = QPushButton("확인")
|
||||
ok_button.setObjectName("btn_primary")
|
||||
ok_button.setMinimumHeight(40)
|
||||
ok_button.clicked.connect(self.accept)
|
||||
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button.setMinimumHeight(40)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
@ -134,8 +134,8 @@ if __name__ == "__main__":
|
||||
dialog = ClockInDialog()
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
selected_time = dialog.get_time()
|
||||
print(tr('clock_in_dialog.selected', time=selected_time.strftime('%H:%M:%S')))
|
||||
print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}")
|
||||
else:
|
||||
print(tr('clock_in_dialog.cancelled'))
|
||||
print("취소됨")
|
||||
|
||||
sys.exit()
|
||||
|
||||
@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
||||
import sqlite3
|
||||
|
||||
|
||||
class LockMonitor:
|
||||
@ -18,17 +17,12 @@ class LockMonitor:
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
self.last_locked: bool = False
|
||||
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
|
||||
|
||||
def tick(self) -> None:
|
||||
try:
|
||||
from utils.lock_detector import is_screen_locked
|
||||
locked = is_screen_locked()
|
||||
except Exception as e:
|
||||
if not self._detector_failed_once:
|
||||
self._detector_failed_once = True
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"lock detector failed (silenced after first): {e}")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
was_locked = self.last_locked
|
||||
@ -62,23 +56,14 @@ class LockMonitor:
|
||||
clock_in_str = when.strftime("%H:%M:%S")
|
||||
existing = self.db.get_today_record()
|
||||
if existing:
|
||||
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()
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
||||
(clock_in_str, today),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
else:
|
||||
try:
|
||||
self.db.add_work_record(today, clock_in_str)
|
||||
except sqlite3.IntegrityError:
|
||||
# get_today_record()와 add_work_record() 사이 경쟁 조건
|
||||
with self.db._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
|
||||
(clock_in_str, today),
|
||||
)
|
||||
conn.commit()
|
||||
self.db.add_work_record(today, clock_in_str)
|
||||
w.update_display()
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
"""
|
||||
점심/저녁 토글 컨트롤러.
|
||||
|
||||
main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status /
|
||||
update_dinner_status 가 합쳐져 있던 것을 분리. 1Hz hot path 외 사용자 액션 응답.
|
||||
|
||||
단위 테스트가 가능하도록 window 의존성을 명시적으로 받음.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class MealController:
|
||||
"""점심/저녁 토글 + 상태 라벨 갱신."""
|
||||
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
|
||||
# -------- 토글 --------
|
||||
def toggle_lunch(self) -> None:
|
||||
w = self.window
|
||||
w.lunch_break_enabled = w.lunch_button.isChecked()
|
||||
self.refresh_lunch_label()
|
||||
|
||||
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
|
||||
if w.lunch_break_enabled:
|
||||
w.auto_lunch_applied_today = True
|
||||
|
||||
if w.is_clocked_in:
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.update_lunch_break(today, w.lunch_break_enabled)
|
||||
|
||||
def toggle_dinner(self) -> None:
|
||||
w = self.window
|
||||
w.dinner_break_enabled = w.dinner_button.isChecked()
|
||||
self.refresh_dinner_label()
|
||||
|
||||
if w.is_clocked_in:
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.update_dinner_break(today, w.dinner_break_enabled)
|
||||
|
||||
# -------- 라벨 --------
|
||||
def refresh_lunch_label(self) -> None:
|
||||
w = self.window
|
||||
w.lunch_button.setText(
|
||||
tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add')
|
||||
)
|
||||
|
||||
def refresh_dinner_label(self) -> None:
|
||||
w = self.window
|
||||
w.dinner_button.setText(
|
||||
tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add')
|
||||
)
|
||||
@ -2,25 +2,11 @@
|
||||
알림 오케스트레이션.
|
||||
|
||||
5분 가드로 건강/주간/누적 임계 알림을 throttle.
|
||||
notifier.py의 알림 메서드를 적절한 시점에 호출.
|
||||
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.settings_keys import (
|
||||
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||
)
|
||||
from core.i18n import tr
|
||||
from utils.debug_log import dlog
|
||||
|
||||
|
||||
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||
try:
|
||||
v = int(db.get_setting(key, str(default)) or default)
|
||||
except (ValueError, TypeError):
|
||||
v = default
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
class NotificationOrchestrator:
|
||||
"""update_display() 1Hz tick에서 호출."""
|
||||
@ -31,156 +17,35 @@ class NotificationOrchestrator:
|
||||
self.notifier = window.notifier
|
||||
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
|
||||
|
||||
def maybe_send_weekly_report(self, now: datetime) -> None:
|
||||
"""월요일 첫 update_display 호출 시 지난주 요약 발송 (시스템 + Discord).
|
||||
|
||||
notification_log로 중복 가드. 월요일이 아니거나 이미 보냈으면 no-op.
|
||||
"""
|
||||
if now.weekday() != 0: # 0=월요일
|
||||
return
|
||||
if self.db.has_notification_today('system', 'weekly_report'):
|
||||
return
|
||||
# 지난주 데이터 (월~일)
|
||||
from datetime import timedelta as _td
|
||||
last_mon = now.date() - _td(days=7)
|
||||
last_sun = now.date() - _td(days=1)
|
||||
records = self.db.get_work_records_by_range(last_mon.isoformat(), last_sun.isoformat())
|
||||
closed = [r for r in records if r.get('clock_out')]
|
||||
if not closed:
|
||||
return # 지난주 기록 없음
|
||||
total_h = sum((r.get('total_hours') or 0) for r in closed)
|
||||
ot_total = sum((r.get('overtime_minutes') or 0) for r in closed)
|
||||
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||
avg_h = total_h / len(closed) if closed else 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)"
|
||||
|
||||
title = tr('notif.weekly_report.title')
|
||||
body = tr('notif.weekly_report.body',
|
||||
start=last_mon, end=last_sun,
|
||||
total_h=total_h, days=len(closed),
|
||||
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
||||
longest=longest_str)
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
self.db.log_notification('system', 'weekly_report')
|
||||
|
||||
# Discord 도 옵션 활성 시 push
|
||||
if self.db.get_setting('discord_notif_clock_out', 'true').lower() == 'true':
|
||||
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||
if url:
|
||||
try:
|
||||
from utils.discord_webhook import send, COLOR_BLUE
|
||||
fields = [
|
||||
{"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True},
|
||||
{"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True},
|
||||
{"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True},
|
||||
{"name": tr('field.longest_day'), "value": longest_str, "inline": False},
|
||||
]
|
||||
ok = send(url, tr('notif.weekly_report.title'),
|
||||
tr('notif.weekly_report.body',
|
||||
start=last_mon, end=last_sun,
|
||||
total_h=total_h, days=len(closed),
|
||||
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
||||
longest=longest_str),
|
||||
color=COLOR_BLUE, fields=fields)
|
||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||
except Exception as e:
|
||||
dlog(f"discord weekly_report failed: {e}")
|
||||
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
|
||||
is_holiday: bool = False) -> None:
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
||||
n = self.notifier
|
||||
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
|
||||
if not is_holiday:
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
|
||||
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
n.check_lunch_reminder(self.window.clock_in_time,
|
||||
self.window.lunch_break_enabled, now)
|
||||
n.check_dinner_reminder(self.window.clock_in_time,
|
||||
getattr(self.window, 'dinner_break_enabled', False), now)
|
||||
if remaining_seconds < 0:
|
||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
||||
|
||||
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
|
||||
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
|
||||
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
|
||||
# 5분 간격 throttle: 건강/주간/누적/휴식권고
|
||||
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||
self._last_5min_bucket = now.minute
|
||||
|
||||
# 월요일 첫 출근 시 지난주 리포트
|
||||
self.maybe_send_weekly_report(now)
|
||||
|
||||
# 휴식 권고 (장시간 연속 근무)
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
||||
|
||||
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
|
||||
consecutive = self.db.get_consecutive_overtime_days()
|
||||
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||
if consecutive >= consecutive_th:
|
||||
if consecutive >= 3:
|
||||
n.notify_health_warning(consecutive)
|
||||
self._discord_health(consecutive, break_minutes, now)
|
||||
|
||||
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
||||
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||
if weekly_hours > weekly_th:
|
||||
if weekly_hours > 52:
|
||||
n.notify_weekly_hours(weekly_hours)
|
||||
|
||||
balance_minutes = self.db.get_total_overtime_balance()
|
||||
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||
if balance_minutes / 60.0 >= ot_threshold_h:
|
||||
if balance_minutes >= 1200:
|
||||
n.notify_overtime_threshold(balance_minutes / 60.0)
|
||||
|
||||
# 도전과제 평가 (5분 throttle)
|
||||
self._evaluate_achievements(now)
|
||||
|
||||
def _evaluate_achievements(self, now: datetime) -> None:
|
||||
"""도전과제 평가 + 신규 잠금 해제 알림.
|
||||
|
||||
실패는 silent — 도전과제 시스템이 메인 흐름을 막으면 안 됨.
|
||||
"""
|
||||
try:
|
||||
from core.achievements import evaluate_all
|
||||
unlocked = evaluate_all(self.db)
|
||||
except Exception as e:
|
||||
dlog(f"achievement eval failed: {e}")
|
||||
return
|
||||
if not unlocked:
|
||||
return
|
||||
# 시스템 알림 + Discord push (옵션)
|
||||
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
|
||||
for a in unlocked:
|
||||
self.db.log_notification('system', f'achievement:{a.code}')
|
||||
if notif_on:
|
||||
title = tr('notif.achievement.title', icon=a.badge_icon)
|
||||
body = tr('notif.achievement.body', name=a.name, description=a.description)
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
# Discord 통합 push (여러 개면 묶어서)
|
||||
self._discord_achievements(unlocked)
|
||||
|
||||
def _discord_achievements(self, unlocked: list) -> None:
|
||||
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
|
||||
return
|
||||
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
from utils import discord_webhook
|
||||
fields = [{"name": f"{a.badge_icon} {a.name}",
|
||||
"value": a.description, "inline": False}
|
||||
for a in unlocked[:10]]
|
||||
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||
ok = discord_webhook.send(
|
||||
url,
|
||||
tr('discord.achievement.title', count=len(unlocked)),
|
||||
tr('discord.achievement.body', extra=extra),
|
||||
color=discord_webhook.COLOR_YELLOW,
|
||||
fields=fields,
|
||||
)
|
||||
self.db.log_notification('discord', 'achievement', success=ok)
|
||||
except Exception as e:
|
||||
dlog(f"discord achievement push failed: {e}")
|
||||
|
||||
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
|
||||
"""건강 경고 Discord push (옵션)."""
|
||||
if self.db.has_notification_today('discord', 'health'):
|
||||
@ -195,5 +60,5 @@ class NotificationOrchestrator:
|
||||
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
||||
ok = discord_webhook.send_health_warning(url, elapsed)
|
||||
self.db.log_notification('discord', 'health', success=ok)
|
||||
except Exception as e:
|
||||
dlog(f"discord health push failed: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -1,456 +0,0 @@
|
||||
"""
|
||||
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
||||
|
||||
핵심 원칙:
|
||||
- 다이얼로그 배경: #0e0e14 (깊은 다크)
|
||||
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
|
||||
- 헤더: 큰 숫자 + 그라디언트 progress bar
|
||||
- 탭: 골드 강조 선택
|
||||
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
|
||||
|
||||
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List, Tuple
|
||||
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
|
||||
QProgressBar, QPushButton, QWidget, QSizePolicy,
|
||||
QTabWidget)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
||||
DARK_BG = '#1A1B1E'
|
||||
DARK_PANEL = '#25262B'
|
||||
DARK_PANEL_2 = '#2C2E33'
|
||||
DARK_BORDER = '#2C2E33'
|
||||
DARK_BORDER_STRONG = '#373A40'
|
||||
DARK_TEXT = '#E9ECEF'
|
||||
DARK_TEXT_DIM = '#909296'
|
||||
DARK_TEXT_FAINT = '#6C6E73'
|
||||
|
||||
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||
ACCENT_GOLD = '#ffd24a'
|
||||
ACCENT_BLUE = '#4DABF7'
|
||||
ACCENT_CYAN = '#4adef0'
|
||||
ACCENT_PINK = '#ff90b8'
|
||||
ACCENT_GREEN = '#51CF66'
|
||||
ACCENT_ORANGE = '#fcd34d'
|
||||
ACCENT_RED = '#FA5252'
|
||||
|
||||
# 카드 테마 (등급/상태별)
|
||||
CARD_THEMES = {
|
||||
'gold': {
|
||||
'border': '#ffb700', 'border_strong': '#ffd24a',
|
||||
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
|
||||
},
|
||||
'blue': {
|
||||
'border': '#5a8eff', 'border_strong': '#6b9eff',
|
||||
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
|
||||
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
|
||||
},
|
||||
'cyan': {
|
||||
'border': '#3acce0', 'border_strong': '#4adef0',
|
||||
'bg_top': '#0e3340', 'bg_bot': '#08222b',
|
||||
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
|
||||
},
|
||||
'green': {
|
||||
'border': '#3ace70', 'border_strong': '#4ade80',
|
||||
'bg_top': '#0e3324', 'bg_bot': '#082218',
|
||||
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
|
||||
},
|
||||
'pink': {
|
||||
'border': '#ff5a8c', 'border_strong': '#ff90b8',
|
||||
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4', 'accent': ACCENT_PINK,
|
||||
},
|
||||
'red': {
|
||||
'border': '#ea5566', 'border_strong': '#fb7185',
|
||||
'bg_top': '#3a1620', 'bg_bot': '#260e16',
|
||||
'text': '#ffb8c0', 'accent': ACCENT_RED,
|
||||
},
|
||||
'gray': {
|
||||
'border': '#44446a', 'border_strong': '#666688',
|
||||
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
|
||||
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 테마 연동 ──────────────────────────────────────────────────
|
||||
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
||||
# 앱 테마(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 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
def dialog_qss() -> str:
|
||||
"""다이얼로그 전체 배경 (현재 테마)."""
|
||||
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = None) -> str:
|
||||
p = _pal()
|
||||
if accent is None:
|
||||
accent = p['blue']
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {p['panel2']};
|
||||
color: {p['text_dim']};
|
||||
padding: 9px 18px;
|
||||
border: 1px solid {p['border']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {p['panel']};
|
||||
color: {accent};
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid {accent};
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
background: {p['border_strong']};
|
||||
color: {p['text']};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scroll_qss() -> str:
|
||||
p = _pal()
|
||||
return f"""
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
QScrollBar:vertical {{
|
||||
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
def button_qss(variant: str = 'default') -> str:
|
||||
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
||||
p = _pal()
|
||||
if variant == 'primary':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['blue']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['blue_hover']}; }}
|
||||
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
|
||||
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['green']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['green_hover']}; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['red']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['red_hover']}; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {p['text_dim']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
||||
border-color: {p['blue']}; }}
|
||||
"""
|
||||
# default
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['panel2']}; color: {p['text']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
|
||||
|
||||
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
big_color: str = ACCENT_GOLD,
|
||||
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
|
||||
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
|
||||
|
||||
Args:
|
||||
title: 큰 숫자 위 작은 라벨 (예: "달성")
|
||||
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
|
||||
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
|
||||
big_color: 큰 숫자 색
|
||||
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||
"""
|
||||
p = _pal()
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
|
||||
""")
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(20, 14, 20, 14)
|
||||
layout.setSpacing(20)
|
||||
|
||||
# 좌측: 제목 + 큰 숫자
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(2)
|
||||
if title:
|
||||
t = QLabel(title)
|
||||
t.setStyleSheet(
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
left.addWidget(t)
|
||||
big = QLabel(
|
||||
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
||||
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
|
||||
f" {subtitle}</span>" if subtitle else '')
|
||||
)
|
||||
big.setTextFormat(Qt.RichText)
|
||||
big.setStyleSheet("background: transparent; border: none;")
|
||||
left.addWidget(big)
|
||||
layout.addLayout(left)
|
||||
|
||||
# 우측: extra widgets
|
||||
if extra_widgets:
|
||||
layout.addStretch()
|
||||
for w in extra_widgets:
|
||||
layout.addWidget(w)
|
||||
else:
|
||||
layout.addStretch()
|
||||
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
|
||||
def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
theme: str = 'blue', icon: str = '') -> QFrame:
|
||||
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
||||
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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(16, 12, 16, 12)
|
||||
outer.setSpacing(12)
|
||||
|
||||
if 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(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
outer.addWidget(icon_lbl)
|
||||
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(2)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 9.5pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
text_box.addWidget(title_lbl)
|
||||
|
||||
val_lbl = QLabel(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
val_lbl.setTextFormat(Qt.RichText)
|
||||
val_lbl.setStyleSheet("background: transparent; border: none;")
|
||||
text_box.addWidget(val_lbl)
|
||||
|
||||
if subtitle:
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setStyleSheet(
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
sub_lbl.setWordWrap(True)
|
||||
text_box.addWidget(sub_lbl)
|
||||
|
||||
outer.addLayout(text_box, 1)
|
||||
card.setLayout(outer)
|
||||
return card
|
||||
|
||||
|
||||
def build_section_card(title: str, content: QWidget,
|
||||
theme: str = 'gray', icon: str = '') -> QFrame:
|
||||
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
||||
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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 14)
|
||||
layout.setSpacing(8)
|
||||
|
||||
head = QHBoxLayout()
|
||||
if 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(
|
||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(i)
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(title_lbl)
|
||||
head.addStretch()
|
||||
layout.addLayout(head)
|
||||
|
||||
layout.addWidget(content, 1)
|
||||
card.setLayout(layout)
|
||||
return card
|
||||
|
||||
|
||||
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
||||
height: int = 10) -> None:
|
||||
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||
pb.setMinimumHeight(height)
|
||||
pb.setMaximumHeight(height)
|
||||
pb.setTextVisible(False)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-radius: {height // 2}px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {t['border']}, stop:1 {t['border_strong']});
|
||||
border-radius: {height // 2}px;
|
||||
}}
|
||||
""")
|
||||
|
||||
|
||||
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||
color: str = None) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
||||
lbl = QLabel(text)
|
||||
if color is None:
|
||||
color = _pal()['text']
|
||||
weight_str = 'bold' if weight == 'bold' else 'normal'
|
||||
lbl.setStyleSheet(
|
||||
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
||||
f"background: transparent; border: none; padding: 0; margin: 0;"
|
||||
)
|
||||
return lbl
|
||||
@ -1,102 +0,0 @@
|
||||
"""
|
||||
목표 진행률 위젯.
|
||||
|
||||
월 연장근무 상한 + 일평균 목표를 stats_view 또는 메인에 표시.
|
||||
설정값이 0이면 비활성 (위젯 자체 hide).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, date
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class GoalWidget(QWidget):
|
||||
"""월간 목표 진행률 표시."""
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title = QLabel(tr('goal.title'))
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
self.ot_label = QLabel(tr('goal.overtime'))
|
||||
self.ot_label.setFixedWidth(100)
|
||||
self.ot_bar = QProgressBar()
|
||||
self.ot_bar.setTextVisible(True)
|
||||
self.ot_bar.setFixedHeight(18)
|
||||
ot_row.addWidget(self.ot_label)
|
||||
ot_row.addWidget(self.ot_bar, 1)
|
||||
layout.addLayout(ot_row)
|
||||
|
||||
# 일평균
|
||||
avg_row = QHBoxLayout()
|
||||
self.avg_label = QLabel(tr('goal.avg_daily'))
|
||||
self.avg_label.setFixedWidth(100)
|
||||
self.avg_bar = QProgressBar()
|
||||
self.avg_bar.setTextVisible(True)
|
||||
self.avg_bar.setFixedHeight(18)
|
||||
avg_row.addWidget(self.avg_label)
|
||||
avg_row.addWidget(self.avg_bar, 1)
|
||||
layout.addLayout(avg_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def refresh(self):
|
||||
"""현재 설정값과 이번 달 통계로 진행률 갱신. 0=비활성 시 row 숨김."""
|
||||
try:
|
||||
ot_target = int(self.db.get_setting('goal_overtime_max_monthly', '0') or 0)
|
||||
avg_target = float(self.db.get_setting('goal_avg_hours_daily', '0') or 0)
|
||||
except (ValueError, TypeError):
|
||||
ot_target, avg_target = 0, 0.0
|
||||
|
||||
# 둘 다 비활성이면 위젯 자체 숨김
|
||||
if ot_target <= 0 and avg_target <= 0:
|
||||
self.setVisible(False)
|
||||
return
|
||||
self.setVisible(True)
|
||||
|
||||
now = datetime.now()
|
||||
stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
ot_total = (stats.get('total_overtime_minutes') or 0)
|
||||
total_h = stats.get('total_hours') or 0
|
||||
work_days = stats.get('work_days') or 1
|
||||
|
||||
# 연장근무 상한 (낮을수록 좋음)
|
||||
if ot_target > 0:
|
||||
self.ot_label.setVisible(True)
|
||||
self.ot_bar.setVisible(True)
|
||||
self.ot_bar.setMaximum(ot_target)
|
||||
self.ot_bar.setValue(min(ot_total, ot_target))
|
||||
ratio = ot_total / ot_target if ot_target else 0
|
||||
ot_h, ot_m = ot_total // 60, ot_total % 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")
|
||||
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
|
||||
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.ot_label.setVisible(False)
|
||||
self.ot_bar.setVisible(False)
|
||||
|
||||
# 일평균 (목표 시간보다 적으면 좋음)
|
||||
if avg_target > 0:
|
||||
self.avg_label.setVisible(True)
|
||||
self.avg_bar.setVisible(True)
|
||||
avg = total_h / work_days if work_days else 0
|
||||
self.avg_bar.setMaximum(int(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")
|
||||
ratio = avg / avg_target if avg_target else 0
|
||||
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
|
||||
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.avg_label.setVisible(False)
|
||||
self.avg_bar.setVisible(False)
|
||||
132
ui/help_view.py
132
ui/help_view.py
@ -2,7 +2,6 @@
|
||||
사용 설명 가이드 창.
|
||||
|
||||
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
|
||||
도전과제/통계 다이얼로그와 동일한 다크 톤.
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QWidget, QTabWidget, QTextBrowser)
|
||||
@ -10,7 +9,6 @@ from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr, tr_html
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
|
||||
|
||||
|
||||
class HelpView(QDialog):
|
||||
@ -30,47 +28,42 @@ class HelpView(QDialog):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(tr('window.help'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(720, 720)
|
||||
self.resize(820, 760)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.setMinimumSize(680, 720)
|
||||
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(20, 16, 20, 14)
|
||||
main_layout.setSpacing(10)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# 다크 타이틀
|
||||
title = QLabel(tr('window.help'))
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
tabs.setDocumentMode(True)
|
||||
for html_key, tab_label_key in self._TABS:
|
||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
||||
main_layout.addWidget(tabs, 1)
|
||||
main_layout.addWidget(tabs)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
button_layout.setContentsMargins(20, 10, 20, 20)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton(tr('help.onboarding_button'))
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
# 온보딩 다시 보기 (왼쪽)
|
||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(40)
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
button_layout.addWidget(onboarding_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setMinimumHeight(36)
|
||||
close_button.setObjectName("btn_primary")
|
||||
close_button.setMinimumHeight(40)
|
||||
close_button.setMinimumWidth(120)
|
||||
close_button.setStyleSheet(button_qss('primary'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
@ -85,106 +78,15 @@ class HelpView(QDialog):
|
||||
|
||||
def _make_tab(self, html: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background: {tc('panel')};")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
browser = QTextBrowser()
|
||||
browser.setOpenExternalLinks(False)
|
||||
# HTML 내부에 다크 톤 스타일 주입
|
||||
styled_html = self._inject_dark_styles(html)
|
||||
browser.setHtml(styled_html)
|
||||
browser.setStyleSheet(f"""
|
||||
QTextBrowser {{
|
||||
background: {tc('panel')};
|
||||
color: {tc('text')};
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 10.5pt;
|
||||
selection-background-color: {tc('blue')};
|
||||
selection-color: #ffffff;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: {tc('panel')}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
""")
|
||||
browser.setHtml(html)
|
||||
layout.addWidget(browser)
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
def _inject_dark_styles(self, html: str) -> str:
|
||||
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
|
||||
text = tc('text')
|
||||
dim = tc('text_dim')
|
||||
blue = tc('blue')
|
||||
green = tc('green')
|
||||
panel2 = tc('panel2')
|
||||
border = tc('border')
|
||||
css = f"""
|
||||
<style>
|
||||
body, p, li {{
|
||||
color: {text};
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
h1, h2, h3, h4 {{
|
||||
color: {blue};
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: {blue}; }}
|
||||
h4 {{ font-size: 11pt; color: {green}; }}
|
||||
b, strong {{ color: {text}; }}
|
||||
code {{
|
||||
background: {panel2};
|
||||
color: {blue};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
pre {{
|
||||
background: {panel2};
|
||||
border: 1px solid {border};
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: {text};
|
||||
}}
|
||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
a {{ color: {blue}; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||
th {{
|
||||
background: {panel2};
|
||||
color: {text};
|
||||
padding: 8px 12px;
|
||||
border: 1px solid {border};
|
||||
text-align: left;
|
||||
}}
|
||||
td {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid {border};
|
||||
color: {text};
|
||||
}}
|
||||
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
|
||||
blockquote {{
|
||||
border-left: 3px solid {blue};
|
||||
margin-left: 0;
|
||||
padding: 4px 16px;
|
||||
color: {dim};
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
return css + html
|
||||
|
||||
|
||||
# 단독 실행 테스트
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
"""
|
||||
런타임 i18n 재번역 — 재시작 없이 언어 전환.
|
||||
|
||||
사용법:
|
||||
from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate
|
||||
|
||||
label = QLabel(tr('label.foo'))
|
||||
register(label, 'label.foo') # 약한 참조로 등록
|
||||
|
||||
button = QPushButton()
|
||||
register(button, 'btn.bar', kwargs={'name': 'X'})
|
||||
|
||||
# 그룹박스 제목 등 setter가 다른 경우
|
||||
register(group, 'group.work_time', setter='setTitle')
|
||||
|
||||
# 윈도우 제목
|
||||
register(dialog, 'window.foo', setter='setWindowTitle')
|
||||
|
||||
언어 변경:
|
||||
set_language_and_retranslate('en')
|
||||
|
||||
각 widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는
|
||||
키는 kwargs를 함께 등록하면 retranslate 시 같은 인자로 재계산.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import weakref
|
||||
from typing import Any, Callable, List, Tuple, Optional
|
||||
|
||||
from core.i18n import tr, set_language as _set_language
|
||||
|
||||
# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트
|
||||
# weakref가 죽으면 다음 retranslate 시 정리.
|
||||
_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = []
|
||||
|
||||
|
||||
def register(widget: Any, key: str, *, setter: str = 'setText',
|
||||
kwargs: Optional[dict] = None,
|
||||
post: Optional[Callable[[str], str]] = None) -> None:
|
||||
"""위젯을 retranslate 대상으로 등록.
|
||||
|
||||
Args:
|
||||
widget: PyQt 위젯 (setText/setTitle/setWindowTitle 등 지원)
|
||||
key: i18n 키
|
||||
setter: 호출할 메서드명 (기본 setText)
|
||||
kwargs: tr()에 전달할 format 인자 (정적인 경우만)
|
||||
post: 번역 후 한 번 더 가공할 콜백 — 예: 이모지 prefix
|
||||
"""
|
||||
# 약한 참조 — 위젯 삭제 시 자동 GC
|
||||
try:
|
||||
ref = weakref.ref(widget)
|
||||
except TypeError:
|
||||
# weakref 미지원 객체는 retranslate 불가
|
||||
return
|
||||
_registry.append((ref, key, setter, kwargs or {}, post))
|
||||
# 초기 적용
|
||||
_apply(widget, key, setter, kwargs or {}, post)
|
||||
|
||||
|
||||
def retranslate_all() -> None:
|
||||
"""모든 등록된 위젯에 현재 언어로 텍스트 재적용."""
|
||||
global _registry
|
||||
alive = []
|
||||
for ref, key, setter, kw, post in _registry:
|
||||
widget = ref()
|
||||
if widget is None:
|
||||
continue # 죽은 위젯은 빼버림
|
||||
try:
|
||||
_apply(widget, key, setter, kw, post)
|
||||
alive.append((ref, key, setter, kw, post))
|
||||
except RuntimeError:
|
||||
# Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스
|
||||
continue
|
||||
_registry = alive
|
||||
|
||||
|
||||
def set_language_and_retranslate(lang: str) -> None:
|
||||
"""언어 전환 + 즉시 재번역."""
|
||||
_set_language(lang)
|
||||
retranslate_all()
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""레지스트리 비우기 (테스트용)."""
|
||||
_registry.clear()
|
||||
|
||||
|
||||
def _apply(widget: Any, key: str, setter: str, kw: dict,
|
||||
post: Optional[Callable[[str], str]]) -> None:
|
||||
text = tr(key, **kw)
|
||||
if post:
|
||||
text = post(text)
|
||||
fn = getattr(widget, setter, None)
|
||||
if callable(fn):
|
||||
fn(text)
|
||||
82
ui/icons.py
82
ui/icons.py
@ -1,82 +0,0 @@
|
||||
"""모노크롬 라인 아이콘 (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()
|
||||
@ -1,121 +0,0 @@
|
||||
"""
|
||||
연차 사용 캘린더 시각화.
|
||||
|
||||
QCalendarWidget에 사용 연차일을 색칠로 표시.
|
||||
- 1.0일: 진한 색
|
||||
- 0.5일(반차): 중간 색
|
||||
- 0.25일(반반차): 옅은 색
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QCalendarWidget)
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class LeaveCalendarView(QDialog):
|
||||
"""연차 캘린더 시각화."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('leave_cal.title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._mark_dates()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 헤더: 잔여 + 범례
|
||||
header = QHBoxLayout()
|
||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||
used = total - balance
|
||||
title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
layout.addLayout(header)
|
||||
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
legend = QHBoxLayout()
|
||||
for _color, _txt in [('#51CF66', tr('leave_cal.legend_full')), ('#FAB005', tr('leave_cal.legend_half')),
|
||||
('#B197FC', tr('leave_cal.legend_quarter')), ('#4DABF7', tr('leave_cal.legend_planned')),
|
||||
('#748FFC', tr('leave_cal.legend_full_planned'))]:
|
||||
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
l.setStyleSheet("padding: 2px 6px;")
|
||||
legend.addWidget(l)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
|
||||
# 캘린더
|
||||
self.calendar = QCalendarWidget()
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self._on_date_click)
|
||||
layout.addWidget(self.calendar, 1)
|
||||
|
||||
# 선택 일자 정보
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
|
||||
layout.addWidget(self.detail_label)
|
||||
|
||||
# 닫기 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _mark_dates(self):
|
||||
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
|
||||
from datetime import date as _date
|
||||
today = _date.today()
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
for r in records:
|
||||
try:
|
||||
d = datetime.strptime(r['date'], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
days = float(r.get('days') or 0)
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
|
||||
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
|
||||
else:
|
||||
# 과거/오늘 = 사용 완료 색상
|
||||
if days >= 1.0:
|
||||
color = QColor("#4caf50")
|
||||
elif days >= 0.5:
|
||||
color = QColor("#ffc107")
|
||||
else:
|
||||
color = QColor("#9c27b0")
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setBackground(QBrush(color))
|
||||
fmt.setForeground(QBrush(QColor("white")))
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
|
||||
def _on_date_click(self, qdate):
|
||||
date_str = qdate.toString('yyyy-MM-dd')
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
match = [r for r in records if r['date'] == date_str]
|
||||
if not match:
|
||||
self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
|
||||
return
|
||||
parts = []
|
||||
for r in match:
|
||||
t = r.get('leave_type', 'annual')
|
||||
d = float(r.get('days') or 0)
|
||||
memo = r.get('memo') or ''
|
||||
parts.append(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))
|
||||
161
ui/leave_view.py
161
ui/leave_view.py
@ -40,32 +40,27 @@ class LeaveView(QDialog):
|
||||
|
||||
# 제목 + 잔액 + 설정 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel(tr('view.leave.title'))
|
||||
title = QLabel("연차 관리")
|
||||
title.setObjectName("dialog_title")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
self.balance_label = QLabel(tr('view.leave.balance_zero'))
|
||||
self.balance_label = QLabel("잔여: 0일")
|
||||
self.balance_label.setObjectName("badge_leave")
|
||||
header_layout.addWidget(self.balance_label)
|
||||
set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
|
||||
set_balance_button = QPushButton("잔여 설정")
|
||||
set_balance_button.clicked.connect(self.set_balance)
|
||||
header_layout.addWidget(set_balance_button)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 사용 내역
|
||||
used_group = QGroupBox(tr('view.leave.used_group'))
|
||||
used_group = QGroupBox("📤 사용 내역")
|
||||
used_layout = QVBoxLayout()
|
||||
used_layout.setSpacing(4)
|
||||
used_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.used_table = QTableWidget()
|
||||
self.used_table.setColumnCount(4)
|
||||
self.used_table.setHorizontalHeaderLabels([
|
||||
tr('view.leave.col_date'),
|
||||
tr('view.leave.col_type'),
|
||||
tr('view.leave.col_used'),
|
||||
tr('view.leave.col_reason'),
|
||||
])
|
||||
self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"])
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
@ -82,45 +77,22 @@ class LeaveView(QDialog):
|
||||
|
||||
# 버튼들
|
||||
button_layout = QHBoxLayout()
|
||||
add_leave_button = QPushButton(tr('view.leave.btn_add'))
|
||||
add_leave_button = QPushButton("➕ 연차 사용 추가")
|
||||
add_leave_button.clicked.connect(self.add_leave_record)
|
||||
button_layout.addWidget(add_leave_button)
|
||||
|
||||
cal_button = QPushButton(tr('view.leave.btn_calendar'))
|
||||
cal_button.clicked.connect(self._show_calendar)
|
||||
button_layout.addWidget(cal_button)
|
||||
|
||||
schedule_button = QPushButton(tr('view.leave.btn_schedule'))
|
||||
schedule_button.setToolTip(tr('view.leave.schedule_tooltip'))
|
||||
schedule_button.clicked.connect(self._show_schedule)
|
||||
button_layout.addWidget(schedule_button)
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button = QPushButton("닫기")
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _show_calendar(self):
|
||||
from ui.leave_calendar_view import LeaveCalendarView
|
||||
dlg = LeaveCalendarView(self, self.db)
|
||||
dlg.exec_()
|
||||
|
||||
def _show_schedule(self):
|
||||
from ui.schedule_view import ScheduleView
|
||||
dlg = ScheduleView(self, self.db)
|
||||
dlg.exec_()
|
||||
# 닫고 돌아오면 잔액/리스트 갱신
|
||||
self.load_data()
|
||||
|
||||
def load_data(self):
|
||||
"""데이터 로드"""
|
||||
# 잔액 업데이트
|
||||
balance = self.db.get_leave_balance()
|
||||
hours = balance * 8
|
||||
self.balance_label.setText(tr('view.leave.balance_fmt',
|
||||
days=balance, hours=hours))
|
||||
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
|
||||
|
||||
# 사용 내역 로드 (잔액 조정 제외)
|
||||
records = self.db.get_leave_records(exclude_bulk=True)
|
||||
@ -137,16 +109,16 @@ class LeaveView(QDialog):
|
||||
days = record['days']
|
||||
hours = days * 8
|
||||
if days == 1.0:
|
||||
days_str = tr('view.leave.used_1day')
|
||||
days_str = "1일"
|
||||
elif days == 0.5:
|
||||
days_str = tr('view.leave.used_half_day')
|
||||
days_str = "0.5일 (4시간)"
|
||||
elif hours < 8:
|
||||
days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours)
|
||||
days_str = f"{days}일 ({hours}시간)"
|
||||
else:
|
||||
days_str = tr('view.leave.used_days_fmt', days=days)
|
||||
days_str = f"{days}일"
|
||||
days_item = QTableWidgetItem(days_str)
|
||||
days_item.setTextAlignment(Qt.AlignCenter)
|
||||
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
|
||||
memo_item = QTableWidgetItem(record['memo'] or "")
|
||||
|
||||
@ -162,7 +134,7 @@ class LeaveView(QDialog):
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction(tr('btn.delete_short'), self)
|
||||
delete_action = QAction("삭제", self)
|
||||
delete_action.triggered.connect(self.delete_leave_record)
|
||||
menu.addAction(delete_action)
|
||||
menu.exec_(self.used_table.viewport().mapToGlobal(position))
|
||||
@ -182,10 +154,11 @@ class LeaveView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.leave.delete_confirm_body',
|
||||
date=date_item.text(), type=type_item.text(),
|
||||
days=days_item.text()),
|
||||
"삭제 확인",
|
||||
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
|
||||
f"날짜: {date_item.text()}\n"
|
||||
f"구분: {type_item.text()}\n"
|
||||
f"사용: {days_item.text()}",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -200,12 +173,13 @@ class LeaveView(QDialog):
|
||||
|
||||
hours, ok = QInputDialog.getDouble(
|
||||
self,
|
||||
tr('view.leave.set_title'),
|
||||
tr('view.leave.set_prompt'),
|
||||
"연차 시간 설정",
|
||||
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
|
||||
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
|
||||
current_hours,
|
||||
0.0,
|
||||
999.0,
|
||||
1
|
||||
1 # 소수점 첫째자리까지 (0.5 단위)
|
||||
)
|
||||
|
||||
if ok:
|
||||
@ -216,8 +190,8 @@ class LeaveView(QDialog):
|
||||
self.db.set_leave_balance(days)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('view.leave.set_done_title'),
|
||||
tr('view.leave.set_done_body', days=days, hours=hours)
|
||||
"설정 완료",
|
||||
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
|
||||
)
|
||||
self.load_data()
|
||||
|
||||
@ -239,7 +213,7 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('view.leave.add_title'))
|
||||
self.setWindowTitle("연차 사용 기록 추가")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -248,32 +222,30 @@ class AddLeaveDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(tr('view.leave.add_title'))
|
||||
title = QLabel("연차 사용 기록 추가")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 날짜 + 구분 한 줄
|
||||
row1 = QHBoxLayout()
|
||||
date_label = QLabel(tr('view.leave.field_date'))
|
||||
date_label = QLabel("날짜:")
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(40)
|
||||
self.date_edit = QDateEdit()
|
||||
self.date_edit.setDate(QDate.currentDate())
|
||||
self.date_edit.setCalendarPopup(True)
|
||||
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
|
||||
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
|
||||
row1.addWidget(date_label)
|
||||
row1.addWidget(self.date_edit)
|
||||
row1.addSpacing(8)
|
||||
type_label = QLabel(tr('view.leave.field_type'))
|
||||
type_label = QLabel("구분:")
|
||||
type_label.setObjectName("field_label")
|
||||
type_label.setFixedWidth(40)
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
|
||||
self.type_combo.addItem(tr('view.leave.type_half'), "half")
|
||||
self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
|
||||
self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
|
||||
self.type_combo.addItem("연차", "annual")
|
||||
self.type_combo.addItem("반차", "half")
|
||||
self.type_combo.addItem("반반차", "quarter")
|
||||
self.type_combo.addItem("시간", "hourly")
|
||||
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
|
||||
row1.addWidget(type_label)
|
||||
row1.addWidget(self.type_combo)
|
||||
@ -281,14 +253,14 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
# 사용 시간 (시간 연차용)
|
||||
hours_layout = QHBoxLayout()
|
||||
hours_label = QLabel(tr('view.leave.field_hours'))
|
||||
hours_label = QLabel("시간:")
|
||||
hours_label.setObjectName("field_label")
|
||||
hours_label.setFixedWidth(40)
|
||||
self.hours_spin = QDoubleSpinBox()
|
||||
self.hours_spin.setRange(0.5, 8.0)
|
||||
self.hours_spin.setSingleStep(0.5)
|
||||
self.hours_spin.setValue(1.0)
|
||||
self.hours_spin.setSuffix(' ' + tr('label.unit_hour'))
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.hours_spin.setEnabled(False)
|
||||
hours_layout.addWidget(hours_label)
|
||||
hours_layout.addWidget(self.hours_spin)
|
||||
@ -296,27 +268,27 @@ class AddLeaveDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
memo_layout = QHBoxLayout()
|
||||
memo_label = QLabel(tr('view.leave.field_reason'))
|
||||
memo_label = QLabel("사유:")
|
||||
memo_label.setObjectName("field_label")
|
||||
memo_label.setFixedWidth(40)
|
||||
self.memo_input = QLineEdit()
|
||||
self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
|
||||
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
|
||||
memo_layout.addWidget(memo_label)
|
||||
memo_layout.addWidget(self.memo_input)
|
||||
layout.addLayout(memo_layout)
|
||||
|
||||
# 안내
|
||||
info_label = QLabel(tr('view.leave.note_auto_deduct'))
|
||||
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
|
||||
info_label.setObjectName("note_text")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button = QPushButton("저장")
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save_record)
|
||||
button_layout.addWidget(save_button)
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(cancel_button)
|
||||
layout.addLayout(button_layout)
|
||||
@ -354,39 +326,8 @@ class AddLeaveDialog(QDialog):
|
||||
if current_balance < days:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.short_title'),
|
||||
tr('view.leave.short_body', balance=current_balance, req=days)
|
||||
)
|
||||
return
|
||||
|
||||
# 휴일/주말 검증 — 차감 의미 없으므로 차단
|
||||
from datetime import datetime as _dt
|
||||
date_dt = _dt.strptime(date, "%Y-%m-%d")
|
||||
if date_dt.weekday() in (5, 6): # 토/일
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.weekend_register_forbidden_title'),
|
||||
tr('view.leave.weekend_register_forbidden_body')
|
||||
)
|
||||
return
|
||||
if self.db.is_holiday(date):
|
||||
holiday = self.db.get_holiday(date)
|
||||
name = (holiday or {}).get('name', tr('label.holiday_default'))
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.holiday_register_forbidden_title'),
|
||||
tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
|
||||
)
|
||||
return
|
||||
|
||||
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
|
||||
existing_min = self.db.get_leave_minutes_for(date)
|
||||
existing_days = existing_min / max(1, self.db.get_work_minutes())
|
||||
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.duplicate_register_title'),
|
||||
tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days)
|
||||
"잔여 연차 부족",
|
||||
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
|
||||
)
|
||||
return
|
||||
|
||||
@ -394,10 +335,12 @@ class AddLeaveDialog(QDialog):
|
||||
hours = days * 8
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('view.leave.confirm_title'),
|
||||
tr('view.leave.confirm_body',
|
||||
date=date, type=leave_type_name, days=days, hours=hours,
|
||||
reason=(memo if memo else '-')),
|
||||
"연차 사용 기록 추가",
|
||||
f"날짜: {date}\n"
|
||||
f"구분: {leave_type_name}\n"
|
||||
f"사용: {days}일 ({hours}시간)\n"
|
||||
f"사유: {memo if memo else '(없음)'}\n\n"
|
||||
f"이 기록을 추가하시겠습니까?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -407,15 +350,15 @@ class AddLeaveDialog(QDialog):
|
||||
self.db.use_leave(days, date, leave_type_name, memo)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('view.leave.added_title'),
|
||||
tr('view.leave.added_body', days=days, hours=hours)
|
||||
"추가 완료",
|
||||
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
|
||||
)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
tr('view.leave.error_title'),
|
||||
tr('view.leave.error_body', err=str(e))
|
||||
"오류",
|
||||
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
1254
ui/main_window.py
1254
ui/main_window.py
File diff suppressed because it is too large
Load Diff
@ -1,176 +0,0 @@
|
||||
"""
|
||||
점심/저녁 실제 시간 입력 다이얼로그.
|
||||
|
||||
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
||||
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
||||
|
||||
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
|
||||
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QMessageBox)
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class MealTimeDialog(QDialog):
|
||||
"""점심/저녁 실제 시작·종료 시간 입력.
|
||||
|
||||
Args:
|
||||
parent: 부모 위젯
|
||||
meal_type: 'lunch' 또는 'dinner'
|
||||
default_minutes: 안내 문구에 표시할 기본 차감 분
|
||||
clock_in_time: 출근 datetime (옵션). 주어지면 식사가 출근 이후인지 검증.
|
||||
clock_out_time: 퇴근 datetime (옵션, 보통 미퇴근 상태). 주어지면 퇴근 이전인지 검증.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60,
|
||||
clock_in_time: Optional[datetime] = None,
|
||||
clock_out_time: Optional[datetime] = None):
|
||||
super().__init__(parent)
|
||||
self.meal_type = meal_type
|
||||
self._clock_in = clock_in_time
|
||||
self._clock_out = clock_out_time
|
||||
meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short')
|
||||
self.setWindowTitle(tr('meal.dialog_title', meal=meal_label))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 260)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
|
||||
if clock_in_time is not None:
|
||||
info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M'))
|
||||
info = QLabel(info_text)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 합리적 기본값: 출근 이후로 보정
|
||||
default_start_h = 12 if meal_type == 'lunch' else 18
|
||||
default_end_h = 13 if meal_type == 'lunch' else 19
|
||||
if clock_in_time is not None and clock_in_time.hour >= default_start_h:
|
||||
# 출근이 이미 점심/저녁 기본 시각을 지났으면 출근 직후를 기본값으로
|
||||
default_start_h = (clock_in_time.hour + 1) % 24
|
||||
default_end_h = (default_start_h + 1) % 24
|
||||
|
||||
# 시작
|
||||
start_row = QHBoxLayout()
|
||||
start_row.addWidget(QLabel(tr('meal.label_start')))
|
||||
self.start_edit = QTimeEdit()
|
||||
self.start_edit.setDisplayFormat("HH:mm")
|
||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||
start_row.addWidget(self.start_edit)
|
||||
start_row.addStretch()
|
||||
layout.addLayout(start_row)
|
||||
|
||||
# 종료
|
||||
end_row = QHBoxLayout()
|
||||
end_row.addWidget(QLabel(tr('meal.label_end')))
|
||||
self.end_edit = QTimeEdit()
|
||||
self.end_edit.setDisplayFormat("HH:mm")
|
||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||
end_row.addWidget(self.end_edit)
|
||||
end_row.addStretch()
|
||||
layout.addLayout(end_row)
|
||||
|
||||
# 미리보기 라벨
|
||||
self.preview = QLabel("")
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
|
||||
layout.addWidget(self.preview)
|
||||
self._update_preview()
|
||||
self.start_edit.timeChanged.connect(self._update_preview)
|
||||
self.end_edit.timeChanged.connect(self._update_preview)
|
||||
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
# -------- 내부 시각 계산 --------
|
||||
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
|
||||
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
|
||||
|
||||
자정 경계 처리:
|
||||
1) 야간 출근자(clock_in 18시 이후)가 새벽 식사 시각을 입력하면
|
||||
시작이 출근 이전으로 보이는데, 이를 다음날 새벽으로 +1day shift.
|
||||
2) 종료가 시작보다 빠르면 종료에 +1day (점심 12:55→13:30 같은 정상은 영향 X).
|
||||
|
||||
주간 출근자(clock_in 09시)가 08시 입력 시 +1day는 적용하지 않아 검증에서 거절.
|
||||
"""
|
||||
s = self.start_edit.time().toPyTime()
|
||||
e = self.end_edit.time().toPyTime()
|
||||
base_date = (self._clock_in.date() if self._clock_in is not None
|
||||
else datetime.today().date())
|
||||
start_dt = datetime.combine(base_date, s)
|
||||
end_dt = datetime.combine(base_date, e)
|
||||
|
||||
# 야간 출근자 자동 보정
|
||||
if (self._clock_in is not None and start_dt < self._clock_in
|
||||
and self._clock_in.hour >= 18):
|
||||
start_dt += timedelta(days=1)
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
if end_dt < start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
||||
return start_dt, end_dt, minutes
|
||||
|
||||
def _update_preview(self):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
self.preview.setText(reason)
|
||||
self.preview.setStyleSheet("color: #FA5252;")
|
||||
else:
|
||||
self.preview.setText(tr('meal.preview_total', minutes=minutes))
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||
minutes: int) -> tuple[bool, str]:
|
||||
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||
if minutes <= 0:
|
||||
return False, tr('meal.error_start_after_end')
|
||||
if minutes > 8 * 60:
|
||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||
return False, tr('meal.error_too_long')
|
||||
if self._clock_in is not None and start_dt < self._clock_in:
|
||||
return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M'))
|
||||
if self._clock_out is not None and end_dt > self._clock_out:
|
||||
return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M'))
|
||||
return True, ""
|
||||
|
||||
def accept(self):
|
||||
"""저장 버튼: 검증 실패 시 다이얼로그 닫지 않고 메시지박스."""
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, tr('meal.input_error_title'), reason)
|
||||
return
|
||||
super().accept()
|
||||
|
||||
def get_times(self) -> tuple[str, str, int]:
|
||||
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
||||
s = self.start_edit.time().toPyTime()
|
||||
e = self.end_edit.time().toPyTime()
|
||||
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
||||
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
||||
_, _, minutes = self._resolve_meal_window()
|
||||
return start_str, end_str, minutes
|
||||
@ -41,7 +41,7 @@ class MiniWidget(QWidget):
|
||||
|
||||
self.title_label = QLabel(tr('label.remaining'))
|
||||
self.title_label.setAlignment(Qt.AlignCenter)
|
||||
self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
|
||||
self.time_label = QLabel("--:--:--")
|
||||
self.time_label.setAlignment(Qt.AlignCenter)
|
||||
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
|
||||
layout.addWidget(self.time_label)
|
||||
self.setLayout(layout)
|
||||
|
||||
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
|
||||
# 기본 스타일 (테마 무관 가독성 유지)
|
||||
self.setStyleSheet("""
|
||||
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
|
||||
QLabel { color: #E9ECEF; background: transparent; }
|
||||
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
|
||||
QLabel { color: #fff; }
|
||||
""")
|
||||
|
||||
apply_dark_titlebar(self)
|
||||
@ -63,12 +63,11 @@ class MiniWidget(QWidget):
|
||||
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
||||
self.time_label.setText(remaining_str)
|
||||
if remaining_str.startswith('+'):
|
||||
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
|
||||
self.title_label.setText(tr('label.overtime_progress'))
|
||||
self.time_label.setStyleSheet("color: #51CF66;")
|
||||
self.time_label.setStyleSheet("color: #ff6b6b;")
|
||||
else:
|
||||
self.title_label.setText(tr('label.remaining'))
|
||||
self.time_label.setStyleSheet("color: #E9ECEF;")
|
||||
self.time_label.setStyleSheet("color: #fff;")
|
||||
|
||||
# 드래그 이동
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
@ -91,19 +90,8 @@ class MiniWidget(QWidget):
|
||||
def contextMenuEvent(self, event):
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
menu = QMenu(self)
|
||||
# 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
|
||||
# 앱 다크 테마 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'))
|
||||
open_main = menu.addAction("메인 창 열기")
|
||||
close_mini = menu.addAction("미니 위젯 닫기")
|
||||
action = menu.exec_(event.globalPos())
|
||||
if action == open_main and self.parent_window:
|
||||
self.parent_window.show()
|
||||
|
||||
@ -12,30 +12,33 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
|
||||
QMessageBox, QGroupBox)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
|
||||
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
|
||||
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
|
||||
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
|
||||
# (label, work_minutes, lunch_minutes)
|
||||
WORK_PRESETS = [
|
||||
('onboarding.preset.standard_8h', 480, 60, 0),
|
||||
('onboarding.preset.short_7h30m', 450, 30, 0),
|
||||
('onboarding.preset.short_7h', 420, 60, 0),
|
||||
('onboarding.preset.short_6h', 360, 30, 0),
|
||||
('onboarding.preset.half_4h', 240, 0, 0),
|
||||
("표준 8시간 (점심 60분)", 480, 60),
|
||||
("단축근무 7시간 30분 (점심 30분)", 450, 30),
|
||||
("단축근무 7시간 (점심 60분)", 420, 60),
|
||||
("단축근무 6시간 (점심 30분)", 360, 30),
|
||||
("반일 4시간 (점심 0분)", 240, 0),
|
||||
]
|
||||
|
||||
|
||||
class WelcomePage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle(tr('onboarding.welcome_title'))
|
||||
self.setSubTitle(tr('onboarding.welcome_subtitle'))
|
||||
self.setTitle("👋 환영합니다!")
|
||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||
layout = QVBoxLayout()
|
||||
intro = QLabel(tr('onboarding.welcome_intro'))
|
||||
intro = QLabel(
|
||||
"이 앱은:\n"
|
||||
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
|
||||
"• 30분 단위 연장근무 적립\n"
|
||||
"• 연차·반차·외출 시간 추적\n"
|
||||
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
|
||||
"[다음] 버튼을 눌러 시작하세요."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
self.setLayout(layout)
|
||||
@ -44,96 +47,74 @@ class WelcomePage(QWizardPage):
|
||||
class WorkPatternPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle(tr('onboarding.work_pattern_title'))
|
||||
self.setSubTitle(tr('onboarding.work_pattern_subtitle'))
|
||||
self.setTitle("🕘 근무 패턴")
|
||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.button_group = QButtonGroup(self)
|
||||
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(tr(key))
|
||||
for i, (label, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(label)
|
||||
self.button_group.addButton(rb, i)
|
||||
layout.addWidget(rb)
|
||||
if i == 0:
|
||||
rb.setChecked(True)
|
||||
|
||||
# 사용자 정의
|
||||
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
|
||||
custom_layout = QVBoxLayout()
|
||||
custom_layout.setSpacing(4)
|
||||
|
||||
suffix_h = tr('onboarding.preset.suffix_hours')
|
||||
suffix_m = tr('onboarding.preset.suffix_minutes')
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio'))
|
||||
custom_box = QGroupBox("사용자 정의")
|
||||
custom_layout = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton("직접 입력:")
|
||||
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
|
||||
self.hours_spin = QSpinBox()
|
||||
self.hours_spin.setRange(0, 12)
|
||||
self.hours_spin.setValue(8)
|
||||
self.hours_spin.setSuffix(suffix_h)
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.minutes_spin = QSpinBox()
|
||||
self.minutes_spin.setRange(0, 59)
|
||||
self.minutes_spin.setSingleStep(15)
|
||||
self.minutes_spin.setSuffix(suffix_m)
|
||||
row1.addWidget(self.custom_radio)
|
||||
row1.addWidget(self.hours_spin)
|
||||
row1.addWidget(self.minutes_spin)
|
||||
row1.addStretch()
|
||||
custom_layout.addLayout(row1)
|
||||
|
||||
row2 = QHBoxLayout()
|
||||
self.minutes_spin.setSuffix(" 분")
|
||||
self.lunch_spin = QSpinBox()
|
||||
self.lunch_spin.setRange(0, 120)
|
||||
self.lunch_spin.setSingleStep(5)
|
||||
self.lunch_spin.setValue(60)
|
||||
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
|
||||
self.lunch_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin = QSpinBox()
|
||||
self.dinner_spin.setRange(0, 120)
|
||||
self.dinner_spin.setSingleStep(5)
|
||||
self.dinner_spin.setValue(0)
|
||||
self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix'))
|
||||
self.dinner_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip'))
|
||||
row2.addSpacing(20)
|
||||
row2.addWidget(self.lunch_spin)
|
||||
row2.addWidget(self.dinner_spin)
|
||||
row2.addStretch()
|
||||
custom_layout.addLayout(row2)
|
||||
|
||||
self.lunch_spin.setPrefix("점심 ")
|
||||
self.lunch_spin.setSuffix(" 분")
|
||||
custom_layout.addWidget(self.custom_radio)
|
||||
custom_layout.addWidget(self.hours_spin)
|
||||
custom_layout.addWidget(self.minutes_spin)
|
||||
custom_layout.addWidget(self.lunch_spin)
|
||||
custom_layout.addStretch()
|
||||
custom_box.setLayout(custom_layout)
|
||||
layout.addWidget(custom_box)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_minutes(self):
|
||||
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
|
||||
idx = self.button_group.checkedId()
|
||||
if 0 <= idx < len(WORK_PRESETS):
|
||||
_, wm, lm, dm = WORK_PRESETS[idx]
|
||||
return wm, lm, dm
|
||||
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
|
||||
self.lunch_spin.value(),
|
||||
self.dinner_spin.value())
|
||||
_, wm, lm = WORK_PRESETS[idx]
|
||||
return wm, lm
|
||||
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
|
||||
|
||||
|
||||
class ClockInDetectionPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle(tr('onboarding.detection_title'))
|
||||
self.setSubTitle(tr('onboarding.detection_subtitle'))
|
||||
self.setTitle("⏰ 출근 시간 감지 방식")
|
||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.option_boot = QRadioButton(tr('onboarding.detection_boot'))
|
||||
self.option_unlock = QRadioButton(tr('onboarding.detection_unlock'))
|
||||
self.option_manual = QRadioButton(tr('onboarding.detection_manual'))
|
||||
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
|
||||
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
|
||||
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
|
||||
self.option_boot.setChecked(True)
|
||||
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
||||
layout.addWidget(opt)
|
||||
|
||||
info = QLabel(tr('onboarding.detection_info'))
|
||||
info = QLabel(
|
||||
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||
info.setStyleSheet("color: #888; padding: 8px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
layout.addStretch()
|
||||
@ -150,35 +131,35 @@ class ClockInDetectionPage(QWizardPage):
|
||||
class LeaveSalaryPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle(tr('onboarding.leave_salary_title'))
|
||||
self.setSubTitle(tr('onboarding.leave_salary_subtitle'))
|
||||
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
|
||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
# 연차
|
||||
leave_box = QGroupBox(tr('onboarding.leave_group'))
|
||||
leave_box = QGroupBox("연간 연차")
|
||||
leave_layout = QHBoxLayout()
|
||||
self.leave_spin = QSpinBox()
|
||||
self.leave_spin.setRange(0, 30)
|
||||
self.leave_spin.setValue(15)
|
||||
self.leave_spin.setSuffix(tr('label.unit_day'))
|
||||
leave_layout.addWidget(QLabel(tr('onboarding.my_leave')))
|
||||
self.leave_spin.setSuffix(" 일")
|
||||
leave_layout.addWidget(QLabel("내 연차:"))
|
||||
leave_layout.addWidget(self.leave_spin)
|
||||
leave_layout.addStretch()
|
||||
leave_box.setLayout(leave_layout)
|
||||
layout.addWidget(leave_box)
|
||||
|
||||
# 급여 (옵션)
|
||||
salary_box = QGroupBox(tr('onboarding.salary_group'))
|
||||
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
|
||||
salary_layout = QVBoxLayout()
|
||||
self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled'))
|
||||
self.salary_enabled = QCheckBox("급여 추정 활성화")
|
||||
salary_layout.addWidget(self.salary_enabled)
|
||||
|
||||
wage_row = QHBoxLayout()
|
||||
wage_row.addWidget(QLabel(tr('onboarding.hourly_wage')))
|
||||
wage_row.addWidget(QLabel("시급:"))
|
||||
self.wage_spin = QSpinBox()
|
||||
self.wage_spin.setRange(0, 1000000)
|
||||
self.wage_spin.setSingleStep(1000)
|
||||
self.wage_spin.setSuffix(tr('onboarding.wage_suffix'))
|
||||
self.wage_spin.setSuffix(" 원/시간")
|
||||
self.wage_spin.setValue(0)
|
||||
self.wage_spin.setEnabled(False)
|
||||
wage_row.addWidget(self.wage_spin)
|
||||
@ -186,11 +167,11 @@ class LeaveSalaryPage(QWizardPage):
|
||||
salary_layout.addLayout(wage_row)
|
||||
|
||||
rate_row = QHBoxLayout()
|
||||
rate_row.addWidget(QLabel(tr('onboarding.overtime_rate')))
|
||||
rate_row.addWidget(QLabel("연장수당 가산률:"))
|
||||
self.rate_combo = QComboBox()
|
||||
self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0)
|
||||
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
|
||||
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
|
||||
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
|
||||
self.rate_combo.setCurrentIndex(1)
|
||||
self.rate_combo.setEnabled(False)
|
||||
rate_row.addWidget(self.rate_combo)
|
||||
@ -209,25 +190,30 @@ class LeaveSalaryPage(QWizardPage):
|
||||
class DiscordPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle(tr('onboarding.discord_title'))
|
||||
self.setSubTitle(tr('onboarding.discord_subtitle'))
|
||||
self.setTitle("💬 Discord 알림 (선택)")
|
||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.enable_check = QCheckBox(tr('onboarding.discord_enable'))
|
||||
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
|
||||
layout.addWidget(self.enable_check)
|
||||
|
||||
self.url_edit = QLineEdit()
|
||||
self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder'))
|
||||
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
|
||||
self.url_edit.setEnabled(False)
|
||||
layout.addWidget(self.url_edit)
|
||||
|
||||
guide = QLabel(tr('onboarding.discord_guide'))
|
||||
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||
guide = QLabel(
|
||||
"셋업 방법:\n"
|
||||
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
|
||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||
"3. 위 입력란에 붙여넣기"
|
||||
)
|
||||
guide.setStyleSheet("color: #888; padding: 6px;")
|
||||
guide.setWordWrap(True)
|
||||
layout.addWidget(guide)
|
||||
|
||||
test_row = QHBoxLayout()
|
||||
self.test_btn = QPushButton(tr('onboarding.discord_test'))
|
||||
self.test_btn = QPushButton("테스트 메시지 보내기")
|
||||
self.test_btn.setEnabled(False)
|
||||
self.test_btn.clicked.connect(self._test_webhook)
|
||||
test_row.addWidget(self.test_btn)
|
||||
@ -243,30 +229,32 @@ class DiscordPage(QWizardPage):
|
||||
def _test_webhook(self):
|
||||
url = self.url_edit.text().strip()
|
||||
if not url:
|
||||
QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body'))
|
||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||
return
|
||||
from utils import discord_webhook
|
||||
if not discord_webhook.is_valid_webhook_url(url):
|
||||
QMessageBox.warning(
|
||||
self, tr('onboarding.discord_url_invalid_title'),
|
||||
tr('onboarding.discord_url_invalid_body')
|
||||
)
|
||||
return
|
||||
ok = discord_webhook.send_test(url)
|
||||
if ok:
|
||||
QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body'))
|
||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||
else:
|
||||
QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body'))
|
||||
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
|
||||
|
||||
|
||||
class FinishPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle(tr('onboarding.finish_title'))
|
||||
self.setSubTitle(tr('onboarding.finish_subtitle'))
|
||||
self.setTitle("🎉 준비 완료!")
|
||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
msg = QLabel(tr('onboarding.finish_msg'))
|
||||
msg = QLabel(
|
||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||
"🕐 단축키:\n"
|
||||
" • Ctrl+O — 출퇴근 토글\n"
|
||||
" • F1 — 도움말\n"
|
||||
" • F5 — 업데이트 확인\n"
|
||||
" • Ctrl+, — 설정"
|
||||
)
|
||||
msg.setWordWrap(True)
|
||||
layout.addWidget(msg)
|
||||
self.setLayout(layout)
|
||||
@ -278,7 +266,7 @@ class OnboardingWizard(QWizard):
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('onboarding.window_title'))
|
||||
self.setWindowTitle("Clock-out Calculator — 시작 설정")
|
||||
self.setMinimumSize(600, 500)
|
||||
self.setWizardStyle(QWizard.ModernStyle)
|
||||
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
||||
@ -298,15 +286,14 @@ class OnboardingWizard(QWizard):
|
||||
|
||||
def accept(self):
|
||||
# 1. 근무 패턴
|
||||
wm, lm, dm = self.work_page.selected_minutes()
|
||||
wm, lm = self.work_page.selected_minutes()
|
||||
if wm < 30:
|
||||
QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small'))
|
||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||
return
|
||||
|
||||
settings = {
|
||||
'work_minutes': wm,
|
||||
'lunch_duration_minutes': lm,
|
||||
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
|
||||
'annual_leave_days': self.leave_page.leave_spin.value(),
|
||||
'annual_leave_total': self.leave_page.leave_spin.value(),
|
||||
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
|
||||
@ -314,8 +301,6 @@ class OnboardingWizard(QWizard):
|
||||
'overtime_rate': self.leave_page.rate_combo.currentData(),
|
||||
'onboarding_completed': True,
|
||||
}
|
||||
if dm > 0:
|
||||
settings['dinner_duration_minutes'] = dm
|
||||
|
||||
# 2. 출근 감지 방식
|
||||
mode = self.detect_page.detection_mode()
|
||||
|
||||
@ -38,39 +38,33 @@ class OvertimeView(QDialog):
|
||||
|
||||
# 제목 + 잔액 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel(tr('view.overtime.title'))
|
||||
title = QLabel("연장근무 내역")
|
||||
title.setObjectName("dialog_title")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
self.balance_label = QLabel(tr('view.overtime.balance_zero'))
|
||||
self.balance_label = QLabel("잔액: 0분")
|
||||
self.balance_label.setObjectName("badge_balance")
|
||||
header_layout.addWidget(self.balance_label)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 적립 내역
|
||||
earned_group = QGroupBox(tr('view.overtime.earned_group'))
|
||||
earned_group = QGroupBox("💰 적립 내역")
|
||||
earned_layout = QVBoxLayout()
|
||||
earned_layout.setSpacing(4)
|
||||
earned_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.earned_table = QTableWidget()
|
||||
self.earned_table.setColumnCount(3)
|
||||
self.earned_table.setHorizontalHeaderLabels([
|
||||
tr('view.overtime.col_date'),
|
||||
tr('view.overtime.col_earned'),
|
||||
tr('view.overtime.col_memo'),
|
||||
])
|
||||
self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"])
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
self.earned_table.setAlternatingRowColors(True)
|
||||
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
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)
|
||||
|
||||
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
||||
add_earned_button = QPushButton("➕ 수동 적립")
|
||||
add_earned_button.clicked.connect(self.add_earned_record)
|
||||
earned_layout.addWidget(add_earned_button)
|
||||
|
||||
@ -78,18 +72,14 @@ class OvertimeView(QDialog):
|
||||
layout.addWidget(earned_group)
|
||||
|
||||
# 사용 내역
|
||||
used_group = QGroupBox(tr('view.overtime.used_group'))
|
||||
used_group = QGroupBox("📤 사용 내역")
|
||||
used_layout = QVBoxLayout()
|
||||
used_layout.setSpacing(4)
|
||||
used_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.used_table = QTableWidget()
|
||||
self.used_table.setColumnCount(3)
|
||||
self.used_table.setHorizontalHeaderLabels([
|
||||
tr('view.overtime.col_date'),
|
||||
tr('view.overtime.col_used'),
|
||||
tr('view.overtime.col_reason'),
|
||||
])
|
||||
self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"])
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
@ -100,7 +90,7 @@ class OvertimeView(QDialog):
|
||||
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
|
||||
used_layout.addWidget(self.used_table)
|
||||
|
||||
add_used_button = QPushButton(tr('view.overtime.btn_add_used'))
|
||||
add_used_button = QPushButton("➕ 수동 사용")
|
||||
add_used_button.clicked.connect(self.add_used_record)
|
||||
used_layout.addWidget(add_used_button)
|
||||
|
||||
@ -108,7 +98,7 @@ class OvertimeView(QDialog):
|
||||
layout.addWidget(used_group)
|
||||
|
||||
# 닫기 버튼
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button = QPushButton("닫기")
|
||||
close_button.clicked.connect(self.close)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
@ -120,42 +110,38 @@ class OvertimeView(QDialog):
|
||||
balance = self.db.get_total_overtime_balance()
|
||||
hours = balance // 60
|
||||
minutes = balance % 60
|
||||
self.balance_label.setText(tr('view.overtime.balance_fmt',
|
||||
h=hours, m=minutes, total=balance))
|
||||
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
|
||||
|
||||
# 적립 내역 로드
|
||||
conn = self.db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
|
||||
SELECT ob.date, ob.earned_minutes,
|
||||
CASE
|
||||
WHEN ob.work_record_id IS NULL THEN '수동 추가'
|
||||
ELSE COALESCE(wr.memo, '')
|
||||
END as memo
|
||||
FROM overtime_bank ob
|
||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||
ORDER BY ob.date DESC
|
||||
''')
|
||||
earned_records = cursor.fetchall()
|
||||
manual_label = tr('msg.manual_added')
|
||||
|
||||
self.earned_table.setRowCount(len(earned_records))
|
||||
for i, record in enumerate(earned_records):
|
||||
date_item = QTableWidgetItem(record[0])
|
||||
date_item.setTextAlignment(Qt.AlignCenter)
|
||||
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
|
||||
|
||||
minutes = record[1]
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
if hours > 0:
|
||||
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
|
||||
else:
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
|
||||
time_item.setForeground(QColor(39, 174, 96)) # 초록색
|
||||
|
||||
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
||||
memo_item = QTableWidgetItem(memo_text)
|
||||
memo_item = QTableWidgetItem(record[2] or "")
|
||||
|
||||
self.earned_table.setItem(i, 0, date_item)
|
||||
self.earned_table.setItem(i, 1, time_item)
|
||||
@ -180,13 +166,10 @@ class OvertimeView(QDialog):
|
||||
minutes = record[2]
|
||||
hours = minutes // 60
|
||||
mins = minutes % 60
|
||||
if hours > 0:
|
||||
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
|
||||
else:
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
|
||||
reason_item = QTableWidgetItem(record[3] or "")
|
||||
|
||||
@ -205,7 +188,7 @@ class OvertimeView(QDialog):
|
||||
|
||||
# 컨텍스트 메뉴 생성
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction(tr('view.overtime.menu_delete'), self)
|
||||
delete_action = QAction("❌ 삭제", self)
|
||||
delete_action.triggered.connect(self.delete_used_record)
|
||||
menu.addAction(delete_action)
|
||||
|
||||
@ -230,10 +213,11 @@ class OvertimeView(QDialog):
|
||||
# 확인 메시지
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.overtime.delete_confirm_body',
|
||||
date=date_item.text(), time=time_item.text(),
|
||||
reason=reason_item.text()),
|
||||
"삭제 확인",
|
||||
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
|
||||
f"날짜: {date_item.text()}\n"
|
||||
f"시간: {time_item.text()}\n"
|
||||
f"사유: {reason_item.text()}",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -252,46 +236,6 @@ class OvertimeView(QDialog):
|
||||
if self.parent() and hasattr(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):
|
||||
"""수동 적립 추가"""
|
||||
dialog = AddOvertimeEarnedDialog(self, self.db)
|
||||
@ -322,7 +266,7 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('view.overtime.manual_earned_title'))
|
||||
self.setWindowTitle("추가근무 수동 적립")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -331,14 +275,14 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(tr('view.overtime.manual_earned_title'))
|
||||
title = QLabel("추가근무 수동 적립")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 날짜
|
||||
date_layout = QHBoxLayout()
|
||||
date_label = QLabel(tr('view.overtime.field_date'))
|
||||
date_label = QLabel("날짜:")
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(60)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -350,15 +294,14 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
# 시간 (30분 단위)
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel(tr('view.overtime.field_time'))
|
||||
time_label = QLabel("시간:")
|
||||
time_label.setObjectName("field_label")
|
||||
time_label.setFixedWidth(60)
|
||||
self.hour_spin = QSpinBox()
|
||||
self.hour_spin.setRange(0, 23)
|
||||
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
|
||||
self.hour_spin.setSuffix("시간")
|
||||
self.minute_combo = QComboBox()
|
||||
self.minute_combo.addItems([tr('view.overtime.minute_0'),
|
||||
tr('view.overtime.minute_30')])
|
||||
self.minute_combo.addItems(["0분", "30분"])
|
||||
time_layout.addWidget(time_label)
|
||||
time_layout.addWidget(self.hour_spin)
|
||||
time_layout.addWidget(self.minute_combo)
|
||||
@ -366,21 +309,21 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
# 메모
|
||||
memo_layout = QHBoxLayout()
|
||||
memo_label = QLabel(tr('view.overtime.field_memo'))
|
||||
memo_label = QLabel("메모:")
|
||||
memo_label.setObjectName("field_label")
|
||||
memo_label.setFixedWidth(60)
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo'))
|
||||
self.memo_edit.setPlaceholderText("선택사항")
|
||||
memo_layout.addWidget(memo_label)
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
layout.addLayout(memo_layout)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button = QPushButton("저장")
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save)
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(save_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
@ -392,12 +335,11 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
"""저장"""
|
||||
# 시간 계산 (30분 단위)
|
||||
hours = self.hour_spin.value()
|
||||
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
|
||||
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
|
||||
total_minutes = hours * 60 + minutes
|
||||
|
||||
if total_minutes == 0:
|
||||
QMessageBox.warning(self, tr('msg.input_error.title'),
|
||||
tr('view.overtime.zero_add_error'))
|
||||
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
|
||||
return
|
||||
|
||||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||||
@ -429,8 +371,8 @@ class AddOvertimeEarnedDialog(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('msg.save_success.title'),
|
||||
tr('view.overtime.saved_earned', h=hours, m=minutes)
|
||||
"저장 완료",
|
||||
f"{hours}시간 {minutes}분이 적립되었습니다."
|
||||
)
|
||||
self.accept()
|
||||
|
||||
@ -446,7 +388,7 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('view.overtime.manual_used_title'))
|
||||
self.setWindowTitle("추가근무 수동 사용")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(360)
|
||||
|
||||
@ -456,25 +398,21 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 제목 + 잔액 한 줄
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel(tr('view.overtime.manual_used_title'))
|
||||
title = QLabel("추가근무 수동 사용")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
header_layout.addWidget(title)
|
||||
header_layout.addStretch()
|
||||
balance = self.db.get_total_overtime_balance()
|
||||
hours = balance // 60
|
||||
minutes = balance % 60
|
||||
if hours > 0:
|
||||
balance_text = tr('view.break.duration_fmt', h=hours, m=minutes)
|
||||
else:
|
||||
balance_text = tr('view.break.duration_min_only', m=minutes)
|
||||
balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}")
|
||||
balance_label = QLabel(f"잔액: {hours}시간 {minutes}분")
|
||||
balance_label.setObjectName("badge_balance")
|
||||
header_layout.addWidget(balance_label)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 날짜
|
||||
date_layout = QHBoxLayout()
|
||||
date_label = QLabel(tr('view.overtime.field_date'))
|
||||
date_label = QLabel("날짜:")
|
||||
date_label.setObjectName("field_label")
|
||||
date_label.setFixedWidth(60)
|
||||
self.date_edit = QDateEdit()
|
||||
@ -486,15 +424,14 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 시간 (30분 단위)
|
||||
time_layout = QHBoxLayout()
|
||||
time_label = QLabel(tr('view.overtime.field_time'))
|
||||
time_label = QLabel("시간:")
|
||||
time_label.setObjectName("field_label")
|
||||
time_label.setFixedWidth(60)
|
||||
self.hour_spin = QSpinBox()
|
||||
self.hour_spin.setRange(0, 23)
|
||||
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
|
||||
self.hour_spin.setSuffix("시간")
|
||||
self.minute_combo = QComboBox()
|
||||
self.minute_combo.addItems([tr('view.overtime.minute_0'),
|
||||
tr('view.overtime.minute_30')])
|
||||
self.minute_combo.addItems(["0분", "30분"])
|
||||
time_layout.addWidget(time_label)
|
||||
time_layout.addWidget(self.hour_spin)
|
||||
time_layout.addWidget(self.minute_combo)
|
||||
@ -502,21 +439,21 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
|
||||
# 사유
|
||||
reason_layout = QHBoxLayout()
|
||||
reason_label = QLabel(tr('view.overtime.field_reason'))
|
||||
reason_label = QLabel("사유:")
|
||||
reason_label.setObjectName("field_label")
|
||||
reason_label.setFixedWidth(60)
|
||||
self.reason_edit = QLineEdit()
|
||||
self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason'))
|
||||
self.reason_edit.setPlaceholderText("예: 개인 사유")
|
||||
reason_layout.addWidget(reason_label)
|
||||
reason_layout.addWidget(self.reason_edit)
|
||||
layout.addLayout(reason_layout)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button = QPushButton("저장")
|
||||
save_button.setObjectName("btn_primary")
|
||||
save_button.clicked.connect(self.save)
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(save_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
@ -528,12 +465,11 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
"""저장"""
|
||||
# 시간 계산 (30분 단위)
|
||||
hours = self.hour_spin.value()
|
||||
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
|
||||
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
|
||||
total_minutes = hours * 60 + minutes
|
||||
|
||||
if total_minutes == 0:
|
||||
QMessageBox.warning(self, tr('msg.input_error.title'),
|
||||
tr('view.overtime.zero_use_error'))
|
||||
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
|
||||
return
|
||||
|
||||
# 잔액 확인
|
||||
@ -541,23 +477,23 @@ class AddOvertimeUsedDialog(QDialog):
|
||||
if total_minutes > balance:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.overtime.balance_short_title'),
|
||||
tr('view.overtime.balance_short_body',
|
||||
req_h=hours, req_m=minutes,
|
||||
bal_h=balance // 60, bal_m=balance % 60)
|
||||
"잔액 부족",
|
||||
f"사용 가능한 시간이 부족합니다.\n\n"
|
||||
f"요청: {hours}시간 {minutes}분\n"
|
||||
f"잔액: {balance // 60}시간 {balance % 60}분"
|
||||
)
|
||||
return
|
||||
|
||||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||||
reason = self.reason_edit.text().strip() or tr('msg.manual_added')
|
||||
reason = self.reason_edit.text().strip() or "수동 사용"
|
||||
|
||||
# DB에 저장
|
||||
self.db.add_overtime_usage(None, total_minutes, date, reason)
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('msg.save_success.title'),
|
||||
tr('view.overtime.saved_used', h=hours, m=minutes)
|
||||
"저장 완료",
|
||||
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
|
||||
)
|
||||
self.accept()
|
||||
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
"""
|
||||
과거 일자 수동 추가 다이얼로그.
|
||||
|
||||
캘린더 우클릭 → "기록 추가"에서 호출. 출/퇴근 시각 + 점심/저녁 + 메모 입력.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QCheckBox, QLineEdit,
|
||||
QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class PastRecordDialog(QDialog):
|
||||
"""과거 일자 근무 기록 입력."""
|
||||
|
||||
def __init__(self, parent=None, date_str: str = ''):
|
||||
super().__init__(parent)
|
||||
self.date_str = date_str
|
||||
self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 320)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(tr('past_record.info', date=date_str))
|
||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 출근
|
||||
ci_row = QHBoxLayout()
|
||||
ci_row.addWidget(QLabel(tr('past_record.label_clock_in')))
|
||||
self.clock_in_edit = QTimeEdit()
|
||||
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||
self.clock_in_edit.setTime(QTime(9, 0))
|
||||
ci_row.addWidget(self.clock_in_edit)
|
||||
ci_row.addStretch()
|
||||
layout.addLayout(ci_row)
|
||||
|
||||
# 퇴근
|
||||
co_row = QHBoxLayout()
|
||||
co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
|
||||
self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
|
||||
self.clock_out_check.setChecked(True)
|
||||
self.clock_out_edit = QTimeEdit()
|
||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||
self.clock_out_edit.setTime(QTime(18, 0))
|
||||
self.clock_out_check.toggled.connect(self.clock_out_edit.setEnabled)
|
||||
co_row.addWidget(self.clock_out_check)
|
||||
co_row.addWidget(self.clock_out_edit)
|
||||
co_row.addStretch()
|
||||
layout.addLayout(co_row)
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
||||
self.lunch_check.setChecked(True)
|
||||
self.dinner_check = QCheckBox(tr('past_record.check_dinner'))
|
||||
meal_row.addWidget(self.lunch_check)
|
||||
meal_row.addWidget(self.dinner_check)
|
||||
meal_row.addStretch()
|
||||
layout.addLayout(meal_row)
|
||||
|
||||
# 메모
|
||||
layout.addWidget(QLabel(tr('past_record.label_memo')))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
|
||||
layout.addWidget(self.memo_edit)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self._validate_and_accept)
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
if self.clock_out_check.isChecked():
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co <= ci:
|
||||
QMessageBox.warning(self, tr('past_record.input_error_title'),
|
||||
tr('past_record.input_error_body'))
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def get_data(self) -> dict:
|
||||
ci = self.clock_in_edit.time().toPyTime()
|
||||
data = {
|
||||
'clock_in': f"{ci.hour:02d}:{ci.minute:02d}:00",
|
||||
'lunch': self.lunch_check.isChecked(),
|
||||
'dinner': self.dinner_check.isChecked(),
|
||||
'memo': self.memo_edit.text().strip(),
|
||||
}
|
||||
if self.clock_out_check.isChecked():
|
||||
co = self.clock_out_edit.time().toPyTime()
|
||||
data['clock_out'] = f"{co.hour:02d}:{co.minute:02d}:00"
|
||||
return data
|
||||
@ -1,200 +0,0 @@
|
||||
"""
|
||||
반복 연차 등록/관리 다이얼로그.
|
||||
|
||||
지원: 매주/격주 요일, 매월 N일.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import date
|
||||
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QComboBox, QDateEdit, QSpinBox,
|
||||
QDoubleSpinBox, QLineEdit, QGroupBox,
|
||||
QListWidget, QListWidgetItem, QMessageBox,
|
||||
QCheckBox, QButtonGroup, QRadioButton)
|
||||
from PyQt5.QtCore import QDate, Qt
|
||||
|
||||
from core.recurring_leaves import describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
_KO_WEEKDAYS = [('월', 'mon'), ('화', 'tue'), ('수', 'wed'),
|
||||
('목', 'thu'), ('금', 'fri'), ('토', 'sat'), ('일', 'sun')]
|
||||
|
||||
|
||||
class RecurringLeaveDialog(QDialog):
|
||||
"""반복 연차 패턴 추가/삭제."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('recurring.title'))
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._reload_list()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 기존 패턴 목록
|
||||
list_group = QGroupBox(tr('recurring.list_group'))
|
||||
lg = QVBoxLayout()
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setMinimumHeight(160)
|
||||
lg.addWidget(self.list_widget)
|
||||
del_btn = QPushButton(tr('recurring.btn_delete_selected'))
|
||||
del_btn.clicked.connect(self._delete_selected)
|
||||
lg.addWidget(del_btn)
|
||||
list_group.setLayout(lg)
|
||||
layout.addWidget(list_group)
|
||||
|
||||
# 신규 등록
|
||||
add_group = QGroupBox(tr('recurring.add_group'))
|
||||
ag = QVBoxLayout()
|
||||
|
||||
# 패턴 종류
|
||||
kind_row = QHBoxLayout()
|
||||
kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
|
||||
self.kind_group = QButtonGroup(self)
|
||||
self.rb_weekly = QRadioButton(tr('recurring.weekly'))
|
||||
self.rb_weekly.setChecked(True)
|
||||
self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
|
||||
self.rb_monthly = QRadioButton(tr('recurring.monthly'))
|
||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||
self.kind_group.addButton(rb)
|
||||
kind_row.addWidget(rb)
|
||||
kind_row.addStretch()
|
||||
ag.addLayout(kind_row)
|
||||
|
||||
# 요일 체크박스 (weekly/biweekly)
|
||||
wd_row = QHBoxLayout()
|
||||
wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
|
||||
self.weekday_checks = []
|
||||
for ko, en in _KO_WEEKDAYS:
|
||||
cb = QCheckBox(tr(f'label.weekday_{en}'))
|
||||
self.weekday_checks.append((cb, en))
|
||||
wd_row.addWidget(cb)
|
||||
wd_row.addStretch()
|
||||
ag.addLayout(wd_row)
|
||||
|
||||
# 매월 N일
|
||||
month_row = QHBoxLayout()
|
||||
month_row.addWidget(QLabel(tr('recurring.label_monthly_day')))
|
||||
self.day_of_month = QSpinBox()
|
||||
self.day_of_month.setRange(1, 31)
|
||||
self.day_of_month.setValue(15)
|
||||
self.day_of_month.setSuffix(tr('recurring.day_suffix'))
|
||||
month_row.addWidget(self.day_of_month)
|
||||
month_row.addStretch()
|
||||
ag.addLayout(month_row)
|
||||
|
||||
# 차감 일수
|
||||
days_row = QHBoxLayout()
|
||||
days_row.addWidget(QLabel(tr('recurring.label_deduction')))
|
||||
self.days_combo = QComboBox()
|
||||
self.days_combo.addItem(tr('recurring.deduction_full'), 1.0)
|
||||
self.days_combo.addItem(tr('recurring.deduction_half'), 0.5)
|
||||
self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25)
|
||||
days_row.addWidget(self.days_combo)
|
||||
days_row.addStretch()
|
||||
ag.addLayout(days_row)
|
||||
|
||||
# 시작/종료 날짜
|
||||
date_row = QHBoxLayout()
|
||||
date_row.addWidget(QLabel(tr('recurring.label_start')))
|
||||
self.start_edit = QDateEdit()
|
||||
self.start_edit.setDate(QDate.currentDate())
|
||||
self.start_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.start_edit)
|
||||
|
||||
date_row.addWidget(QLabel(tr('recurring.label_end')))
|
||||
self.end_edit = QDateEdit()
|
||||
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||
self.end_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.end_edit)
|
||||
self.no_end_check = QCheckBox(tr('recurring.no_end'))
|
||||
self.no_end_check.toggled.connect(
|
||||
lambda v: self.end_edit.setEnabled(not v)
|
||||
)
|
||||
date_row.addWidget(self.no_end_check)
|
||||
date_row.addStretch()
|
||||
ag.addLayout(date_row)
|
||||
|
||||
# 메모
|
||||
memo_row = QHBoxLayout()
|
||||
memo_row.addWidget(QLabel(tr('recurring.label_memo')))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
|
||||
memo_row.addWidget(self.memo_edit)
|
||||
ag.addLayout(memo_row)
|
||||
|
||||
# 추가 버튼
|
||||
add_btn = QPushButton(tr('recurring.btn_add'))
|
||||
add_btn.setObjectName("btn_primary")
|
||||
add_btn.clicked.connect(self._save)
|
||||
ag.addWidget(add_btn)
|
||||
|
||||
add_group.setLayout(ag)
|
||||
layout.addWidget(add_group)
|
||||
|
||||
# 닫기
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _reload_list(self):
|
||||
self.list_widget.clear()
|
||||
for r in self.db.get_recurring_leaves():
|
||||
desc = describe_pattern(r['pattern'])
|
||||
end = r.get('end_date') or tr('recurring.no_end')
|
||||
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||
f"· {r['start_date']} ~ {end}")
|
||||
if r.get('memo'):
|
||||
text += f" — {r['memo']}"
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, r['id'])
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _delete_selected(self):
|
||||
item = self.list_widget.currentItem()
|
||||
if not item:
|
||||
return
|
||||
rec_id = item.data(Qt.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, tr('recurring.delete_confirm_title'),
|
||||
tr('recurring.delete_confirm_body', item=item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_recurring_leave(rec_id)
|
||||
self._reload_list()
|
||||
|
||||
def _build_pattern(self) -> str | None:
|
||||
if self.rb_monthly.isChecked():
|
||||
return f"monthly:{self.day_of_month.value()}"
|
||||
# weekly/biweekly
|
||||
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
|
||||
if not chosen:
|
||||
return None
|
||||
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
|
||||
return f"{prefix}:" + ",".join(chosen)
|
||||
|
||||
def _save(self):
|
||||
pattern = self._build_pattern()
|
||||
if not pattern:
|
||||
QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
|
||||
return
|
||||
days = self.days_combo.currentData()
|
||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||
start = self.start_edit.date().toString('yyyy-MM-dd')
|
||||
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
|
||||
memo = self.memo_edit.text().strip()
|
||||
|
||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||
QMessageBox.information(self, tr('recurring.add_done_title'),
|
||||
tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
|
||||
self.memo_edit.clear()
|
||||
self._reload_list()
|
||||
@ -1,318 +0,0 @@
|
||||
"""
|
||||
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
|
||||
|
||||
기능:
|
||||
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
|
||||
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
|
||||
- 반복 패턴 관리 → RecurringLeaveDialog
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import List
|
||||
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QCalendarWidget, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QMenu,
|
||||
QGroupBox, QSplitter, QWidget)
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# 색상 팔레트
|
||||
_C_HOLIDAY = QColor("#e53935") # 빨강
|
||||
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
|
||||
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
|
||||
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
|
||||
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
|
||||
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
|
||||
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
|
||||
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
|
||||
|
||||
|
||||
class ScheduleView(QDialog):
|
||||
"""월간 통합 스케줄 다이얼로그."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('schedule.title'))
|
||||
self.setMinimumSize(820, 560)
|
||||
self._build_ui()
|
||||
self._reload()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 상단 툴바
|
||||
bar = QHBoxLayout()
|
||||
title = QLabel(tr('schedule.header'))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
bar.addWidget(title)
|
||||
bar.addStretch()
|
||||
|
||||
rec_btn = QPushButton(tr('schedule.btn_recurring'))
|
||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||
bar.addWidget(rec_btn)
|
||||
|
||||
add_btn = QPushButton(tr('schedule.btn_add_leave'))
|
||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||
bar.addWidget(add_btn)
|
||||
|
||||
layout.addLayout(bar)
|
||||
|
||||
# 범례
|
||||
legend = QHBoxLayout()
|
||||
for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
|
||||
(tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
|
||||
(tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
|
||||
(tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
|
||||
(tr('schedule.legend_recurring'), _C_RECURRING)]:
|
||||
sw = QLabel(f" {label} ")
|
||||
sw.setStyleSheet(
|
||||
f"background-color: {color.name()}; color: white; "
|
||||
f"padding: 2px 6px; border-radius: 3px;"
|
||||
)
|
||||
legend.addWidget(sw)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
|
||||
# 캘린더 + 상세 splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 좌측: 캘린더
|
||||
self.calendar = QCalendarWidget()
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self._on_date_click)
|
||||
self.calendar.currentPageChanged.connect(self._on_page_change)
|
||||
splitter.addWidget(self.calendar)
|
||||
|
||||
# 우측: 상세 패널
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
|
||||
self.detail_title = QLabel(tr('schedule.detail_placeholder'))
|
||||
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
right_layout.addWidget(self.detail_title)
|
||||
|
||||
self.detail_list = QListWidget()
|
||||
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
|
||||
right_layout.addWidget(self.detail_list, 1)
|
||||
|
||||
right.setLayout(right_layout)
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([520, 280])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# ------------------------------------------------------------- reload
|
||||
|
||||
def _reload(self):
|
||||
"""현재 화면 월에 대해 색상/리스트 갱신."""
|
||||
# 모든 날짜 포맷 초기화
|
||||
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
|
||||
|
||||
y = self.calendar.yearShown()
|
||||
m = self.calendar.monthShown()
|
||||
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
|
||||
first = date(y, m, 1)
|
||||
if m == 12:
|
||||
last = date(y + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
last = date(y, m + 1, 1) - timedelta(days=1)
|
||||
view_start = first - timedelta(days=7)
|
||||
view_end = last + timedelta(days=7)
|
||||
|
||||
# 휴일
|
||||
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
|
||||
view_end.isoformat()) \
|
||||
if hasattr(self.db, 'get_holidays_in_range') else []
|
||||
if not holidays:
|
||||
holidays = self._fallback_holidays(view_start, view_end)
|
||||
|
||||
for h in holidays:
|
||||
d = self._parse_date(h.get('date'))
|
||||
if d is None:
|
||||
continue
|
||||
self._paint(d, _C_HOLIDAY, fg='white')
|
||||
|
||||
# 연차 (구체)
|
||||
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
|
||||
view_end.isoformat())
|
||||
today = date.today()
|
||||
for r in leaves:
|
||||
d = self._parse_date(r.get('date'))
|
||||
if d is None:
|
||||
continue
|
||||
days = float(r.get('days') or 0)
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
|
||||
else:
|
||||
if days >= 1.0:
|
||||
color = _C_LEAVE_FULL_PAST
|
||||
elif days >= 0.5:
|
||||
color = _C_LEAVE_HALF_PAST
|
||||
else:
|
||||
color = _C_LEAVE_QUART_PAST
|
||||
self._paint(d, color, fg='white')
|
||||
|
||||
# 반복 패턴 인스턴스
|
||||
recurring = self.db.get_recurring_leaves()
|
||||
for occ in expand_for_range(recurring, view_start, view_end):
|
||||
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
|
||||
existing = self.calendar.dateTextFormat(
|
||||
QDate(occ.date.year, occ.date.month, occ.date.day))
|
||||
if existing.background() != QBrush():
|
||||
continue
|
||||
self._paint(occ.date, _C_RECURRING, fg='white')
|
||||
|
||||
def _paint(self, d: date, color: QColor, fg: str = 'white'):
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setBackground(QBrush(color))
|
||||
fmt.setForeground(QBrush(QColor(fg)))
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
|
||||
# ------------------------------------------------------------- events
|
||||
|
||||
def _on_date_click(self, qd: QDate):
|
||||
d = date(qd.year(), qd.month(), qd.day())
|
||||
date_str = d.isoformat()
|
||||
weekday_kr = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
||||
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), tr('label.weekday_sun')]
|
||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
|
||||
self.detail_list.clear()
|
||||
|
||||
# 휴일
|
||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||
if holiday:
|
||||
item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
|
||||
item.setForeground(QBrush(QColor("#e53935")))
|
||||
self.detail_list.addItem(item)
|
||||
elif d.weekday() in (5, 6):
|
||||
item = QListWidgetItem(tr('schedule.weekend', weekday=weekday_kr[d.weekday()]))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 연차 (구체)
|
||||
for r in self.db.get_leave_records_by_date(date_str):
|
||||
days = float(r.get('days') or 0)
|
||||
t = r.get('leave_type', '연차')
|
||||
memo = r.get('memo') or ''
|
||||
label = tr('schedule.leave_label', type=t, days=days)
|
||||
if memo:
|
||||
label += f" — {memo}"
|
||||
label += f" [id={r['id']}]"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, ('concrete', r['id']))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 반복 패턴 매치
|
||||
recurring = self.db.get_recurring_leaves(active_on=date_str)
|
||||
from core.recurring_leaves import expand_for_date
|
||||
for occ in expand_for_date(recurring, d):
|
||||
item = QListWidgetItem(
|
||||
tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
|
||||
days=occ.days, type=occ.leave_type)
|
||||
)
|
||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
if self.detail_list.count() == 0:
|
||||
self.detail_list.addItem(tr('schedule.no_events'))
|
||||
|
||||
def _on_page_change(self, year: int, month: int):
|
||||
self._reload()
|
||||
|
||||
def _on_list_menu(self, pos):
|
||||
item = self.detail_list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
data = item.data(Qt.UserRole)
|
||||
if not data:
|
||||
return
|
||||
kind, _id = data
|
||||
menu = QMenu(self)
|
||||
del_act = menu.addAction(tr('schedule.delete'))
|
||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._delete_record(kind, _id)
|
||||
|
||||
def _delete_record(self, kind: str, _id: int):
|
||||
if kind == 'concrete':
|
||||
reply = QMessageBox.question(
|
||||
self, tr('schedule.delete_leave_confirm_title'),
|
||||
tr('schedule.delete_leave_confirm_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_leave_record(_id)
|
||||
self._reload()
|
||||
# 상세 갱신
|
||||
d = self.calendar.selectedDate()
|
||||
self._on_date_click(d)
|
||||
elif kind == 'recurring':
|
||||
reply = QMessageBox.question(
|
||||
self, tr('schedule.delete_recurring_confirm_title'),
|
||||
tr('schedule.delete_recurring_confirm_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_recurring_leave(_id)
|
||||
self._reload()
|
||||
d = self.calendar.selectedDate()
|
||||
self._on_date_click(d)
|
||||
|
||||
def _open_recurring_dialog(self):
|
||||
from ui.recurring_leave_dialog import RecurringLeaveDialog
|
||||
dlg = RecurringLeaveDialog(self, self.db)
|
||||
dlg.exec_()
|
||||
self._reload()
|
||||
|
||||
def _open_add_leave_dialog(self):
|
||||
from ui.leave_view import AddLeaveDialog
|
||||
dlg = AddLeaveDialog(self, self.db)
|
||||
# 선택된 날짜로 기본값 설정
|
||||
d = self.calendar.selectedDate()
|
||||
if d.isValid():
|
||||
dlg.date_edit.setDate(d)
|
||||
if dlg.exec_() == dlg.Accepted:
|
||||
self._reload()
|
||||
self._on_date_click(d)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
|
||||
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
|
||||
if not hasattr(self.db, 'get_holiday'):
|
||||
return []
|
||||
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
|
||||
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
|
||||
out = []
|
||||
cur = view_start
|
||||
while cur <= view_end:
|
||||
h = self.db.get_holiday(cur.isoformat())
|
||||
if h:
|
||||
out.append(h)
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
File diff suppressed because it is too large
Load Diff
268
ui/stats_view.py
268
ui/stats_view.py
@ -2,8 +2,7 @@
|
||||
통계 대시보드 - 주간/월간 통계
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
|
||||
QFrame)
|
||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
|
||||
from PyQt5.QtCore import Qt
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
@ -13,10 +12,6 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.database import Database
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||
transparent_label, tc,
|
||||
)
|
||||
|
||||
|
||||
class StatsView(QDialog):
|
||||
@ -27,91 +22,72 @@ class StatsView(QDialog):
|
||||
self.db = db if db else Database()
|
||||
self.init_ui()
|
||||
self.load_stats()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('window.stats'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(720, 600)
|
||||
self.resize(900, 720)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.setMinimumSize(420, 350)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 14)
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 다크 톤 타이틀
|
||||
title = QLabel(f"{tr('stats.title')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
title = QLabel(tr('stats.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
|
||||
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
|
||||
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
|
||||
# 도전과제용 탭 진입 카운터
|
||||
tabs.currentChanged.connect(self._on_tab_changed)
|
||||
self._on_tab_changed(0)
|
||||
layout.addWidget(tabs, 1)
|
||||
layout.addWidget(tabs)
|
||||
|
||||
# 닫기 버튼 — 우측 정렬
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setMinimumWidth(100)
|
||||
close_button.setStyleSheet(button_qss('ghost'))
|
||||
close_button.clicked.connect(self.close)
|
||||
btn_row.addWidget(close_button)
|
||||
layout.addLayout(btn_row)
|
||||
layout.addWidget(close_button)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_tab_changed(self, idx: int) -> None:
|
||||
"""탭별 진입 카운터 (도전과제 시스템용). 실패는 silent."""
|
||||
keys = ['stat_weekly_view_count', 'stat_monthly_view_count',
|
||||
'stat_pattern_view_count']
|
||||
if 0 <= idx < len(keys):
|
||||
try:
|
||||
cur = self.db.get_setting_int(keys[idx], 0)
|
||||
self.db.set_setting(keys[idx], str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_weekly_tab(self) -> QWidget:
|
||||
"""주간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.weekly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
||||
theme='blue', icon='clock')
|
||||
self.weekly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_week'),
|
||||
theme='cyan', icon='calendar')
|
||||
self.weekly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
||||
theme='green', icon='chart')
|
||||
self.weekly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_week'),
|
||||
theme='gold', icon='flame')
|
||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||
self.weekly_avg_card, self.weekly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
layout.addLayout(cards_row)
|
||||
summary_group = QGroupBox(tr('stats.weekly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||
self.weekly_total_hours = QLabel("0")
|
||||
self.weekly_total_hours.setObjectName("stat_value")
|
||||
self.weekly_work_days = QLabel("0")
|
||||
self.weekly_work_days.setObjectName("stat_value")
|
||||
self.weekly_avg_hours = QLabel("0")
|
||||
self.weekly_avg_hours.setObjectName("stat_value")
|
||||
self.weekly_overtime = QLabel("0")
|
||||
self.weekly_overtime.setObjectName("stat_value")
|
||||
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
||||
summary_layout.addWidget(self.weekly_total_hours, 0, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
||||
summary_layout.addWidget(self.weekly_work_days, 1, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
||||
summary_layout.addWidget(self.weekly_avg_hours, 2, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
||||
summary_layout.addWidget(self.weekly_overtime, 3, 1)
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
|
||||
# 주간 차트 (일별 근무시간)
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.weekly_chart = make_chart_widget(widget)
|
||||
chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart,
|
||||
theme='gray', icon='trending-up')
|
||||
layout.addWidget(chart_card, 1)
|
||||
layout.addWidget(self.weekly_chart, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@ -119,49 +95,46 @@ class StatsView(QDialog):
|
||||
def create_monthly_tab(self) -> QWidget:
|
||||
"""월간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
# 카드 4개
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.monthly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
||||
theme='blue', icon='clock')
|
||||
self.monthly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_month'),
|
||||
theme='cyan', icon='calendar')
|
||||
self.monthly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
||||
theme='green', icon='chart')
|
||||
self.monthly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_month'),
|
||||
theme='gold', icon='flame')
|
||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||
self.monthly_avg_card, self.monthly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
layout.addLayout(cards_row)
|
||||
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
# 추정 급여 카드 (옵션 활성 시)
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet(
|
||||
f"background: rgba(81, 207, 102, 0.12); "
|
||||
f"border: 1px solid {tc('green')}; border-radius: 8px; "
|
||||
f"color: {tc('green')}; font-weight: bold; "
|
||||
f"padding: 10px 14px; font-size: 11pt;"
|
||||
)
|
||||
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
|
||||
self.salary_label.setVisible(False)
|
||||
layout.addWidget(self.salary_label)
|
||||
|
||||
# 목표 진행률
|
||||
from ui.goal_widget import GoalWidget
|
||||
self.goal_widget = GoalWidget(self.db)
|
||||
layout.addWidget(self.goal_widget)
|
||||
summary_group = QGroupBox(tr('stats.monthly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.monthly_total_hours = QLabel("0")
|
||||
self.monthly_total_hours.setObjectName("stat_value")
|
||||
self.monthly_work_days = QLabel("0")
|
||||
self.monthly_work_days.setObjectName("stat_value")
|
||||
self.monthly_avg_hours = QLabel("0")
|
||||
self.monthly_avg_hours.setObjectName("stat_value")
|
||||
self.monthly_overtime = QLabel("0")
|
||||
self.monthly_overtime.setObjectName("stat_value")
|
||||
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
||||
summary_layout.addWidget(self.monthly_total_hours, 0, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
||||
summary_layout.addWidget(self.monthly_work_days, 1, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
||||
summary_layout.addWidget(self.monthly_avg_hours, 2, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
||||
summary_layout.addWidget(self.monthly_overtime, 3, 1)
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
layout.addWidget(self.salary_label)
|
||||
|
||||
# 월간 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.monthly_chart = make_chart_widget(widget)
|
||||
chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart,
|
||||
theme='gray', icon='chart')
|
||||
layout.addWidget(chart_card, 1)
|
||||
layout.addWidget(self.monthly_chart, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@ -169,71 +142,42 @@ class StatsView(QDialog):
|
||||
def create_pattern_tab(self) -> QWidget:
|
||||
"""패턴 분석 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
pattern_group = QGroupBox(tr('stats.pattern_insights'))
|
||||
pattern_layout = QVBoxLayout()
|
||||
pattern_layout.setSpacing(4)
|
||||
pattern_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
# 패턴 텍스트 카드
|
||||
self.pattern_text = QLabel(tr('stats.analyzing'))
|
||||
self.pattern_text.setWordWrap(True)
|
||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
self.pattern_text.setStyleSheet(
|
||||
f"font-size: 11pt; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(build_section_card(tr('stats.pattern_insights'), self.pattern_text,
|
||||
theme='cyan', icon='search'))
|
||||
|
||||
# 출근 시각 분포 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.clock_in_chart = make_chart_widget(widget)
|
||||
layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
|
||||
theme='gray', icon='clock'), 1)
|
||||
pattern_layout.addWidget(self.pattern_text)
|
||||
|
||||
pattern_group.setLayout(pattern_layout)
|
||||
layout.addWidget(pattern_group)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _set_card_value(self, card, value: str) -> None:
|
||||
"""build_stat_card로 만든 카드의 큰 숫자 라벨 업데이트.
|
||||
|
||||
카드 구조: QFrame > QHBoxLayout > [icon QLabel] [text QVBoxLayout > title, value, subtitle]
|
||||
value는 두 번째 QLabel.
|
||||
"""
|
||||
# text_box는 outer hbox의 마지막 layout
|
||||
outer = card.layout()
|
||||
if outer is None or outer.count() == 0:
|
||||
return
|
||||
# text_box 찾기 (마지막 item, layout)
|
||||
text_item = outer.itemAt(outer.count() - 1)
|
||||
text_box = text_item.layout() if text_item else None
|
||||
if text_box is None or text_box.count() < 2:
|
||||
return
|
||||
val_lbl = text_box.itemAt(1).widget() # 두 번째가 큰 숫자
|
||||
if val_lbl is None:
|
||||
return
|
||||
# 큰 숫자 RichText 형식 유지
|
||||
from ui.dark_components import CARD_THEMES
|
||||
# tier color는 카드 자체에 알 방법이 없으니 기본 골드 톤
|
||||
val_lbl.setText(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ffd24a;'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
|
||||
def load_stats(self):
|
||||
"""통계 로드"""
|
||||
# 주간 통계
|
||||
weekly_stats = self.db.get_weekly_stats()
|
||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||
self._set_card_value(self.weekly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||
self._set_card_value(self.weekly_days_card, tr('stats.value_days', days=weekly_stats.get('work_days', 0)))
|
||||
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 0)}일")
|
||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||
self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}"))
|
||||
self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간")
|
||||
|
||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
self._set_card_value(self.weekly_ot_card, tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
||||
self.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
||||
|
||||
# 주간 차트
|
||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||
@ -243,29 +187,26 @@ class StatsView(QDialog):
|
||||
(today - _td(days=6)).isoformat(), today.isoformat()
|
||||
)
|
||||
if hasattr(self, 'weekly_chart'):
|
||||
# 도전과제 chart_hover 감지를 위해 db 참조 attach
|
||||
self.weekly_chart._achievement_db = self.db
|
||||
draw_daily_hours(self.weekly_chart, week_records)
|
||||
|
||||
# 월간 통계
|
||||
now = datetime.now()
|
||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||
self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||
self.monthly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
work_days = monthly_stats.get('work_days', 0) or 0
|
||||
self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days))
|
||||
self.monthly_work_days.setText(f"{work_days}일")
|
||||
|
||||
if work_days > 0:
|
||||
avg = total_hours / work_days
|
||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}"))
|
||||
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
|
||||
else:
|
||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0))
|
||||
self.monthly_avg_hours.setText("0시간")
|
||||
|
||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
self._set_card_value(self.monthly_ot_card,
|
||||
tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
||||
self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
||||
|
||||
# 월간 차트 (요일별 평균)
|
||||
if hasattr(self, 'monthly_chart'):
|
||||
@ -274,10 +215,6 @@ class StatsView(QDialog):
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
self._update_salary_estimate(monthly_stats.get('records', []))
|
||||
|
||||
# 목표 진행률
|
||||
if hasattr(self, 'goal_widget'):
|
||||
self.goal_widget.refresh()
|
||||
|
||||
# 패턴 분석
|
||||
self.analyze_patterns(monthly_stats.get('records', []))
|
||||
|
||||
@ -300,20 +237,13 @@ class StatsView(QDialog):
|
||||
from core.salary import estimate_pay, format_won
|
||||
result = estimate_pay(records, wage, rate)
|
||||
self.salary_label.setText(
|
||||
tr('stats.salary_estimate',
|
||||
total=format_won(result['total']),
|
||||
base=format_won(result['base']),
|
||||
overtime=format_won(result['overtime']))
|
||||
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
||||
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
||||
)
|
||||
self.salary_label.setVisible(True)
|
||||
|
||||
def analyze_patterns(self, records):
|
||||
"""패턴 분석"""
|
||||
# 출근 분포 차트는 데이터 유무와 무관하게 갱신 (빈 차트 표시)
|
||||
if hasattr(self, 'clock_in_chart'):
|
||||
from ui.chart_widget import draw_clock_in_distribution
|
||||
draw_clock_in_distribution(self.clock_in_chart, records or [])
|
||||
|
||||
if not records:
|
||||
self.pattern_text.setText(tr('stats.no_data'))
|
||||
return
|
||||
@ -336,7 +266,7 @@ class StatsView(QDialog):
|
||||
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
||||
avg_hour = int(avg_minutes // 60)
|
||||
avg_min = int(avg_minutes % 60)
|
||||
insights.append(tr('stats.avg_clock_in', time=f"{avg_hour:02d}:{avg_min:02d}"))
|
||||
insights.append(f"📌 평균 출근시간: {avg_hour:02d}:{avg_min:02d}")
|
||||
|
||||
# 연장근무 빈도
|
||||
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
||||
@ -344,14 +274,14 @@ class StatsView(QDialog):
|
||||
|
||||
if total_days > 0:
|
||||
overtime_rate = (overtime_days / total_days) * 100
|
||||
insights.append(tr('stats.overtime_frequency', rate=f"{overtime_rate:.0f}", days=overtime_days, total=total_days))
|
||||
insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)")
|
||||
|
||||
# 가장 긴 근무일
|
||||
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
||||
if records_with_hours:
|
||||
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
||||
if longest_work.get('total_hours', 0) > 0:
|
||||
insights.append(tr('stats.longest_work', date=longest_work['date'], hours=f"{longest_work['total_hours']:.1f}"))
|
||||
insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)")
|
||||
|
||||
# 건강 경고
|
||||
recent_records = records[-7:] # 최근 7일
|
||||
@ -366,15 +296,15 @@ class StatsView(QDialog):
|
||||
consecutive_overtime = 0
|
||||
|
||||
if max_consecutive >= 3:
|
||||
insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive))
|
||||
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!")
|
||||
|
||||
# 주 52시간 체크
|
||||
if len(recent_records) >= 7:
|
||||
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
||||
if week_total > 52:
|
||||
insights.append(tr('stats.weekly_52_exceeded', hours=f"{week_total:.1f}"))
|
||||
insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간")
|
||||
|
||||
self.pattern_text.setText("\n\n".join(insights) if insights else tr('stats.no_pattern_data'))
|
||||
self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.")
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
|
||||
282
ui/styles.py
282
ui/styles.py
@ -33,8 +33,8 @@ def _ensure_icons():
|
||||
for name, color_hex, points in [
|
||||
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
||||
]:
|
||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||
if not os.path.exists(path):
|
||||
@ -78,9 +78,6 @@ LIGHT_COLORS = {
|
||||
'bg_primary': '#F5F5F7',
|
||||
'bg_secondary': '#FFFFFF',
|
||||
'bg_tertiary': '#EDEDF0',
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#E2E3E7',
|
||||
'surface_pressed': '#D5D6DB',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#1A1A2E',
|
||||
'text_secondary': '#4A4A68',
|
||||
@ -88,15 +85,9 @@ LIGHT_COLORS = {
|
||||
'text_inverse': '#FFFFFF',
|
||||
# 액센트
|
||||
'accent_primary': '#3B82F6',
|
||||
'accent_primary_hover': '#2F74EE',
|
||||
'accent_primary_pressed': '#2563EB',
|
||||
'accent_success': '#10B981',
|
||||
'accent_success_hover': '#0EA372',
|
||||
'accent_success_pressed': '#0C8F63',
|
||||
'accent_warning': '#F59E0B',
|
||||
'accent_danger': '#EF4444',
|
||||
'accent_danger_hover': '#DC2626',
|
||||
'accent_danger_pressed': '#B91C1C',
|
||||
# 테두리
|
||||
'border_subtle': '#E5E7EB',
|
||||
'border_default': '#D1D5DB',
|
||||
@ -129,58 +120,40 @@ LIGHT_COLORS = {
|
||||
}
|
||||
|
||||
DARK_COLORS = {
|
||||
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
|
||||
'bg_primary': '#1A1B1E', # 앱 배경
|
||||
'bg_secondary': '#25262B', # 카드 / 패널
|
||||
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#34363D',
|
||||
'surface_pressed': '#3A3D44',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#E9ECEF',
|
||||
'text_secondary': '#909296',
|
||||
'text_tertiary': '#6C6E73',
|
||||
'bg_primary': '#111118',
|
||||
'bg_secondary': '#1C1C2E',
|
||||
'bg_tertiary': '#282842',
|
||||
'text_primary': '#ECECF4',
|
||||
'text_secondary': '#B0B0C8',
|
||||
'text_tertiary': '#808098',
|
||||
'text_inverse': '#FFFFFF',
|
||||
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||
'accent_primary': '#4DABF7',
|
||||
'accent_primary_hover': '#69B6F8',
|
||||
'accent_primary_pressed': '#3D97E0',
|
||||
'accent_success': '#51CF66',
|
||||
'accent_success_hover': '#69DB7C',
|
||||
'accent_success_pressed': '#43B85A',
|
||||
'accent_warning': '#FAB005',
|
||||
'accent_danger': '#FA5252',
|
||||
'accent_danger_hover': '#FF6B6B',
|
||||
'accent_danger_pressed': '#E64545',
|
||||
# 테두리
|
||||
'border_subtle': '#2C2E33',
|
||||
'border_default': '#373A40',
|
||||
'border_focus': '#4DABF7',
|
||||
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
|
||||
'badge_overtime_bg': '#2C2E33',
|
||||
'badge_overtime_text': '#FAB005',
|
||||
'badge_leave_bg': '#2C2E33',
|
||||
'badge_leave_text': '#4DABF7',
|
||||
'badge_total_bg': '#2C2E33',
|
||||
'badge_total_text': '#51CF66',
|
||||
# 프로그레스 — 단일 accent 솔리드
|
||||
'progress_bg': '#2C2E33',
|
||||
'progress_start': '#4DABF7',
|
||||
'progress_end': '#4DABF7',
|
||||
# 상태 색상 (동적 텍스트 피드백)
|
||||
'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',
|
||||
'accent_primary': '#6B9EFF',
|
||||
'accent_success': '#4ADE80',
|
||||
'accent_warning': '#FCD34D',
|
||||
'accent_danger': '#FB7185',
|
||||
'border_subtle': '#32324E',
|
||||
'border_default': '#44446A',
|
||||
'border_focus': '#6B9EFF',
|
||||
'badge_overtime_bg': '#3D2008',
|
||||
'badge_overtime_text': '#FDE68A',
|
||||
'badge_leave_bg': '#1E2D5F',
|
||||
'badge_leave_text': '#A5D0FE',
|
||||
'badge_total_bg': '#0A3324',
|
||||
'badge_total_text': '#86EFAC',
|
||||
'progress_bg': '#282842',
|
||||
'progress_start': '#6B9EFF',
|
||||
'progress_end': '#4ADE80',
|
||||
'status_overtime': '#FB7185',
|
||||
'status_warning': '#FCD34D',
|
||||
'status_normal': '#4ADE80',
|
||||
'status_break_active': '#FB7185',
|
||||
'status_break_idle': '#808098',
|
||||
'cal_normal': '#1A4D3A',
|
||||
'cal_overtime': '#5C1A1A',
|
||||
'cal_incomplete': '#5C3A10',
|
||||
'scrollbar_bg': '#111118',
|
||||
'scrollbar_handle': '#44446A',
|
||||
'scrollbar_hover': '#5A5A88',
|
||||
}
|
||||
|
||||
|
||||
@ -219,7 +192,7 @@ QMainWindow, QDialog {{
|
||||
}}
|
||||
|
||||
QWidget {{
|
||||
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@ -233,14 +206,14 @@ QWidget#central_widget {{
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QLabel#app_title {{
|
||||
font-size: 13pt;
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#date_label {{
|
||||
font-size: 9.5pt;
|
||||
font-size: 9pt;
|
||||
color: {c['text_secondary']};
|
||||
padding-bottom: 4px;
|
||||
}}
|
||||
@ -248,7 +221,7 @@ QLabel#date_label {{
|
||||
QLabel#section_title {{
|
||||
font-size: 9.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_secondary']};
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
QLabel#field_label {{
|
||||
@ -256,30 +229,29 @@ QLabel#field_label {{
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||
QLabel#time_value {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 15pt;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||
QLabel#time_display {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 30pt;
|
||||
font-size: 22pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}}
|
||||
|
||||
QLabel#expected_time {{
|
||||
font-size: 11.5pt;
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_secondary']};
|
||||
padding: 2px;
|
||||
color: {c['text_primary']};
|
||||
padding: 4px;
|
||||
}}
|
||||
|
||||
QLabel#dialog_title {{
|
||||
@ -323,7 +295,7 @@ QLabel#badge_overtime {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_overtime_bg']};
|
||||
color: {c['badge_overtime_text']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QLabel#badge_leave {{
|
||||
@ -334,7 +306,7 @@ QLabel#badge_leave {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_leave_bg']};
|
||||
color: {c['badge_leave_text']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QLabel#badge_total {{
|
||||
@ -345,7 +317,7 @@ QLabel#badge_total {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QLabel#badge_balance {{
|
||||
@ -354,7 +326,7 @@ QLabel#badge_balance {{
|
||||
padding: 10px;
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
QLabel#badge_success {{
|
||||
@ -363,7 +335,7 @@ QLabel#badge_success {{
|
||||
padding: 8px;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -383,9 +355,9 @@ QLabel#separator {{
|
||||
QGroupBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 16px;
|
||||
padding: 14px;
|
||||
padding-top: 28px;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
@ -406,55 +378,52 @@ QGroupBox::title {{
|
||||
버튼
|
||||
════════════════════════════════════════ */
|
||||
|
||||
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||
QPushButton {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
background: {c['surface_hover']};
|
||||
background: {c['border_default']};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background: {c['surface_pressed']};
|
||||
background: {c['border_subtle']};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background: {c['bg_secondary']};
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_tertiary']};
|
||||
border-color: {c['border_subtle']};
|
||||
}}
|
||||
|
||||
QPushButton:checked {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
border-color: {c['accent_primary']};
|
||||
}}
|
||||
|
||||
QPushButton:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||
/* 퇴근 버튼 (primary action) */
|
||||
QPushButton#clock_out_button {{
|
||||
background: {c['accent_primary']};
|
||||
background: {c['accent_success']};
|
||||
color: {c['text_inverse']};
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding: 11px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:hover {{
|
||||
background: {c['accent_primary_hover']};
|
||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:pressed {{
|
||||
background: {c['accent_primary_pressed']};
|
||||
background: {'#0C8F63' if not is_dark else '#28A87A'};
|
||||
}}
|
||||
|
||||
/* 주요 액션 버튼 */
|
||||
@ -466,11 +435,11 @@ QPushButton#btn_primary {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:hover {{
|
||||
background: {c['accent_primary_hover']};
|
||||
background: {c['accent_primary']}DD;
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:pressed {{
|
||||
background: {c['accent_primary_pressed']};
|
||||
background: {c['accent_primary']}BB;
|
||||
}}
|
||||
|
||||
/* 위험 버튼 */
|
||||
@ -481,11 +450,11 @@ QPushButton#btn_danger {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:hover {{
|
||||
background: {c['accent_danger_hover']};
|
||||
background: {c['accent_danger']}DD;
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:pressed {{
|
||||
background: {c['accent_danger_pressed']};
|
||||
background: {c['accent_danger']}BB;
|
||||
}}
|
||||
|
||||
/* 성공 버튼 */
|
||||
@ -496,44 +465,25 @@ QPushButton#btn_success {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:hover {{
|
||||
background: {c['accent_success_hover']};
|
||||
background: {c['accent_success']}DD;
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:pressed {{
|
||||
background: {c['accent_success_pressed']};
|
||||
background: {c['accent_success']}BB;
|
||||
}}
|
||||
|
||||
/* 작은 버튼 — 미묘한 표면 */
|
||||
/* 작은 버튼 */
|
||||
QPushButton#btn_small {{
|
||||
font-size: 8.5pt;
|
||||
padding: 6px 10px;
|
||||
padding: 5px 10px;
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:hover {{
|
||||
background: {c['surface_hover']};
|
||||
background: {c['accent_primary']}20;
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:pressed {{
|
||||
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']};
|
||||
background: {c['accent_primary']}35;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -543,7 +493,7 @@ QPushButton#nav_btn:pressed {{
|
||||
QLineEdit, QTextEdit, QComboBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
@ -553,17 +503,21 @@ QLineEdit, QTextEdit, QComboBox {{
|
||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
min-height: 20px;
|
||||
}}
|
||||
|
||||
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 7px;
|
||||
}}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||
border: 1px solid {c['border_focus']};
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 27px 5px 7px;
|
||||
}}
|
||||
|
||||
/* 비활성 입력 필드 */
|
||||
@ -609,13 +563,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||
QDateEdit::up-button, QTimeEdit::up-button {{
|
||||
subcontrol-position: top right;
|
||||
border-top-right-radius: 7px;
|
||||
border-top-right-radius: 4px;
|
||||
}}
|
||||
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||
subcontrol-position: bottom right;
|
||||
border-bottom-right-radius: 7px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}}
|
||||
|
||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||
@ -674,17 +628,17 @@ QCheckBox::indicator:hover {{
|
||||
QProgressBar {{
|
||||
border: none;
|
||||
background: {c['progress_bg']};
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
max-height: 6px;
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
font-size: 0px;
|
||||
}}
|
||||
|
||||
QProgressBar::chunk {{
|
||||
background: {c['progress_start']};
|
||||
border-radius: 3px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
||||
border-radius: 4px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -694,7 +648,7 @@ QProgressBar::chunk {{
|
||||
QTableWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
gridline-color: {c['border_subtle']};
|
||||
color: {c['text_primary']};
|
||||
font-size: 9pt;
|
||||
@ -713,47 +667,23 @@ QTableWidget::item:alternate {{
|
||||
background: {c['bg_tertiary']};
|
||||
}}
|
||||
|
||||
/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
|
||||
QHeaderView {{
|
||||
background: {c['bg_secondary']};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QHeaderView::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_secondary']};
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
font-weight: bold;
|
||||
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 {{
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
background: {c['bg_secondary']};
|
||||
top: -1px;
|
||||
}}
|
||||
@ -764,8 +694,8 @@ QTabBar::tab {{
|
||||
padding: 8px 20px;
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
margin-right: 2px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
@ -857,7 +787,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
||||
QCalendarWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
@ -972,7 +902,7 @@ QToolTip {{
|
||||
QMenu {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@ -986,16 +916,6 @@ QMenu::item:selected {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
}}
|
||||
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background: {c['border_subtle']};
|
||||
margin: 4px 8px;
|
||||
}}
|
||||
|
||||
QMenu::icon {{
|
||||
padding-left: 8px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -7,8 +7,6 @@ from __future__ import annotations
|
||||
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class TodaySummaryCard(QFrame):
|
||||
"""퇴근 처리 직후 표시되는 요약 카드."""
|
||||
@ -18,12 +16,12 @@ class TodaySummaryCard(QFrame):
|
||||
self.setObjectName("today_summary_card")
|
||||
self.setStyleSheet("""
|
||||
QFrame#today_summary_card {
|
||||
background-color: rgba(81, 207, 102, 0.08);
|
||||
border: 1px solid rgba(81, 207, 102, 0.40);
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
QLabel { padding: 1px; background: transparent; border: none; }
|
||||
QLabel { padding: 1px; }
|
||||
""")
|
||||
self.setVisible(False)
|
||||
|
||||
@ -32,7 +30,7 @@ class TodaySummaryCard(QFrame):
|
||||
layout.setSpacing(2)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel(tr('today.title'))
|
||||
title = QLabel("📋 오늘의 요약")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@ -45,9 +43,9 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
self.total_label = QLabel("")
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
|
||||
layout.addWidget(self.total_label)
|
||||
layout.addWidget(self.detail_label)
|
||||
@ -57,37 +55,33 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
def show_summary(self, total_hours: float, lunch_minutes: int,
|
||||
break_minutes: int, overtime_actual: int,
|
||||
overtime_earned: int, salary_text: str = "",
|
||||
dinner_minutes: int = 0) -> None:
|
||||
overtime_earned: int, salary_text: str = "") -> None:
|
||||
"""카드 내용 채우고 표시.
|
||||
|
||||
Args:
|
||||
total_hours: 총 근무시간(시간)
|
||||
lunch_minutes: 점심 시간(분)
|
||||
break_minutes: 외출 시간(분, 식사 제외)
|
||||
break_minutes: 외출 시간(분)
|
||||
overtime_actual: 실제 연장근무(분)
|
||||
overtime_earned: 적립 연장근무(분)
|
||||
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
||||
dinner_minutes: 저녁 시간(분), 0이면 표시 안 함
|
||||
"""
|
||||
h = int(total_hours)
|
||||
m = int((total_hours - h) * 60)
|
||||
self.total_label.setText(tr('today.total_work', hours=h, minutes=m))
|
||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
||||
|
||||
details = []
|
||||
if lunch_minutes > 0:
|
||||
details.append(tr('today.detail_lunch', minutes=lunch_minutes))
|
||||
if dinner_minutes > 0:
|
||||
details.append(tr('today.detail_dinner', minutes=dinner_minutes))
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
if break_minutes > 0:
|
||||
details.append(tr('today.detail_break', minutes=break_minutes))
|
||||
details.append(f"외출 {break_minutes}분")
|
||||
if overtime_actual > 0:
|
||||
details.append(tr('today.detail_overtime', actual=overtime_actual, earned=overtime_earned))
|
||||
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
|
||||
self.detail_label.setText(" · ".join(details) if details else "")
|
||||
self.detail_label.setVisible(bool(details))
|
||||
|
||||
if salary_text:
|
||||
self.salary_label.setText(f"{salary_text}")
|
||||
self.salary_label.setText(f"💰 {salary_text}")
|
||||
self.salary_label.setVisible(True)
|
||||
else:
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
117
updater.py
117
updater.py
@ -13,9 +13,6 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
|
||||
3. new_exe → target_exe 이동
|
||||
4. target_exe 재실행 + 업데이터 자가 종료
|
||||
실패 시 .bak 복원
|
||||
|
||||
빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임.
|
||||
모든 진단 출력은 ~/.clockout_logs/updater.log 로 append.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
@ -24,30 +21,9 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
|
||||
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
"""진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
|
||||
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
|
||||
# stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
|
||||
try:
|
||||
print(line, end='', file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_LOG_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def is_pid_running(pid: int) -> bool:
|
||||
"""Windows에서 PID 실행 중인지 확인."""
|
||||
if sys.platform != 'win32':
|
||||
@ -86,86 +62,42 @@ def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def replace_file(new_path: Path, target_path: Path,
|
||||
max_retries: int = 5) -> Path | None:
|
||||
def replace_file(new_path: Path, target_path: Path) -> Path | None:
|
||||
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
|
||||
|
||||
Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
|
||||
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
|
||||
있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).
|
||||
|
||||
Returns:
|
||||
백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
|
||||
백업 파일 경로 (롤백용). 실패 시 None.
|
||||
"""
|
||||
backup = target_path.with_suffix(target_path.suffix + '.bak')
|
||||
last_err: Exception | None = None
|
||||
|
||||
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
|
||||
if backup.exists():
|
||||
try:
|
||||
try:
|
||||
if backup.exists():
|
||||
backup.unlink()
|
||||
except OSError as e:
|
||||
_log(f"[updater] old backup unlink failed (continuing): {e}")
|
||||
|
||||
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
||||
for attempt in range(max_retries):
|
||||
if not target_path.exists():
|
||||
break # 첫 설치 등 — target 없으면 그냥 다음 단계로
|
||||
try:
|
||||
if target_path.exists():
|
||||
shutil.move(str(target_path), str(backup))
|
||||
break
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
# 모든 재시도 실패
|
||||
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||
shutil.move(str(new_path), str(target_path))
|
||||
return backup
|
||||
except OSError as e:
|
||||
print(f"[updater] replace failed: {e}", file=sys.stderr)
|
||||
# 롤백 시도
|
||||
if backup.exists() and not target_path.exists():
|
||||
try:
|
||||
shutil.move(str(backup), str(target_path))
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
# 3단계: new → target 이동
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
shutil.move(str(new_path), str(target_path))
|
||||
return backup
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
|
||||
# new 이동 실패 → backup으로 롤백 시도
|
||||
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
|
||||
if backup.exists() and not target_path.exists():
|
||||
try:
|
||||
shutil.move(str(backup), str(target_path))
|
||||
_log("[updater] rolled back from backup")
|
||||
except OSError as e:
|
||||
_log(f"[updater] rollback also failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def launch(exe_path: Path) -> bool:
|
||||
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
||||
try:
|
||||
if sys.platform == 'win32':
|
||||
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
|
||||
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
|
||||
close_fds=True,
|
||||
)
|
||||
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
|
||||
else:
|
||||
subprocess.Popen([str(exe_path)], close_fds=True)
|
||||
return True
|
||||
except OSError as e:
|
||||
_log(f"[updater] launch failed: {e}")
|
||||
print(f"[updater] launch failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
@ -177,34 +109,29 @@ def main() -> int:
|
||||
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
||||
args = parser.parse_args()
|
||||
|
||||
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
|
||||
|
||||
new_exe = Path(args.new).resolve()
|
||||
target_exe = Path(args.target).resolve()
|
||||
|
||||
if not new_exe.exists():
|
||||
_log(f"[updater] new exe not found: {new_exe}")
|
||||
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not wait_for_exit(args.pid, timeout_sec=30):
|
||||
_log(f"[updater] timeout waiting for PID {args.pid}")
|
||||
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
||||
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
|
||||
# Windows 파일 핸들 해제 시간 여유
|
||||
time.sleep(0.5)
|
||||
|
||||
backup = replace_file(new_exe, target_exe)
|
||||
if backup is None:
|
||||
_log("[updater] replace_file failed — aborting")
|
||||
return 4
|
||||
|
||||
if args.no_launch:
|
||||
_log("[updater] --no-launch set, exiting after replace")
|
||||
return 0
|
||||
|
||||
if not launch(target_exe):
|
||||
_log("[updater] launch failed — rolling back")
|
||||
# 시작 실패 시 롤백
|
||||
try:
|
||||
target_exe.unlink()
|
||||
shutil.move(str(backup), str(target_exe))
|
||||
@ -213,7 +140,7 @@ def main() -> int:
|
||||
pass
|
||||
return 5
|
||||
|
||||
_log("[updater] update complete, new app launched")
|
||||
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ exe = EXE(
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
|
||||
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
||||
@ -71,7 +71,5 @@ def _rotate(directory: Path, keep: int) -> None:
|
||||
for old in files[keep:]:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError as e:
|
||||
# 회전 실패 시 로그만 — 다음 실행에 재시도. 누적 시 디스크 압박 가능.
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"backup rotation failed for {old}: {e}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@ -1,228 +0,0 @@
|
||||
"""
|
||||
전역 예외 후킹 + Gitea Issues 자동 보고.
|
||||
|
||||
sys.excepthook을 등록해 처리되지 않은 예외를 가로채:
|
||||
1. crash_log 테이블에 저장
|
||||
2. 사용자에게 다이얼로그로 알림 + "Gitea에 보고" / "복사" 옵션
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# 자체 호스팅 Gitea (updater_client와 동일)
|
||||
GITEA_API = 'https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator'
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
|
||||
|
||||
|
||||
def install_global_handler(db, app_version: str = 'unknown') -> None:
|
||||
"""sys.excepthook 등록. db는 crash_log 저장용.
|
||||
|
||||
각 단계(log → dialog → 파일 fallback → original hook)는 모두 독립 try로 감싸
|
||||
하나가 실패해도 다음 단계가 동작. 가장 최후 fallback은 stderr + 로컬 파일.
|
||||
"""
|
||||
original = sys.excepthook
|
||||
|
||||
def hook(exc_type, exc_value, exc_tb):
|
||||
if exc_type is KeyboardInterrupt:
|
||||
original(exc_type, exc_value, exc_tb)
|
||||
return
|
||||
|
||||
# 1단계: traceback 직렬화 (실패하면 minimal fallback)
|
||||
try:
|
||||
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
except Exception:
|
||||
tb_str = f"{exc_type}: {exc_value} (traceback formatting failed)"
|
||||
|
||||
type_name = getattr(exc_type, '__name__', str(exc_type))
|
||||
msg = str(exc_value)
|
||||
|
||||
# 2단계: DB 로깅 (DB 잠금/디스크 풀 등으로 실패 가능 — 단계 분리)
|
||||
log_ok = False
|
||||
try:
|
||||
_log_crash(db, type_name, msg, tb_str, app_version)
|
||||
log_ok = True
|
||||
except Exception as log_err:
|
||||
_fallback_to_file(type_name, msg, tb_str, app_version,
|
||||
f"DB log failed: {log_err}")
|
||||
|
||||
# 3단계: 다이얼로그 (PyQt 미초기화/이미 종료 등으로 실패 가능)
|
||||
try:
|
||||
_show_dialog(db, type_name, msg, tb_str, app_version)
|
||||
except Exception as dlg_err:
|
||||
# 다이얼로그 실패 시 stderr + 파일에 기록 (DB 로깅도 실패했으면 이게 유일한 흔적)
|
||||
if not log_ok:
|
||||
_fallback_to_file(type_name, msg, tb_str, app_version,
|
||||
f"DB+dialog both failed; dialog err: {dlg_err}")
|
||||
try:
|
||||
print(f"\n[CRASH] {type_name}: {msg}\n{tb_str}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4단계: 원래 hook도 호출 (콘솔 출력 + 종료)
|
||||
try:
|
||||
original(exc_type, exc_value, exc_tb)
|
||||
except Exception:
|
||||
pass # 마지막 단계라 더 할 게 없음
|
||||
|
||||
sys.excepthook = hook
|
||||
|
||||
|
||||
def _fallback_to_file(exc_type: str, message: str, tb: str,
|
||||
version: str, reason: str) -> None:
|
||||
"""DB 로깅이 실패한 경우 ~/.clockout_logs/crashes.log에 append.
|
||||
|
||||
Best-effort — 파일 쓰기 실패도 silent (이 시점엔 뭐 할 게 없음).
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
log_dir = Path.home() / '.clockout_logs'
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / 'crashes.log'
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(
|
||||
f"\n=== {datetime.now().isoformat(timespec='seconds')} ===\n"
|
||||
f"version={version} reason={reason}\n"
|
||||
f"{exc_type}: {message}\n{tb}\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||
"""crash_log 테이블에 기록."""
|
||||
try:
|
||||
conn = db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
# 테이블 자동 생성 (마이그레이션 누락 대비)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS crash_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
exception_type TEXT,
|
||||
message TEXT,
|
||||
traceback TEXT,
|
||||
app_version TEXT,
|
||||
reported BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO crash_log (exception_type, message, traceback, app_version)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (exc_type, message, tb, version))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _show_dialog(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||
"""크래시 알림 + Gitea 보고/복사 버튼."""
|
||||
try:
|
||||
from PyQt5.QtWidgets import (QApplication, QMessageBox, QDialog,
|
||||
QVBoxLayout, QLabel, QTextEdit,
|
||||
QHBoxLayout, QPushButton, QLineEdit)
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return
|
||||
|
||||
dlg = QDialog()
|
||||
dlg.setWindowTitle("⚠️ 오류 발생")
|
||||
dlg.setMinimumSize(560, 420)
|
||||
layout = QVBoxLayout()
|
||||
|
||||
title = QLabel(f"<b>{exc_type}</b>: {message[:200]}")
|
||||
title.setWordWrap(True)
|
||||
layout.addWidget(title)
|
||||
|
||||
layout.addWidget(QLabel("무엇을 하다가 발생했나요? (선택)"))
|
||||
user_note = QLineEdit()
|
||||
user_note.setPlaceholderText("예: 출근 버튼 누른 직후")
|
||||
layout.addWidget(user_note)
|
||||
|
||||
layout.addWidget(QLabel("기술 정보 (자동 보고에 포함):"))
|
||||
tb_view = QTextEdit()
|
||||
tb_view.setReadOnly(True)
|
||||
tb_view.setFont(__import__('PyQt5.QtGui', fromlist=['QFont']).QFont('Consolas', 9))
|
||||
tb_view.setText(tb[-3000:]) # 너무 긴 traceback 제한
|
||||
layout.addWidget(tb_view, 1)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
cancel_btn = QPushButton("닫기")
|
||||
copy_btn = QPushButton("📋 복사")
|
||||
report_btn = QPushButton("📤 Gitea에 보고")
|
||||
|
||||
has_token = bool(db.get_setting('gitea_feedback_token', '') or '')
|
||||
enabled = db.get_setting('gitea_feedback_enabled', 'false').lower() == 'true'
|
||||
if not (has_token and enabled):
|
||||
report_btn.setEnabled(False)
|
||||
report_btn.setToolTip("설정 → 데이터 관리 → Gitea 피드백 토큰 입력 후 활성화 필요")
|
||||
|
||||
def do_copy():
|
||||
clipboard = QApplication.clipboard()
|
||||
text = (
|
||||
f"## {exc_type}\n\n{message}\n\n"
|
||||
f"**Version**: {version}\n**Note**: {user_note.text()}\n\n"
|
||||
f"```\n{tb}\n```"
|
||||
)
|
||||
clipboard.setText(text)
|
||||
copy_btn.setText("✓ 복사됨")
|
||||
|
||||
def do_report():
|
||||
token = db.get_setting('gitea_feedback_token', '') or ''
|
||||
if not token:
|
||||
QMessageBox.warning(dlg, "토큰 없음", "Gitea PAT를 설정에서 먼저 입력하세요.")
|
||||
return
|
||||
title_str = f"[Auto] {exc_type}: {message[:80]}"
|
||||
body = (
|
||||
f"**Version**: `{version}`\n"
|
||||
f"**Time**: {datetime.now().isoformat(timespec='seconds')}\n"
|
||||
f"**User note**: {user_note.text() or '(none)'}\n\n"
|
||||
f"### Traceback\n```\n{tb[-3000:]}\n```"
|
||||
)
|
||||
ok = _send_to_gitea(token, title_str, body)
|
||||
if ok:
|
||||
QMessageBox.information(dlg, "보고 완료", "Gitea Issues에 등록되었습니다.")
|
||||
report_btn.setText("✓ 보고됨")
|
||||
report_btn.setEnabled(False)
|
||||
else:
|
||||
QMessageBox.warning(dlg, "보고 실패", "네트워크 또는 토큰 권한 문제일 수 있습니다.")
|
||||
|
||||
cancel_btn.clicked.connect(dlg.reject)
|
||||
copy_btn.clicked.connect(do_copy)
|
||||
report_btn.clicked.connect(do_report)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
btn_row.addStretch()
|
||||
btn_row.addWidget(copy_btn)
|
||||
btn_row.addWidget(report_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
dlg.setLayout(layout)
|
||||
dlg.exec_()
|
||||
|
||||
|
||||
def _send_to_gitea(token: str, title: str, body: str) -> bool:
|
||||
"""Gitea Issues API로 issue 생성."""
|
||||
payload = json.dumps({'title': title, 'body': body}).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
f"{GITEA_API}/issues",
|
||||
data=payload,
|
||||
headers={
|
||||
'Authorization': f'token {token}',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': USER_AGENT,
|
||||
},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, TimeoutError):
|
||||
return False
|
||||
@ -11,15 +11,12 @@ class CSVExporter:
|
||||
"""CSV 내보내기 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def export_work_records(records: List[Dict], filename: str = None,
|
||||
db=None) -> str:
|
||||
def export_work_records(records: List[Dict], filename: str = None) -> str:
|
||||
"""
|
||||
근무 기록을 CSV로 내보내기 (사람이 읽는 한글 헤더 + 사용/미사용 표기).
|
||||
|
||||
근무 기록을 CSV로 내보내기
|
||||
Args:
|
||||
records: 근무 기록 리스트
|
||||
filename: 저장할 파일명 (None이면 자동 생성)
|
||||
db: 점심/저녁 기본 분(minutes)을 표시용으로 읽을 Database 인스턴스 (옵션)
|
||||
Returns:
|
||||
str: 저장된 파일 경로
|
||||
"""
|
||||
@ -27,70 +24,31 @@ class CSVExporter:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"work_records_{timestamp}.csv"
|
||||
|
||||
# 표시용 기본 분 (옵션)
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
|
||||
|
||||
# CSV 파일 작성
|
||||
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||
fieldnames = [
|
||||
'날짜', '출근시간', '퇴근시간',
|
||||
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
|
||||
'총근무시간', '연장근무(분)', '적립(분)', '메모',
|
||||
'날짜', '출근시간', '퇴근시간', '점심시간',
|
||||
'총근무시간', '연장근무(분)', '적립(분)', '메모'
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for record in records:
|
||||
lunch_on = bool(record.get('lunch_break'))
|
||||
dinner_on = bool(record.get('dinner_break'))
|
||||
row = {
|
||||
'날짜': record.get('date', ''),
|
||||
'출근시간': record.get('clock_in', ''),
|
||||
'퇴근시간': record.get('clock_out', '미기록'),
|
||||
'점심시간': '사용' if lunch_on else '미사용',
|
||||
'점심(분)': lunch_default if lunch_on else 0,
|
||||
'저녁시간': '사용' if dinner_on else '미사용',
|
||||
'저녁(분)': dinner_default if dinner_on else 0,
|
||||
'점심시간': '사용' if record.get('lunch_break') else '미사용',
|
||||
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
|
||||
'연장근무(분)': record.get('overtime_minutes', 0),
|
||||
'적립(분)': record.get('overtime_earned', 0),
|
||||
'메모': record.get('memo', ''),
|
||||
'메모': record.get('memo', '')
|
||||
}
|
||||
writer.writerow(row)
|
||||
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def export_work_records_for_reimport(records: List[Dict], filename: str = None,
|
||||
db=None) -> str:
|
||||
"""csv_importer가 직접 다시 읽을 수 있는 round-trip 포맷으로 내보내기.
|
||||
|
||||
헤더: date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"work_records_reimport_{timestamp}.csv"
|
||||
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
|
||||
|
||||
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||
fieldnames = ['date', 'clock_in', 'clock_out',
|
||||
'lunch_minutes', 'dinner_minutes', 'memo']
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for record in records:
|
||||
writer.writerow({
|
||||
'date': record.get('date', ''),
|
||||
'clock_in': record.get('clock_in', ''),
|
||||
'clock_out': record.get('clock_out', '') or '',
|
||||
'lunch_minutes': lunch_default if record.get('lunch_break') else 0,
|
||||
'dinner_minutes': dinner_default if record.get('dinner_break') else 0,
|
||||
'memo': record.get('memo', '') or '',
|
||||
})
|
||||
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def export_overtime_summary(db, filename: str = None) -> str:
|
||||
"""
|
||||
|
||||
@ -1,183 +0,0 @@
|
||||
"""
|
||||
CSV 가져오기 — 우리 표준 포맷.
|
||||
|
||||
표준 포맷 (v2.7.1+):
|
||||
date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||
2026-04-01,09:00:00,18:30:00,60,0,"메모"
|
||||
|
||||
- 헤더 첫 줄 필수
|
||||
- date: YYYY-MM-DD
|
||||
- clock_in/out: HH:MM:SS 또는 HH:MM (clock_out이 clock_in보다 빠르면 익일로 간주)
|
||||
- lunch_minutes: 정수 (0이면 점심 미포함)
|
||||
- dinner_minutes: 정수 (옵션, 0/누락이면 저녁 미포함)
|
||||
- memo: 선택 (따옴표 가능)
|
||||
|
||||
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Iterator, Tuple
|
||||
|
||||
|
||||
def parse_csv(path: str) -> List[Dict]:
|
||||
"""CSV 파일을 dict 리스트로 파싱. 검증 실패 시 ValueError."""
|
||||
rows = []
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"파일 없음: {path}")
|
||||
|
||||
with open(p, 'r', encoding='utf-8-sig', newline='') as f:
|
||||
reader = csv.DictReader(f)
|
||||
required = {'date', 'clock_in'}
|
||||
if not required.issubset(reader.fieldnames or []):
|
||||
raise ValueError(
|
||||
f"헤더에 필수 필드 누락: {required - set(reader.fieldnames or [])}\n"
|
||||
f"필수 헤더: date,clock_in,clock_out,lunch_minutes,memo"
|
||||
)
|
||||
|
||||
for i, row in enumerate(reader, start=2): # 데이터 시작 줄 번호 (1=헤더)
|
||||
try:
|
||||
clean = _normalize_row(row)
|
||||
rows.append(clean)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"줄 {i}: {e}")
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_minutes(raw: str, field_name: str) -> int:
|
||||
"""0 이상 정수 파싱. 빈 값이면 0."""
|
||||
s = (raw or '').strip()
|
||||
if not s:
|
||||
return 0
|
||||
try:
|
||||
v = int(s)
|
||||
if v < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"{field_name}는 0 이상 정수여야 함: '{raw}'")
|
||||
return v
|
||||
|
||||
|
||||
def _normalize_row(row: Dict) -> Dict:
|
||||
"""단일 행 검증 + 정규화."""
|
||||
date_str = (row.get('date') or '').strip()
|
||||
if not date_str:
|
||||
raise ValueError("date 비어있음")
|
||||
try:
|
||||
datetime.strptime(date_str, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise ValueError(f"date 형식 오류: '{date_str}' (YYYY-MM-DD 필요)")
|
||||
|
||||
ci = _normalize_time(row.get('clock_in', '').strip(), 'clock_in')
|
||||
co_raw = (row.get('clock_out') or '').strip()
|
||||
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
|
||||
|
||||
lunch = _parse_minutes(row.get('lunch_minutes', ''), 'lunch_minutes')
|
||||
dinner = _parse_minutes(row.get('dinner_minutes', ''), 'dinner_minutes')
|
||||
|
||||
memo = (row.get('memo') or '').strip()
|
||||
return {
|
||||
'date': date_str,
|
||||
'clock_in': ci,
|
||||
'clock_out': co,
|
||||
'lunch_minutes': lunch,
|
||||
'dinner_minutes': dinner,
|
||||
'memo': memo,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_time(s: str, field_name: str) -> str:
|
||||
"""'HH:MM' 또는 'HH:MM:SS' → 'HH:MM:SS'."""
|
||||
if not s:
|
||||
raise ValueError(f"{field_name} 비어있음")
|
||||
parts = s.split(':')
|
||||
if len(parts) == 2:
|
||||
s = f"{s}:00"
|
||||
elif len(parts) != 3:
|
||||
raise ValueError(f"{field_name} 형식 오류: '{s}' (HH:MM[:SS] 필요)")
|
||||
try:
|
||||
datetime.strptime(s, '%H:%M:%S')
|
||||
except ValueError:
|
||||
raise ValueError(f"{field_name} 시간 파싱 실패: '{s}'")
|
||||
return s
|
||||
|
||||
|
||||
def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int, int, int]:
|
||||
"""파싱된 rows를 DB에 일괄 입력.
|
||||
|
||||
Args:
|
||||
db: Database 인스턴스
|
||||
rows: parse_csv 결과
|
||||
on_conflict: 'skip' | 'overwrite'
|
||||
|
||||
Returns:
|
||||
(added, updated, skipped) 수
|
||||
"""
|
||||
if on_conflict not in ('skip', 'overwrite'):
|
||||
raise ValueError("on_conflict는 'skip' 또는 'overwrite'")
|
||||
|
||||
added = updated = skipped = 0
|
||||
|
||||
from datetime import timedelta
|
||||
from core.time_calculator import TimeCalculator
|
||||
work_minutes = db.get_work_minutes()
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60)
|
||||
|
||||
for row in rows:
|
||||
existing = db.get_work_record(row['date'])
|
||||
|
||||
if existing and on_conflict == 'skip':
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if existing and on_conflict == 'overwrite':
|
||||
# 기존 레코드 삭제 후 재추가 (단순화)
|
||||
conn = db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM overtime_usage WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
updated += 1
|
||||
else:
|
||||
added += 1
|
||||
|
||||
wid = db.add_work_record(row['date'], row['clock_in'], is_manual=True)
|
||||
if row.get('clock_out'):
|
||||
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||
# 자정 경계: 퇴근이 출근보다 빠르면 익일로 간주
|
||||
if co_dt <= ci_dt:
|
||||
co_dt += timedelta(days=1)
|
||||
lunch_min = row.get('lunch_minutes') or 0
|
||||
dinner_min = row.get('dinner_minutes') or 0
|
||||
calc = TimeCalculator(
|
||||
work_minutes=work_minutes,
|
||||
lunch_duration_minutes=lunch_min or lunch_default,
|
||||
dinner_duration_minutes=dinner_min or dinner_default,
|
||||
)
|
||||
include_lunch = lunch_min > 0
|
||||
include_dinner = dinner_min > 0
|
||||
total = (co_dt - ci_dt).total_seconds() / 3600
|
||||
ot_actual, ot_earned = calc.calculate_overtime(
|
||||
ci_dt, co_dt, include_lunch=include_lunch, include_dinner=include_dinner,
|
||||
)
|
||||
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
|
||||
if include_lunch:
|
||||
db.update_lunch_break(row['date'], True)
|
||||
if include_dinner:
|
||||
db.update_dinner_break(row['date'], True)
|
||||
if ot_earned > 0:
|
||||
db.add_overtime_earned(wid, ot_earned, row['date'])
|
||||
|
||||
return added, updated, skipped
|
||||
@ -5,7 +5,6 @@ URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
@ -13,9 +12,7 @@ from typing import Optional, List
|
||||
|
||||
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
||||
# 일반 브라우저 UA로 위장해야 통과.
|
||||
# 버전은 core/version.py에서 동기화.
|
||||
from core.version import __version__
|
||||
USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/{__version__}'
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
|
||||
|
||||
# Discord embed 색상 (decimal)
|
||||
COLOR_GREEN = 0x57F287
|
||||
@ -24,23 +21,6 @@ COLOR_YELLOW = 0xFEE75C
|
||||
COLOR_PINK = 0xEB459E
|
||||
COLOR_ORANGE = 0xED4245
|
||||
|
||||
# Discord 웹훅 URL 패턴: https://(discord.com|discordapp.com|...)/api/webhooks/{ID:digits}/{TOKEN}
|
||||
# canary./ptb. 서브도메인도 허용. PTB 모바일 앱이 종종 그 URL 발급.
|
||||
_WEBHOOK_RE = re.compile(
|
||||
r'^https://(?:(?:canary|ptb)\.)?discord(?:app)?\.com/api/webhooks/\d{17,20}/[\w-]{50,}$'
|
||||
)
|
||||
|
||||
|
||||
def is_valid_webhook_url(url: str) -> bool:
|
||||
"""입력된 URL이 Discord webhook 형식인지 검증.
|
||||
|
||||
형식만 확인 — 실제 도달성·토큰 유효성은 send_test()로 검증해야 함.
|
||||
빈 문자열이나 공백은 False (비활성 상태로 간주).
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
return bool(_WEBHOOK_RE.match(url.strip()))
|
||||
|
||||
|
||||
def send(webhook_url: str, title: str, description: str,
|
||||
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
|
||||
@ -57,7 +37,7 @@ def send(webhook_url: str, title: str, description: str,
|
||||
Returns:
|
||||
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
|
||||
"""
|
||||
if not is_valid_webhook_url(webhook_url):
|
||||
if not webhook_url or not webhook_url.startswith('https://'):
|
||||
return False
|
||||
|
||||
payload = {
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
"""번들 폰트(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))
|
||||
@ -1,100 +0,0 @@
|
||||
"""
|
||||
공공데이터포털 — 한국천문연구원 특일정보 OpenAPI 클라이언트.
|
||||
|
||||
엔드포인트: getRestDeInfo (국경일/공휴일 — 임시공휴일 포함)
|
||||
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
|
||||
|
||||
`holidays` 패키지가 누락하는 임시공휴일·근로자의 날 등을
|
||||
정부 공인 데이터로 보강하기 위해 사용.
|
||||
|
||||
설계:
|
||||
- 네트워크 실패는 silent (None 반환) — 호출자가 fallback 처리
|
||||
- API 키는 코드 내 박혀있으나 dev 본인 계정의 특일정보 API 한정 키
|
||||
(50명 이내 사용 환경에서 일일 한도 1000회 충분)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
# 공공데이터포털 특일정보 API 서비스 키.
|
||||
# 소스코드/바이너리 노출 방지를 위해 환경변수에서 읽습니다.
|
||||
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||
_SERVICE_KEY = os.environ.get('CLOCKOUT_HOLIDAY_API_KEY', '')
|
||||
|
||||
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
||||
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
||||
|
||||
|
||||
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
|
||||
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
|
||||
|
||||
Returns:
|
||||
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
|
||||
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 등)
|
||||
"""
|
||||
params = {
|
||||
'serviceKey': _SERVICE_KEY,
|
||||
'solYear': str(year),
|
||||
'_type': 'json',
|
||||
'numOfRows': '100',
|
||||
'pageNo': '1',
|
||||
}
|
||||
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except (urllib.error.URLError, urllib.error.HTTPError,
|
||||
json.JSONDecodeError, OSError, TimeoutError):
|
||||
return None
|
||||
return _parse_response(data)
|
||||
|
||||
|
||||
def _parse_response(data: Dict) -> Optional[List[Dict]]:
|
||||
"""API 응답 JSON을 표준 형식으로 정규화.
|
||||
|
||||
API 응답 패턴:
|
||||
- resultCode == '00' → 정상
|
||||
- items.item: 단일 결과면 dict, 여러 개면 list
|
||||
- items가 빈 문자열일 때 (totalCount=0)도 정상으로 간주
|
||||
"""
|
||||
try:
|
||||
response = data.get('response') or {}
|
||||
header = response.get('header') or {}
|
||||
if header.get('resultCode') != '00':
|
||||
return None
|
||||
body = response.get('body') or {}
|
||||
items_root = body.get('items')
|
||||
if not items_root:
|
||||
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
|
||||
item = items_root.get('item') if isinstance(items_root, dict) else None
|
||||
if item is None:
|
||||
return []
|
||||
if isinstance(item, dict):
|
||||
item = [item]
|
||||
out = []
|
||||
for entry in item:
|
||||
locdate = entry.get('locdate')
|
||||
name = entry.get('dateName')
|
||||
is_holiday = (entry.get('isHoliday') == 'Y')
|
||||
if not locdate or not name:
|
||||
continue
|
||||
# locdate: 20260501 (int 또는 str)
|
||||
ds = str(locdate)
|
||||
if len(ds) != 8 or not ds.isdigit():
|
||||
continue
|
||||
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
|
||||
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
|
||||
return out
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
|
||||
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10
|
||||
@ -57,75 +57,58 @@ class SystemTrayIcon(QSystemTrayIcon):
|
||||
return QIcon(pixmap)
|
||||
|
||||
def setup_menu(self):
|
||||
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
|
||||
"""트레이 메뉴 설정"""
|
||||
menu = QMenu()
|
||||
|
||||
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
|
||||
self._icon_actions = []
|
||||
show_action = QAction(tr('tray.open'), self)
|
||||
show_action.triggered.connect(self.show_window)
|
||||
menu.addAction(show_action)
|
||||
|
||||
def add(text, slot, icon_name=None):
|
||||
action = QAction(text, self)
|
||||
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')
|
||||
mini_action = QAction(tr('tray.mini_widget'), self)
|
||||
mini_action.triggered.connect(self._open_mini_widget)
|
||||
menu.addAction(mini_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
|
||||
add(tr('btn.break_out'), self._break_out)
|
||||
add(tr('btn.break_in'), self._break_in)
|
||||
lunch_action = QAction(tr('tray.toggle_lunch'), self)
|
||||
lunch_action.triggered.connect(self._toggle_lunch)
|
||||
menu.addAction(lunch_action)
|
||||
|
||||
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()
|
||||
|
||||
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
|
||||
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
|
||||
clock_out_action.triggered.connect(self.quick_clock_out)
|
||||
menu.addAction(clock_out_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
|
||||
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
|
||||
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
|
||||
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
|
||||
stats_action = QAction("📊 " + tr('menu.stats'), self)
|
||||
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
|
||||
menu.addAction(stats_action)
|
||||
|
||||
cal_action = QAction("📅 " + tr('menu.calendar'), self)
|
||||
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
||||
menu.addAction(cal_action)
|
||||
|
||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
||||
menu.addAction(help_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
add(tr('tray.quit'), self.quit_app)
|
||||
quit_action = QAction(tr('tray.quit'), self)
|
||||
quit_action.triggered.connect(self.quit_app)
|
||||
menu.addAction(quit_action)
|
||||
|
||||
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):
|
||||
if self.parent_window and hasattr(self.parent_window, method_name):
|
||||
|
||||
@ -188,15 +188,8 @@ def apply_update(new_exe: Path) -> bool:
|
||||
|
||||
pid = os.getpid()
|
||||
try:
|
||||
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
|
||||
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
|
||||
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
|
||||
if sys.platform == 'win32':
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
|
||||
else:
|
||||
creationflags = 0
|
||||
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
|
||||
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
|
||||
subprocess.Popen(
|
||||
[str(updater_exe),
|
||||
'--pid', str(pid),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user