Compare commits
No commits in common. "main" and "v2.9.0" have entirely different histories.
610
AGENTS.md
610
AGENTS.md
@ -1,476 +1,176 @@
|
||||
# Clock-out Time Calculator — Agent Guide
|
||||
# Project Conventions and Operational Gotchas
|
||||
|
||||
> Last verified against the working tree at version **2.11.2** (`core/version.py`).
|
||||
> 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` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
|
||||
- **Run:** `python main.py`
|
||||
- **Module-level smoke:**
|
||||
- Event monitoring: `python core/event_monitor.py`
|
||||
- Time calculation: `python core/time_calculator.py`
|
||||
- **Integration tests** (all should be green before release):
|
||||
- `python _integration_test.py` — business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+)
|
||||
- `python _i18n_gui_test.py` — ko/en switch on real widgets
|
||||
- `python _gui_smoke_test.py` — widget instantiation
|
||||
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
|
||||
|
||||
---
|
||||
## 🗄️ Architecture Notes (Core Business Logic)
|
||||
|
||||
## 1. Project Overview
|
||||
### Database (10+ tables in `database.db`)
|
||||
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
|
||||
|
||||
**Clock-out Time Calculator** (Korean: 퇴근시간 계산기) is a Windows desktop productivity application written in Python with PyQt5. It tracks a user's workday, automatically detects clock-in time from Windows Event Log / boot time, counts down to the expected clock-out time in real time, banks overtime in configurable units, manages annual leave, and provides statistics, notifications, Discord integration, and automatic self-updates.
|
||||
### Invariants
|
||||
- **`work_records.date` UNIQUE** — one row per workday.
|
||||
- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
|
||||
- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
|
||||
- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox).
|
||||
|
||||
- **Primary language:** Python 3.9+
|
||||
- **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`
|
||||
### Settings system
|
||||
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
||||
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
||||
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
||||
|
||||
The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
|
||||
### i18n
|
||||
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
|
||||
- Sentence formatting via Python `str.format(**kwargs)`.
|
||||
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
|
||||
|
||||
---
|
||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||
|
||||
## 2. Technology Stack
|
||||
### 1. Time-off subtraction order in `update_display()`
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| 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 |
|
||||
### 2. Hot-path caching
|
||||
`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`.
|
||||
|
||||
Dependencies are declared in `requirements.txt`:
|
||||
### 3. Time format separation
|
||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
||||
|
||||
```text
|
||||
PyQt5>=5.15.0
|
||||
pywin32>=305
|
||||
python-dateutil>=2.8.0
|
||||
matplotlib>=3.4.0
|
||||
plyer>=2.0.0
|
||||
holidays>=0.40
|
||||
```
|
||||
### 4. Workday boundary
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic.
|
||||
|
||||
Install with:
|
||||
### 5. Migration idempotency
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.
|
||||
|
||||
### 6. Single-file deployment
|
||||
`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build.
|
||||
|
||||
### 7. Updater handoff
|
||||
`updater.py` is standalone (no PyQt). Args: `--pid <main_pid> --new <new_main.exe> --target <current_main.exe>`. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater.
|
||||
|
||||
## 🧩 Module Map
|
||||
|
||||
### `core/`
|
||||
- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`).
|
||||
- `time_calculator.py` — `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit).
|
||||
- `event_monitor.py` — Win Event IDs 6005/4624/6006.
|
||||
- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable.
|
||||
- `i18n.py` — `_DICT` ko/en + `_HELP_HTML` (6 tabs).
|
||||
- `salary.py` — `estimate_pay(records, hourly_wage, overtime_rate=1.5)`.
|
||||
- `settings_keys.py` — All setting keys as constants.
|
||||
- `version.py` — `__version__` single source of truth.
|
||||
|
||||
### `ui/`
|
||||
- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts.
|
||||
- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
|
||||
- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
|
||||
- `mini_widget.py` — always-on-top frameless.
|
||||
- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button.
|
||||
- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel).
|
||||
- `today_summary.py` — post-clockout card.
|
||||
- `goal_widget.py` — monthly progress bars (overtime cap, daily avg).
|
||||
- `meal_time_dialog.py` — lunch/dinner real start-end input.
|
||||
- `past_record_dialog.py` — calendar right-click "add past record".
|
||||
- `leave_calendar_view.py` — color-coded leave (green/yellow/purple).
|
||||
- `accessibility.py` — `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`.
|
||||
- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
|
||||
|
||||
### `ui/controllers/`
|
||||
- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
|
||||
- `auto_lunch.py` — toggles lunch after 4 hours since clock-in.
|
||||
- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays.
|
||||
|
||||
### `utils/`
|
||||
- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API.
|
||||
- `lock_detector.py` — `OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
|
||||
- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally.
|
||||
- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`.
|
||||
- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
|
||||
- `csv_exporter.py` — same format.
|
||||
- `crash_handler.py` — `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report.
|
||||
- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`.
|
||||
- `system_tray.py` — tray menu with i18n labels.
|
||||
- `time_format.py` — `format_hours_minutes(minutes)`.
|
||||
- `debug_log.py` — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`.
|
||||
- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver.
|
||||
|
||||
### Top-level
|
||||
- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow.
|
||||
- `updater.py` — Standalone PID wait + file replace + relaunch.
|
||||
- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`.
|
||||
- `updater.spec` — Standalone updater build.
|
||||
- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.
|
||||
|
||||
## ⚙️ Build Process
|
||||
|
||||
```bash
|
||||
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
|
||||
# Manual two-step
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||
python -m PyInstaller --clean main.spec
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
||||
|
||||
# Or one-shot
|
||||
.\release.ps1 v2.7.0
|
||||
```
|
||||
|
||||
- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
|
||||
- `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.
|
||||
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
||||
- `holidays` package only baked in if installed in build env.
|
||||
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
||||
|
||||
### Production build (one-shot)
|
||||
## 🚦 External Integrations
|
||||
|
||||
```bash
|
||||
.\release.ps1 v2.11.2
|
||||
- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<version>`. Repo must be public.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
|
||||
- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only.
|
||||
- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
|
||||
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
|
||||
## 🐞 Past Incidents (do NOT re-introduce)
|
||||
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
|
||||
- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`.
|
||||
- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor).
|
||||
- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
|
||||
- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
|
||||
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
|
||||
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`.
|
||||
- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
|
||||
- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
|
||||
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
|
||||
|
||||
## 🌐 i18n Coverage Status
|
||||
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
|
||||
- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
|
||||
- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).
|
||||
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`.
|
||||
|
||||
## 🚢 Release Flow ([release.ps1](release.ps1))
|
||||
|
||||
```
|
||||
0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
|
||||
1. Bump core/version.py
|
||||
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
|
||||
3. PyInstaller (updater.spec → staging copy → main.spec)
|
||||
4. ZIP packaging (main.exe + updater.exe)
|
||||
5. Git commit (version.py + CHANGELOG.md) + tag + push
|
||||
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
|
||||
7. Asset upload (main.exe, updater.exe, ZIP)
|
||||
```
|
||||
|
||||
The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`.
|
||||
|
||||
### Build gotchas
|
||||
|
||||
- Kill any running `main.exe` before building or PyInstaller will fail with `PermissionError`.
|
||||
- The `holidays` package is only baked into the EXE if it is installed in the build environment.
|
||||
- Optional code signing is supported via `$env:CODE_SIGN_CERT` (.pfx path) and `$env:CODE_SIGN_PASS`.
|
||||
- `main.spec` lists several `hiddenimports` that are easy to break: `holidays`, `holidays.countries.south_korea`, `win32evtlog`, `win32evtlogutil`, `matplotlib.backends.backend_qtagg`, `matplotlib.backends.backend_qt5agg`, `PyQt5.QtSvg`, `PyQt5.sip`, and `numpy.core._multiarray_tests`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Strategy
|
||||
|
||||
The project uses three layers of tests.
|
||||
|
||||
### 5.1 Unit tests (pytest)
|
||||
|
||||
Configuration: `pytest.ini`
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
There are **13 test files** under `tests/`:
|
||||
|
||||
- `test_time_calculator.py` — clock-out, overtime truncation, holiday overtime, day-type detection
|
||||
- `test_database.py` — settings, migrations, leave calculations, consecutive OT days
|
||||
- `test_csv_importer.py` — CSV parsing, validation, conflict handling
|
||||
- `test_salary.py` — pay estimation and won formatting
|
||||
- `test_recurring_leaves.py` — pattern parsing and expansion
|
||||
- `test_crash_handler.py` — crash log insertion and Gitea reporting (mocked)
|
||||
- `test_discord_webhook.py` — URL validation, payload shape, network errors
|
||||
- `test_holiday_api.py` — API response parsing and error handling
|
||||
- `test_i18n.py` — language switching, missing-key fallback, interpolation
|
||||
- `test_i18n_runtime.py` — runtime retranslation of Qt widgets
|
||||
- `test_overtime_accrual_guard.py` — auto-overtime rollover guard
|
||||
- `test_updater.py` — version parsing, Gitea API URL, update logic
|
||||
|
||||
`tests/conftest.py` sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` so the background holiday-sync thread does not hold open temporary DB files during test cleanup.
|
||||
|
||||
**Current status:** `194 passed`.
|
||||
|
||||
### 5.2 Integration scenarios
|
||||
|
||||
```bash
|
||||
python _integration_test.py
|
||||
```
|
||||
|
||||
A standalone script with a custom `@case` decorator. It runs **53 scenarios** (S1–S52, S52A–E) covering fresh-install migrations, work-pattern calculations, overtime banking, leave, weekends/holidays, notifications, backup, settings sync, i18n, CSV import/export, salary, crash log, updater semver, Discord guards, goals, and accessibility keys.
|
||||
|
||||
**Current status:** `PASS: 53 FAIL: 0 WARN: 0`.
|
||||
|
||||
### 5.3 GUI smoke tests
|
||||
|
||||
```bash
|
||||
python _i18n_gui_test.py # Korean/English label switching
|
||||
python _gui_smoke_test.py # Widget instantiation
|
||||
```
|
||||
|
||||
Both use `QT_QPA_PLATFORM=offscreen` so no real windows appear.
|
||||
|
||||
- `_i18n_gui_test.py`: 5 cases, **all passing**.
|
||||
- `_gui_smoke_test.py`: 8 cases, **all passing**.
|
||||
|
||||
### 5.4 Pre-release test command summary
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -q
|
||||
python _integration_test.py
|
||||
python _i18n_gui_test.py
|
||||
python _gui_smoke_test.py
|
||||
```
|
||||
|
||||
`release.ps1` runs the first two by default (skippable with `-SkipTests`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Style and Conventions
|
||||
|
||||
### Identifiers
|
||||
|
||||
- Classes: `PascalCase`
|
||||
- Functions / variables: `snake_case`
|
||||
- Module-level constants (especially setting keys): `UPPER_CASE`
|
||||
|
||||
### Settings keys
|
||||
|
||||
All setting keys are defined as constants in `core/settings_keys.py`. Import and use the constants; **never use raw strings** for new logic. When adding a new key, also add a default value in `Database.init_default_settings()`.
|
||||
|
||||
### Imports
|
||||
|
||||
Order is typically: standard library → third-party → project modules. Several newer modules use `from __future__ import annotations`.
|
||||
|
||||
### Comments and docstrings
|
||||
|
||||
Code identifiers are English, but inline comments and docstrings are predominantly Korean. New code should follow the existing bilingual style: English identifiers, Korean explanatory comments.
|
||||
|
||||
### UI construction
|
||||
|
||||
- Build widgets in `init_ui()`.
|
||||
- Use helper methods such as `create_*_group()` for readability.
|
||||
- Set `objectName` for QSS styling.
|
||||
- Always verify `self.setLayout(main_layout)` is at the **end** of `init_ui()`, not accidentally indented into a method body.
|
||||
|
||||
### Type hints
|
||||
|
||||
Use type hints on public DB and helper methods (`-> int`, `-> Optional[Dict]`, `-> List[Dict]`, etc.).
|
||||
|
||||
### String formatting
|
||||
|
||||
Use f-strings for internal messages. For user-visible text, use the i18n API:
|
||||
|
||||
```python
|
||||
from core.i18n import tr
|
||||
tr('key', name=value)
|
||||
```
|
||||
|
||||
### No enforced linter
|
||||
|
||||
There is no `pyproject.toml`, `.flake8`, or `setup.cfg`. Formatting is informal; keep line lengths reasonable and match the surrounding style.
|
||||
|
||||
---
|
||||
|
||||
## 7. Critical Invariants (MUST PRESERVE)
|
||||
|
||||
### 7.1 Time-off subtraction order in `MainWindow.update_display()`
|
||||
|
||||
Pass the actual `break_minutes` to `TimeCalculator.calculate_remaining_time()`, then subtract `total_time_off = overtime_used + leave_used` from the resulting `timedelta` **after** the call. **Never** mutate `break_minutes` to `break_minutes - overtime_used` before calling. Doing so previously caused a "+29h remaining time" bug.
|
||||
|
||||
```python
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
overtime_used_today = self.db.get_today_overtime_usage()
|
||||
leave_used_today = self.db.get_today_leave_minutes()
|
||||
total_time_off = overtime_used_today + leave_used_today
|
||||
remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_minutes)
|
||||
remaining -= timedelta(minutes=total_time_off)
|
||||
```
|
||||
|
||||
### 7.2 Hot-path caching
|
||||
|
||||
`update_display()` runs at **1 Hz**. DB reads inside it must be cached or throttled:
|
||||
|
||||
- `cached_time_format` in `MainWindow`
|
||||
- `AutoLunchManager` caches enabled/non-working state
|
||||
- `NotificationOrchestrator` gates periodic checks to 5-minute buckets
|
||||
- Use `_set_text_if_changed()` to avoid useless repaints
|
||||
|
||||
### 7.3 24-hour internal time
|
||||
|
||||
All calculation uses 24-hour `datetime`. 12-hour conversion (Korean "오전"/"오후") happens only in `MainWindow.format_time()`.
|
||||
|
||||
### 7.4 Workday boundary
|
||||
|
||||
`workday_boundary_hour` defaults to 6. Overnight work stays on the previous day's record until that hour. Do not naively use `date.today()` in time logic.
|
||||
|
||||
### 7.5 Database invariants
|
||||
|
||||
- `work_records.date` is `UNIQUE`.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are **NULLable** for manual entries. Never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
||||
- `leave_records.days` is `REAL` (1.0 / 0.5 / 0.25 / hourly).
|
||||
- Canonical time unit is **minutes** (`work_minutes`). `work_hours` is a derived/floor-synced property. Use `int(minutes) // 60` for hours, not `round()`.
|
||||
- Overtime balance = `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` plus any `INITIAL_OVERTIME_MINUTES`.
|
||||
- WAL mode + 5-second busy timeout is enabled for cloud-sync friendliness (OneDrive/Dropbox).
|
||||
|
||||
### 7.6 Migration idempotency
|
||||
|
||||
Every `migrate_*()` method must early-return if already applied. Use sentinel settings or `IF NOT EXISTS`. Examples: `balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`.
|
||||
|
||||
### 7.7 Settings auto-sync pairs
|
||||
|
||||
`Database.save_settings()` automatically keeps these pairs in sync:
|
||||
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor division)
|
||||
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
|
||||
|
||||
### 7.8 Single-file deployment and updater handoff
|
||||
|
||||
- `main.exe` embeds `updater.exe` via `main.spec` data files.
|
||||
- `_ensure_updater_extracted()` in `main.py` extracts the embedded updater on first launch from `sys._MEIPASS`.
|
||||
- Protect the staging copy at `build/staging/updater.exe`.
|
||||
- `updater.py` is **standalone** (no Qt/network deps). It accepts `--pid`, `--new`, and `--target`, waits for the PID, swaps files with `.bak` rollback, and relaunches.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
### 8.1 HTTP API
|
||||
|
||||
`utils/http_api.py` is **not present** in the current tree. If it is reintroduced, it must bind to `127.0.0.1` only and remain read-only. Never expose it externally.
|
||||
|
||||
### 8.2 Discord webhook
|
||||
|
||||
- URL is validated against official Discord webhook domains (`discord.com`, `discordapp.com`, canary/ptb).
|
||||
- A browser User-Agent is mandatory; Cloudflare blocks the default Python UA.
|
||||
- Push failures are silent so they do not break the app.
|
||||
|
||||
### 8.3 Auto-update
|
||||
|
||||
- Update check polls a self-hosted Gitea API: `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`.
|
||||
- Downloads `main_new.exe` next to the running executable; `updater.exe` performs the swap with `.bak` rollback.
|
||||
- Update apply only works in frozen builds; development `.py` runs are notified but not modified.
|
||||
|
||||
### 8.4 DB path handling and cloud sync
|
||||
|
||||
- `main.py` and `MainWindow` both bootstrap with the default DB first, read `DB_PATH_OVERRIDE`, then reopen with the override path. Do not break this order.
|
||||
- WAL mode + busy timeout tolerates OneDrive/Dropbox sync.
|
||||
|
||||
### 8.5 Crash reporting
|
||||
|
||||
- Gitea issue reporting is **opt-in** via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- Tokens are stored as plain strings in the SQLite `settings` table. The UI uses `QLineEdit.Password` echo mode.
|
||||
|
||||
### 8.6 Single instance
|
||||
|
||||
Multiple copies are prevented via `QLocalServer` named `ClockOutCalculatorInstance`.
|
||||
|
||||
### 8.7 Debug logging
|
||||
|
||||
`utils/debug_log.py` only emits output when `CLOCKOUT_DEBUG=1` (or `true`/`yes`).
|
||||
|
||||
---
|
||||
|
||||
## 9. External Integrations
|
||||
|
||||
- **Auto-update** (`utils/updater_client.py`): polls the Gitea Releases API. User-Agent: `ClockOutCalculator-Updater/1.0`.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): optional one-direction push. Browser User-Agent required.
|
||||
- **Gitea Issues** (`utils/crash_handler.py`): optional crash reporting; opt-in only.
|
||||
- **Cloud sync via `DB_PATH_OVERRIDE`**: settings stores the DB path; the app reopens the override path after reading it.
|
||||
- **`holidays` package**: `Database.add_korean_holidays_auto()` returns `-1` if the package is missing, and the UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
- **Korean holiday API** (`utils/holiday_api.py`): 공공데이터포털 특일정보 API 키는 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수에서 읽음. 소스코드/바이너리에 키를 하드코딩하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n Conventions
|
||||
|
||||
- API: `tr(key, **kwargs)` and `tr_html(key)` from `core/i18n.py`.
|
||||
- Translations live in `_DICT['ko']` and `_DICT['en']`. **Both languages must receive any new key.**
|
||||
- Sentence interpolation uses Python `str.format(**kwargs)`.
|
||||
- HelpView HTML content is in `_HELP_HTML`.
|
||||
- Runtime retranslation is supported via `ui/i18n_runtime.py` using a weakref registry. Register widgets with `register(widget, key, setter='setText', kwargs={}, post=None)` and call `set_language_and_retranslate(lang)` to update them.
|
||||
- UI files (`ui/`) and achievement metadata (`core/achievements.py`) are fully key-based.
|
||||
- Remaining P4 internal-data hardcoding (not user-facing labels) includes DB-stored `leave_type` values and Korean holiday names in `core/database.py`.
|
||||
- A language change may still prompt a restart for widgets not registered for runtime retranslate.
|
||||
|
||||
---
|
||||
|
||||
## 11. Release Flow
|
||||
|
||||
`release.ps1 vX.Y.Z` performs the full release locally:
|
||||
|
||||
1. **Pre-checks**: verify `$env:GITEA_TOKEN`, no running `main.exe`, no existing tag, no uncommitted changes (unless `-DryRun`).
|
||||
2. **Bump version**: rewrite `core/version.py`.
|
||||
3. **Tests**: run `pytest tests/` and `python _integration_test.py` (skippable with `-SkipTests`).
|
||||
4. **Build**: `updater.spec` → `build/staging/` → `main.spec`.
|
||||
5. **Code signing** (optional): sign both EXEs if `$env:CODE_SIGN_CERT` is set.
|
||||
6. **ZIP packaging**: `dist/ClockOutCalculator-vX.Y.Z.zip` containing `main.exe` and `updater.exe`.
|
||||
7. **Git commit + tag + push**: commit `core/version.py` and `CHANGELOG.md`.
|
||||
8. **Gitea Release POST**: read `CHANGELOG.md` with UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section.
|
||||
9. **Asset upload**: `main.exe`, `updater.exe`, and the ZIP.
|
||||
|
||||
Use `-DryRun` to preview without git push or API calls.
|
||||
|
||||
---
|
||||
|
||||
## 12. Past Incidents (Do Not Re-introduce)
|
||||
|
||||
| Incident | Root cause / fix |
|
||||
|----------|------------------|
|
||||
| +29h remaining time | Mutating `break_minutes -= overtime_used` before calculation. Fixed by subtracting `total_time_off` after `calculate_remaining_time`. |
|
||||
| Manual overtime invisible | Filtering `work_record_id IS NOT NULL`. Now show all rows and label NULL as "수동 추가" / "Manual". |
|
||||
| `annual_leave_total` vs `annual_leave_days` | Two keys for the same value; auto-synced in `save_settings()`. |
|
||||
| Banker's rounding | `round(450/60) = 8` because Python rounds half to even. Use `int(minutes) // 60`. |
|
||||
| FK enforcement rollback | `PRAGMA foreign_keys=ON` conflicted with existing manual overtime records. Kept WAL + timeout, no FK enforcement. |
|
||||
| Discord 403 / Cloudflare 1010 | Default Python UA blocked. Fixed with browser UA. |
|
||||
| Help dialog blank | `self.setLayout(main_layout)` indented into `_reopen_onboarding`. Verify `setLayout()` is at the end of `init_ui()`. |
|
||||
| `dist/updater.exe` wiped by `--clean` | Solved by staging copy at `build/staging/updater.exe`. |
|
||||
| Onboarding auto-skipped | Existing users with `work_records` were auto-completed. Added "Re-run Onboarding" button to Help. |
|
||||
| PowerShell 5.1 ANSI | `Get-Content`/`Set-Content` default to ANSI and mangle Korean in `CHANGELOG.md` and `core/version.py`. Use `[System.IO.File]::ReadAllText`/`WriteAllText(path, UTF8)`. |
|
||||
| PowerShell NativeCommandError | Use `Invoke-Native` helper with `$ErrorActionPreference = 'Continue'` and explicit `$LASTEXITCODE`. |
|
||||
| Frozen chart numpy failure | Added `numpy.core._multiarray_tests` to `main.spec` hiddenimports. |
|
||||
|
||||
---
|
||||
|
||||
## 13. Additional References
|
||||
|
||||
- `README.md` — end-user feature overview (Korean/English mixed).
|
||||
- `CLAUDE.md` — additional Korean-language guidance for this project.
|
||||
- `INSTALL.md` — installation and troubleshooting instructions.
|
||||
- `CHANGELOG.md` — release notes in Korean.
|
||||
- `resources/resource_links.md` — links to external resources.
|
||||
|
||||
When modifying code, update this `AGENTS.md` if you change any conventions, build steps, module map, or external integrations described here.
|
||||
`--DryRun` previews without git push or API calls.
|
||||
|
||||
120
CHANGELOG.md
120
CHANGELOG.md
@ -4,126 +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 버그 (사용자 보고)
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
- notification_log: 휴식 권고 카운트
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from core.i18n import tr
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Callable, Optional, List, Tuple
|
||||
@ -427,40 +426,40 @@ def _bool_eval(condition_fn):
|
||||
# ---- 1. 출근 streak (24개 — 22번 거북이 제거) ----
|
||||
_STREAK_DEFS = [
|
||||
# (code, name, desc, target, evaluator, tier, icon)
|
||||
('streak_first', tr('achieve.streak_first.name'), tr('achieve.streak_first.desc'), 1,
|
||||
('streak_first', '첫걸음', '첫 출근 기록', 1,
|
||||
_bool_eval(lambda db: _count_work_records(db) >= 1), TIER_BRONZE, '👋'),
|
||||
('streak_3', tr('achieve.streak_3.name'), tr('achieve.streak_3.desc'), 3,
|
||||
('streak_3', '뿌리내림', '3일 연속 영업일 출근', 3,
|
||||
_make_streak_eval(3), TIER_BRONZE, '🌱'),
|
||||
('streak_5', tr('achieve.streak_5.name'), tr('achieve.streak_5.desc'), 5,
|
||||
('streak_5', '첫 주 완주', '5 영업일 연속 출근', 5,
|
||||
_make_streak_eval(5), TIER_SILVER, '📅'),
|
||||
('streak_7_cal', tr('achieve.streak_7_cal.name'), tr('achieve.streak_7_cal.desc'), 7,
|
||||
('streak_7_cal', '7일 연속', '주말 포함 7일 연속 출근', 7,
|
||||
_make_streak_eval(7, business_only=False), TIER_SILVER, '🔥'),
|
||||
('streak_10', tr('achieve.streak_10.name'), tr('achieve.streak_10.desc'), 10,
|
||||
('streak_10', '2주 연속', '10 영업일 연속 출근', 10,
|
||||
_make_streak_eval(10), TIER_SILVER, '💪'),
|
||||
('streak_22', tr('achieve.streak_22.name'), tr('achieve.streak_22.desc'), 22,
|
||||
('streak_22', '한 달 개근', '한 달 영업일 100% 출근 (22일)', 22,
|
||||
_make_streak_eval(22), TIER_GOLD, '🏔️'),
|
||||
('streak_50', tr('achieve.streak_50.name'), tr('achieve.streak_50.desc'), 50,
|
||||
('streak_50', '50일 연속', '50 영업일 연속 출근', 50,
|
||||
_make_streak_eval(50), TIER_GOLD, '🎯'),
|
||||
('streak_100', tr('achieve.streak_100.name'), tr('achieve.streak_100.desc'), 100,
|
||||
('streak_100', '100일 연속', '100 영업일 연속 출근', 100,
|
||||
_make_streak_eval(100), TIER_PLATINUM, '💎'),
|
||||
('streak_quarter', tr('achieve.streak_quarter.name'), tr('achieve.streak_quarter.desc'), 65,
|
||||
('streak_quarter', '분기 완주', '약 65 영업일 (3개월)', 65,
|
||||
_make_streak_eval(65), TIER_PLATINUM, '🏆'),
|
||||
('streak_half_year', tr('achieve.streak_half_year.name'), tr('achieve.streak_half_year.desc'), 130,
|
||||
('streak_half_year', '반년 마라톤', '약 130 영업일 (6개월)', 130,
|
||||
_make_streak_eval(130), TIER_PLATINUM, '👑'),
|
||||
('streak_year', tr('achieve.streak_year.name'), tr('achieve.streak_year.desc'), 260,
|
||||
('streak_year', '1년 풀 시즌', '약 260 영업일 (1년)', 260,
|
||||
_make_streak_eval(260), TIER_LEGEND, '🌟'),
|
||||
('streak_200', tr('achieve.streak_200.name'), tr('achieve.streak_200.desc'), 200,
|
||||
('streak_200', '사이언스', '200 영업일 연속', 200,
|
||||
_make_streak_eval(200), TIER_LEGEND, '🌌'),
|
||||
('streak_365_cal', tr('achieve.streak_365_cal.name'), tr('achieve.streak_365_cal.desc'), 365,
|
||||
('streak_365_cal', '불사신', '365일 달력 연속', 365,
|
||||
_make_streak_eval(365, business_only=False), TIER_LEGEND, '🛡️'),
|
||||
('streak_resilience', tr('achieve.streak_resilience.name'), tr('achieve.streak_resilience.desc'), 1,
|
||||
('streak_resilience', '회복력', '결근 후 다음날 즉시 출근 (자동: 달력 streak 깨진 후 재시작)', 1,
|
||||
_bool_eval(lambda db: _consecutive_workdays(db) >= 1
|
||||
and _count_work_records(db) >= 5), TIER_BRONZE, '⚡'),
|
||||
('streak_total_100', tr('achieve.streak_total_100.name'), tr('achieve.streak_total_100.desc'), 100,
|
||||
('streak_total_100', '누적 100회', '누적 출근 100회', 100,
|
||||
_make_count_eval(_count_work_records, 100), TIER_GOLD, '💼'),
|
||||
('streak_total_500', tr('achieve.streak_total_500.name'), tr('achieve.streak_total_500.desc'), 500,
|
||||
('streak_total_500', '누적 500회', '누적 출근 500회', 500,
|
||||
_make_count_eval(_count_work_records, 500), TIER_PLATINUM, '🏛️'),
|
||||
('streak_total_1000', tr('achieve.streak_total_1000.name'), tr('achieve.streak_total_1000.desc'), 1000,
|
||||
('streak_total_1000', '누적 1000회', '누적 출근 1000회', 1000,
|
||||
_make_count_eval(_count_work_records, 1000), TIER_LEGEND, '🎖️'),
|
||||
]
|
||||
|
||||
@ -477,37 +476,37 @@ def _count_weekday_clockins(db, weekday: int) -> int:
|
||||
|
||||
|
||||
_STREAK_DEFS.extend([
|
||||
('streak_monday_10', tr('achieve.streak_monday_10.name'), tr('achieve.streak_monday_10.desc'), 10,
|
||||
('streak_monday_10', '월요일 정복', '월요일 10주 연속 출근', 10,
|
||||
_make_count_eval(lambda db: _count_weekday_clockins(db, 1), 10), TIER_SILVER, '🌅'),
|
||||
('streak_friday_10', tr('achieve.streak_friday_10.name'), tr('achieve.streak_friday_10.desc'), 10,
|
||||
('streak_friday_10', '금요일 무결', '금요일 10주 연속 출근', 10,
|
||||
_make_count_eval(lambda db: _count_weekday_clockins(db, 5), 10), TIER_SILVER, '🌒'),
|
||||
])
|
||||
|
||||
|
||||
# ---- 2. 시간 엄수 (19개 - 34/46 제거) ----
|
||||
_PUNCTUAL_DEFS = [
|
||||
('punc_before_8_1', tr('achieve.punc_before_8_1.name'), tr('achieve.punc_before_8_1.desc'), 1,
|
||||
('punc_before_8_1', '얼리버드', '08:00 이전 출근 1회', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 1), TIER_BRONZE, '🌄'),
|
||||
('punc_before_8_10', tr('achieve.punc_before_8_10.name'), tr('achieve.punc_before_8_10.desc'), 10,
|
||||
('punc_before_8_10', '참새족', '08:00 이전 10회', 10,
|
||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 10), TIER_SILVER, '🐦'),
|
||||
('punc_before_8_30', tr('achieve.punc_before_8_30.name'), tr('achieve.punc_before_8_30.desc'), 30,
|
||||
('punc_before_8_30', '일찍 자고 일찍', '08:00 이전 30회', 30,
|
||||
_make_count_eval(lambda db: _count_clock_in_before(db, 8), 30), TIER_GOLD, '🌞'),
|
||||
('punc_before_6_1', tr('achieve.punc_before_6_1.name'), tr('achieve.punc_before_6_1.desc'), 1,
|
||||
('punc_before_6_1', '새벽잠 없음', '06:00 이전 1회', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 1), TIER_GOLD, '🥱'),
|
||||
('punc_before_6_10', tr('achieve.punc_before_6_10.name'), tr('achieve.punc_before_6_10.desc'), 10,
|
||||
('punc_before_6_10', '어둠을 가르는 자', '06:00 이전 10회', 10,
|
||||
_make_count_eval(lambda db: _count_clock_in_before(db, 6), 10), TIER_PLATINUM, '🌑'),
|
||||
('punc_before_5', tr('achieve.punc_before_5.name'), tr('achieve.punc_before_5.desc'), 1,
|
||||
('punc_before_5', '새벽 챔피언', '05:00 이전 출근', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_before(db, 5), 1), TIER_LEGEND, '🌌'),
|
||||
('punc_at_9', tr('achieve.punc_at_9.name'), tr('achieve.punc_at_9.desc'), 1,
|
||||
('punc_at_9', '9시 정각', '09:00 정각(±1분) 출근 1회', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 1),
|
||||
TIER_BRONZE, '🎯'),
|
||||
('punc_at_9_5', tr('achieve.punc_at_9_5.name'), tr('achieve.punc_at_9_5.desc'), 5,
|
||||
('punc_at_9_5', '완벽한 9시', '09:00 정각(±1분) 5회', 5,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 2), 5),
|
||||
TIER_SILVER, '🏹'),
|
||||
('punc_late_5min', tr('achieve.punc_late_5min.name'), tr('achieve.punc_late_5min.desc'), 1,
|
||||
('punc_late_5min', '5분 늦음', '09:00~09:05 출근 1회 (자조)', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 0, 9, 6), 1),
|
||||
TIER_BRONZE, '🛌'),
|
||||
('punc_at_909', tr('achieve.punc_at_909.name'), tr('achieve.punc_at_909.desc'), 1,
|
||||
('punc_at_909', '운명의 시각', '09:09 출근 (시크릿)', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range_minute(db, 9, 9, 9, 10), 1),
|
||||
TIER_GOLD, '🎰'),
|
||||
]
|
||||
@ -526,76 +525,76 @@ def _count_clock_in_in_range_minute(db, sh: int, sm: int, eh: int, em: int) -> i
|
||||
|
||||
# ---- 3. 워라밸·정시 퇴근 (8개 코어) ----
|
||||
_BALANCE_DEFS = [
|
||||
('bal_first_punct', tr('achieve.bal_first_punct.name'), tr('achieve.bal_first_punct.desc'), 1,
|
||||
('bal_first_punct', '첫 칼퇴', '정시 퇴근 첫 달성', 1,
|
||||
_make_count_eval(_count_punctual_clockouts, 1), TIER_BRONZE, '🚪'),
|
||||
('bal_punct_10', tr('achieve.bal_punct_10.name'), tr('achieve.bal_punct_10.desc'), 10,
|
||||
('bal_punct_10', '칼퇴러', '정시 퇴근 10회', 10,
|
||||
_make_count_eval(_count_punctual_clockouts, 10), TIER_SILVER, '🎉'),
|
||||
('bal_punct_30', tr('achieve.bal_punct_30.name'), tr('achieve.bal_punct_30.desc'), 30,
|
||||
('bal_punct_30', '칼퇴 챔프', '정시 퇴근 30회', 30,
|
||||
_make_count_eval(_count_punctual_clockouts, 30), TIER_GOLD, '🏃'),
|
||||
('bal_punct_100', tr('achieve.bal_punct_100.name'), tr('achieve.bal_punct_100.desc'), 100,
|
||||
('bal_punct_100', '진정한 자유', '정시 퇴근 100회', 100,
|
||||
_make_count_eval(_count_punctual_clockouts, 100), TIER_LEGEND, '🏖️'),
|
||||
('bal_punct_300', tr('achieve.bal_punct_300.name'), tr('achieve.bal_punct_300.desc'), 300,
|
||||
('bal_punct_300', '워라밸 마스터', '정시 퇴근 300회', 300,
|
||||
_make_count_eval(_count_punctual_clockouts, 300), TIER_LEGEND, '🪐'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 4. 연장근무 적립 ----
|
||||
_OT_BANK_DEFS = [
|
||||
('ot_first_30m', tr('achieve.ot_first_30m.name'), tr('achieve.ot_first_30m.desc'), 30,
|
||||
('ot_first_30m', '첫 30분', '첫 연장 적립', 30,
|
||||
_make_count_eval(_ot_total_earned, 30), TIER_BRONZE, '💰'),
|
||||
('ot_total_60m', tr('achieve.ot_total_60m.name'), tr('achieve.ot_total_60m.desc'), 60,
|
||||
('ot_total_60m', '1시간 적금', '누적 1시간 적립', 60,
|
||||
_make_count_eval(_ot_total_earned, 60), TIER_BRONZE, '💵'),
|
||||
('ot_total_5h', tr('achieve.ot_total_5h.name'), tr('achieve.ot_total_5h.desc'), 300,
|
||||
('ot_total_5h', '5시간 적립', '누적 5시간', 300,
|
||||
_make_count_eval(_ot_total_earned, 300), TIER_SILVER, '🏦'),
|
||||
('ot_total_10h', tr('achieve.ot_total_10h.name'), tr('achieve.ot_total_10h.desc'), 600,
|
||||
('ot_total_10h', '10시간 적립', '누적 10시간', 600,
|
||||
_make_count_eval(_ot_total_earned, 600), TIER_SILVER, '💎'),
|
||||
('ot_total_25h', tr('achieve.ot_total_25h.name'), tr('achieve.ot_total_25h.desc'), 1500,
|
||||
('ot_total_25h', '25시간 적립', '누적 25시간', 1500,
|
||||
_make_count_eval(_ot_total_earned, 1500), TIER_GOLD, '🏆'),
|
||||
('ot_total_50h', tr('achieve.ot_total_50h.name'), tr('achieve.ot_total_50h.desc'), 3000,
|
||||
('ot_total_50h', '50시간 적립', '누적 50시간', 3000,
|
||||
_make_count_eval(_ot_total_earned, 3000), TIER_GOLD, '🎯'),
|
||||
('ot_total_100h', tr('achieve.ot_total_100h.name'), tr('achieve.ot_total_100h.desc'), 6000,
|
||||
('ot_total_100h', '마라토너', '누적 100시간 (걱정 메시지)', 6000,
|
||||
_make_count_eval(_ot_total_earned, 6000), TIER_PLATINUM, '🏔️'),
|
||||
('ot_total_200h', tr('achieve.ot_total_200h.name'), tr('achieve.ot_total_200h.desc'), 12000,
|
||||
('ot_total_200h', '워크홀릭 경고', '누적 200시간 (경고)', 12000,
|
||||
_make_count_eval(_ot_total_earned, 12000), TIER_PLATINUM, '🌑'),
|
||||
('ot_total_300h', tr('achieve.ot_total_300h.name'), tr('achieve.ot_total_300h.desc'), 18000,
|
||||
('ot_total_300h', '위험 신호', '누적 300시간 (강한 경고)', 18000,
|
||||
_make_count_eval(_ot_total_earned, 18000), TIER_LEGEND, '⚠️'),
|
||||
('ot_total_500h', tr('achieve.ot_total_500h.name'), tr('achieve.ot_total_500h.desc'), 30000,
|
||||
('ot_total_500h', '응급실 단골', '누적 500시간 (자조)', 30000,
|
||||
_make_count_eval(_ot_total_earned, 30000), TIER_LEGEND, '🚑'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 5. 연장근무 사용 ----
|
||||
_OT_USE_DEFS = [
|
||||
('use_first', tr('achieve.use_first.name'), tr('achieve.use_first.desc'), 1,
|
||||
('use_first', '첫 휴식', '적립 첫 사용', 1,
|
||||
_bool_eval(lambda db: _ot_total_used(db) > 0), TIER_BRONZE, '🛌'),
|
||||
('use_total_5h', tr('achieve.use_total_5h.name'), tr('achieve.use_total_5h.desc'), 300,
|
||||
('use_total_5h', '선물 사용', '누적 5시간 사용', 300,
|
||||
_make_count_eval(_ot_total_used, 300), TIER_SILVER, '🎁'),
|
||||
('use_total_25h', tr('achieve.use_total_25h.name'), tr('achieve.use_total_25h.desc'), 1500,
|
||||
('use_total_25h', '휴식의 가치', '누적 25시간 사용', 1500,
|
||||
_make_count_eval(_ot_total_used, 1500), TIER_GOLD, '🛀'),
|
||||
('use_total_50h', tr('achieve.use_total_50h.name'), tr('achieve.use_total_50h.desc'), 3000,
|
||||
('use_total_50h', '회복 마스터', '누적 50시간 사용', 3000,
|
||||
_make_count_eval(_ot_total_used, 3000), TIER_GOLD, '🏖️'),
|
||||
('use_total_100h', tr('achieve.use_total_100h.name'), tr('achieve.use_total_100h.desc'), 6000,
|
||||
('use_total_100h', '마사지', '누적 100시간 사용', 6000,
|
||||
_make_count_eval(_ot_total_used, 6000), TIER_PLATINUM, '💆'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 6. 연차 ----
|
||||
_LEAVE_DEFS = [
|
||||
('leave_first', tr('achieve.leave_first.name'), tr('achieve.leave_first.desc'), 1,
|
||||
('leave_first', '첫 연차', '첫 연차 사용', 1,
|
||||
_make_count_eval(_count_leave_records, 1), TIER_BRONZE, '🌴'),
|
||||
('leave_half', tr('achieve.leave_half.name'), tr('achieve.leave_half.desc'), 1,
|
||||
('leave_half', '첫 반차', '0.5일 연차 사용', 1,
|
||||
_bool_eval(lambda db: _has_leave_with_days(db, 0.5)), TIER_BRONZE, '🍃'),
|
||||
('leave_quarter', tr('achieve.leave_quarter.name'), tr('achieve.leave_quarter.desc'), 1,
|
||||
('leave_quarter', '시간 연차', '0.25일 연차 사용', 1,
|
||||
_bool_eval(lambda db: _has_leave_with_days(db, 0.25)), TIER_BRONZE, '⏱️'),
|
||||
('leave_streak_3', tr('achieve.leave_streak_3.name'), tr('achieve.leave_streak_3.desc'), 3,
|
||||
('leave_streak_3', '미니 휴가', '연속 3일 연차', 3,
|
||||
_make_count_eval(_consecutive_leave_days, 3), TIER_SILVER, '🏝️'),
|
||||
('leave_streak_5', tr('achieve.leave_streak_5.name'), tr('achieve.leave_streak_5.desc'), 5,
|
||||
('leave_streak_5', '본격 휴가', '연속 5일 연차', 5,
|
||||
_make_count_eval(_consecutive_leave_days, 5), TIER_GOLD, '🌅'),
|
||||
('leave_streak_7', tr('achieve.leave_streak_7.name'), tr('achieve.leave_streak_7.desc'), 7,
|
||||
('leave_streak_7', '장거리 휴가', '연속 7일 이상 연차', 7,
|
||||
_make_count_eval(_consecutive_leave_days, 7), TIER_PLATINUM, '🛬'),
|
||||
('leave_total_10', tr('achieve.leave_total_10.name'), tr('achieve.leave_total_10.desc'), 10,
|
||||
('leave_total_10', '연차 10회', '연차 기록 10건', 10,
|
||||
_make_count_eval(_count_leave_records, 10), TIER_SILVER, '🌊'),
|
||||
('leave_sick', tr('achieve.leave_sick.name'), tr('achieve.leave_sick.desc'), 1,
|
||||
('leave_sick', '병가', 'sick 타입 연차 사용', 1,
|
||||
_make_count_eval(lambda db: _count_leave_records(db, 'sick'), 1),
|
||||
TIER_BRONZE, '🏥'),
|
||||
]
|
||||
@ -603,22 +602,22 @@ _LEAVE_DEFS = [
|
||||
|
||||
# ---- 7. 식사 (점심/저녁) ----
|
||||
_MEAL_DEFS = [
|
||||
('meal_lunch_first', tr('achieve.meal_lunch_first.name'), tr('achieve.meal_lunch_first.desc'), 1,
|
||||
('meal_lunch_first', '첫 점심 등록', '점심 첫 토글', 1,
|
||||
_make_count_eval(_count_lunch_registrations, 1), TIER_BRONZE, '🍱'),
|
||||
('meal_lunch_30', tr('achieve.meal_lunch_30.name'), tr('achieve.meal_lunch_30.desc'), 30,
|
||||
('meal_lunch_30', '점심 마스터', '점심 등록 30회', 30,
|
||||
_make_count_eval(_count_lunch_registrations, 30), TIER_SILVER, '🥢'),
|
||||
('meal_lunch_100', tr('achieve.meal_lunch_100.name'), tr('achieve.meal_lunch_100.desc'), 100,
|
||||
('meal_lunch_100', '점심 챔프', '점심 등록 100회', 100,
|
||||
_make_count_eval(_count_lunch_registrations, 100), TIER_GOLD, '🍜'),
|
||||
('meal_dinner_first', tr('achieve.meal_dinner_first.name'), tr('achieve.meal_dinner_first.desc'), 1,
|
||||
('meal_dinner_first', '첫 저녁 등록', '저녁 첫 토글', 1,
|
||||
_make_count_eval(_count_dinner_registrations, 1), TIER_BRONZE, '🍽️'),
|
||||
('meal_dinner_10', tr('achieve.meal_dinner_10.name'), tr('achieve.meal_dinner_10.desc'), 10,
|
||||
('meal_dinner_10', '저녁 단골', '저녁 등록 10회 (경고)', 10,
|
||||
_make_count_eval(_count_dinner_registrations, 10), TIER_SILVER, '🍛'),
|
||||
('meal_dinner_30', tr('achieve.meal_dinner_30.name'), tr('achieve.meal_dinner_30.desc'), 30,
|
||||
('meal_dinner_30', '야식 단골', '저녁 등록 30회 (경고)', 30,
|
||||
_make_count_eval(_count_dinner_registrations, 30), TIER_GOLD, '🌃'),
|
||||
('meal_lunch_actual', tr('achieve.meal_lunch_actual.name'), tr('achieve.meal_lunch_actual.desc'), 1,
|
||||
('meal_lunch_actual', '실측 점심', '실제 점심 시각 입력', 1,
|
||||
_make_count_eval(lambda db: _count_break_records_type(db, 'lunch'), 1),
|
||||
TIER_BRONZE, '⏱️'),
|
||||
('meal_dinner_actual', tr('achieve.meal_dinner_actual.name'), tr('achieve.meal_dinner_actual.desc'), 1,
|
||||
('meal_dinner_actual', '실측 저녁', '실제 저녁 시각 입력', 1,
|
||||
_make_count_eval(lambda db: _count_break_records_type(db, 'dinner'), 1),
|
||||
TIER_BRONZE, '⏰'),
|
||||
]
|
||||
@ -626,13 +625,13 @@ _MEAL_DEFS = [
|
||||
|
||||
# ---- 8. 외출 ----
|
||||
_BREAK_DEFS = [
|
||||
('break_first', tr('achieve.break_first.name'), tr('achieve.break_first.desc'), 1,
|
||||
('break_first', '첫 외출', '첫 외출 시작', 1,
|
||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 1),
|
||||
TIER_BRONZE, '🚶'),
|
||||
('break_10', tr('achieve.break_10.name'), tr('achieve.break_10.desc'), 10,
|
||||
('break_10', '외출 챔프', '외출 10회', 10,
|
||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 10),
|
||||
TIER_SILVER, '🚪'),
|
||||
('break_50', tr('achieve.break_50.name'), tr('achieve.break_50.desc'), 50,
|
||||
('break_50', '산책러', '외출 50회', 50,
|
||||
_make_count_eval(lambda db: _count_break_records_type(db, 'break'), 50),
|
||||
TIER_GOLD, '🚶♂️'),
|
||||
]
|
||||
@ -640,39 +639,39 @@ _BREAK_DEFS = [
|
||||
|
||||
# ---- 9. 시간대별 ----
|
||||
_TIME_SLOT_DEFS = [
|
||||
('slot_in_06', tr('achieve.slot_in_06.name'), tr('achieve.slot_in_06.desc'), 1,
|
||||
('slot_in_06', '06시대 출근', '06:00-06:59 출근 1회', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 6, 7), 1),
|
||||
TIER_BRONZE, '🌅'),
|
||||
('slot_in_07', tr('achieve.slot_in_07.name'), tr('achieve.slot_in_07.desc'), 1,
|
||||
('slot_in_07', '07시대 출근', '07:00-07:59 출근 1회', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 7, 8), 1),
|
||||
TIER_BRONZE, '🌄'),
|
||||
('slot_in_08', tr('achieve.slot_in_08.name'), tr('achieve.slot_in_08.desc'), 1,
|
||||
('slot_in_08', '08시대 출근', '08:00-08:59 출근 1회', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 8, 9), 1),
|
||||
TIER_BRONZE, '☀️'),
|
||||
('slot_in_10', tr('achieve.slot_in_10.name'), tr('achieve.slot_in_10.desc'), 1,
|
||||
('slot_in_10', '10시대 출근', '10시대 출근 (지각/유연근무)', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 10, 11), 1),
|
||||
TIER_BRONZE, '🕙'),
|
||||
('slot_in_11', tr('achieve.slot_in_11.name'), tr('achieve.slot_in_11.desc'), 1,
|
||||
('slot_in_11', '11시대 출근', '11시대 출근 (자조)', 1,
|
||||
_make_count_eval(lambda db: _count_clock_in_in_range(db, 11, 12), 1),
|
||||
TIER_SILVER, '🕦'),
|
||||
('slot_out_19', tr('achieve.slot_out_19.name'), tr('achieve.slot_out_19.desc'), 10,
|
||||
('slot_out_19', '19시대 퇴근', '19시대 퇴근 10회 (경고)', 10,
|
||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 19), 10),
|
||||
TIER_SILVER, '🌆'),
|
||||
('slot_out_20', tr('achieve.slot_out_20.name'), tr('achieve.slot_out_20.desc'), 10,
|
||||
('slot_out_20', '20시대 퇴근', '20시대 퇴근 10회 (경고)', 10,
|
||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 20), 10),
|
||||
TIER_GOLD, '🌌'),
|
||||
('slot_out_21', tr('achieve.slot_out_21.name'), tr('achieve.slot_out_21.desc'), 5,
|
||||
('slot_out_21', '21시대 퇴근', '21시대 퇴근 5회 (경고)', 5,
|
||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 21), 5),
|
||||
TIER_GOLD, '🌑'),
|
||||
('slot_out_22', tr('achieve.slot_out_22.name'), tr('achieve.slot_out_22.desc'), 1,
|
||||
('slot_out_22', '22시대 퇴근', '22시대 퇴근 1회 (경고)', 1,
|
||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 22), 1),
|
||||
TIER_PLATINUM, '🦉'),
|
||||
('slot_out_23', tr('achieve.slot_out_23.name'), tr('achieve.slot_out_23.desc'), 1,
|
||||
('slot_out_23', '23시대 퇴근', '23시대 퇴근 1회 (경고)', 1,
|
||||
_make_count_eval(lambda db: _count_clockouts_in_hour(db, 23), 1),
|
||||
TIER_PLATINUM, '🦇'),
|
||||
('slot_midnight', tr('achieve.slot_midnight.name'), tr('achieve.slot_midnight.desc'), 1,
|
||||
('slot_midnight', '자정 퇴근', '자정 이후 퇴근 (경고)', 1,
|
||||
_make_count_eval(_count_clock_out_after_midnight, 1), TIER_LEGEND, '🌚'),
|
||||
('slot_midnight_3', tr('achieve.slot_midnight_3.name'), tr('achieve.slot_midnight_3.desc'), 3,
|
||||
('slot_midnight_3', '올빼미 트리오', '자정 이후 퇴근 3회 (경고)', 3,
|
||||
_make_count_eval(_count_clock_out_after_midnight, 3), TIER_LEGEND, '🌌'),
|
||||
]
|
||||
|
||||
@ -693,50 +692,50 @@ def _count_clockouts_in_hour(db, hour: int) -> int:
|
||||
|
||||
# ---- 10. 공휴일·주말 ----
|
||||
_SPECIAL_DAY_DEFS = [
|
||||
('weekend_1', tr('achieve.weekend_1.name'), tr('achieve.weekend_1.desc'), 1,
|
||||
('weekend_1', '주말 출근 1회', '토/일 출근 1회', 1,
|
||||
_make_count_eval(_count_weekend_clockins, 1), TIER_SILVER, '🌃'),
|
||||
('weekend_5', tr('achieve.weekend_5.name'), tr('achieve.weekend_5.desc'), 5,
|
||||
('weekend_5', '주말 워커', '주말 출근 5회 (경고)', 5,
|
||||
_make_count_eval(_count_weekend_clockins, 5), TIER_GOLD, '🌑'),
|
||||
('weekend_20', tr('achieve.weekend_20.name'), tr('achieve.weekend_20.desc'), 20,
|
||||
('weekend_20', '진짜 워크홀릭', '주말 출근 20회 (강한 자조)', 20,
|
||||
_make_count_eval(_count_weekend_clockins, 20), TIER_PLATINUM, '💀'),
|
||||
('holiday_1', tr('achieve.holiday_1.name'), tr('achieve.holiday_1.desc'), 1,
|
||||
('holiday_1', '공휴일 출근', '한국 공휴일 출근 1회', 1,
|
||||
_make_count_eval(_count_holiday_clockins, 1), TIER_GOLD, '📆'),
|
||||
('holiday_5', tr('achieve.holiday_5.name'), tr('achieve.holiday_5.desc'), 5,
|
||||
('holiday_5', '공휴일 워커홀릭', '한국 공휴일 출근 5회 (경고)', 5,
|
||||
_make_count_eval(_count_holiday_clockins, 5), TIER_LEGEND, '⚠️'),
|
||||
('day_christmas', tr('achieve.day_christmas.name'), tr('achieve.day_christmas.desc'), 1,
|
||||
('day_christmas', '크리스마스 출근', '12/25 출근 (자조)', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '12-25')), TIER_GOLD, '🎄'),
|
||||
('day_newyear', tr('achieve.day_newyear.name'), tr('achieve.day_newyear.desc'), 1,
|
||||
('day_newyear', '신정 출근', '1/1 출근 (자조)', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '01-01')), TIER_GOLD, '🎊'),
|
||||
('day_liberation', tr('achieve.day_liberation.name'), tr('achieve.day_liberation.desc'), 1,
|
||||
('day_liberation', '광복절 출근', '8/15 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '08-15')), TIER_SILVER, '🎆'),
|
||||
('day_children', tr('achieve.day_children.name'), tr('achieve.day_children.desc'), 1,
|
||||
('day_children', '어린이날 출근', '5/5 출근 (자조)', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '05-05')), TIER_GOLD, '🎀'),
|
||||
('day_hangul', tr('achieve.day_hangul.name'), tr('achieve.day_hangul.desc'), 1,
|
||||
('day_hangul', '한글날 출근', '10/9 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '10-09')), TIER_SILVER, '🎤'),
|
||||
('day_valentine', tr('achieve.day_valentine.name'), tr('achieve.day_valentine.desc'), 1,
|
||||
('day_valentine', '발렌타인데이 출근', '2/14 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '02-14')), TIER_BRONZE, '💝'),
|
||||
('day_white', tr('achieve.day_white.name'), tr('achieve.day_white.desc'), 1,
|
||||
('day_white', '화이트데이 출근', '3/14 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '03-14')), TIER_BRONZE, '🌹'),
|
||||
('day_pepero', tr('achieve.day_pepero.name'), tr('achieve.day_pepero.desc'), 1,
|
||||
('day_pepero', '빼빼로데이', '11/11 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '11-11')), TIER_SILVER, '🍫'),
|
||||
('day_halloween', tr('achieve.day_halloween.name'), tr('achieve.day_halloween.desc'), 1,
|
||||
('day_halloween', '핼러윈 출근', '10/31 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '10-31')), TIER_BRONZE, '🎃'),
|
||||
('day_aprilfools', tr('achieve.day_aprilfools.name'), tr('achieve.day_aprilfools.desc'), 1,
|
||||
('day_aprilfools', '만우절 출근', '4/1 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '04-01')), TIER_BRONZE, '🃏'),
|
||||
('day_77', tr('achieve.day_77.name'), tr('achieve.day_77.desc'), 1,
|
||||
('day_77', '칠월칠석', '7/7 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '07-07')), TIER_SILVER, '🎋'),
|
||||
('day_dongji', tr('achieve.day_dongji.name'), tr('achieve.day_dongji.desc'), 1,
|
||||
('day_dongji', '동지 출근', '12/22 출근', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '12-22')), TIER_BRONZE, '🎇'),
|
||||
('day_parents', tr('achieve.day_parents.name'), tr('achieve.day_parents.desc'), 1,
|
||||
('day_parents', '어버이날 정시 퇴근', '5/8 정시 퇴근', 1,
|
||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-08')),
|
||||
TIER_SILVER, '🪅'),
|
||||
('day_teacher', tr('achieve.day_teacher.name'), tr('achieve.day_teacher.desc'), 1,
|
||||
('day_teacher', '스승의 날 정시 퇴근', '5/15 정시 퇴근', 1,
|
||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '05-15')),
|
||||
TIER_BRONZE, '🎂'),
|
||||
('day_xmas_eve', tr('achieve.day_xmas_eve.name'), tr('achieve.day_xmas_eve.desc'), 1,
|
||||
('day_xmas_eve', '크리스마스이브 정시 퇴근', '12/24 정시 퇴근', 1,
|
||||
_bool_eval(lambda db: _has_punctual_clockout_on(db, '12-24')),
|
||||
TIER_SILVER, '🎁'),
|
||||
('day_earth', tr('achieve.day_earth.name'), tr('achieve.day_earth.desc'), 1,
|
||||
('day_earth', '지구의 날', '4/22 출근 (시크릿)', 1,
|
||||
_bool_eval(lambda db: _has_clockin_on(db, '04-22')), TIER_GOLD, '🌏'),
|
||||
]
|
||||
|
||||
@ -761,79 +760,79 @@ def _make_month_first_eval(month: int):
|
||||
|
||||
|
||||
_SEASON_DEFS = [
|
||||
('season_jan', tr('achieve.season_jan.name'), tr('achieve.season_jan.desc'), 1,
|
||||
('season_jan', '1월 정착', '1월 한 달 출근', 1,
|
||||
_make_month_first_eval(1), TIER_BRONZE, '⛄'),
|
||||
('season_feb', tr('achieve.season_feb.name'), tr('achieve.season_feb.desc'), 1,
|
||||
('season_feb', '2월 정착', '2월 영업일 모두 출근', 1,
|
||||
_make_month_full_attendance_eval(2), TIER_SILVER, '🌨️'),
|
||||
('season_mar', tr('achieve.season_mar.name'), tr('achieve.season_mar.desc'), 1,
|
||||
('season_mar', '봄을 맞이', '3월 첫 출근', 1,
|
||||
_make_month_first_eval(3), TIER_BRONZE, '🌸'),
|
||||
('season_apr', tr('achieve.season_apr.name'), tr('achieve.season_apr.desc'), 1,
|
||||
('season_apr', '4월 정착', '4월 한 달 출근', 1,
|
||||
_make_month_full_attendance_eval(4), TIER_BRONZE, '🌷'),
|
||||
('season_may', tr('achieve.season_may.name'), tr('achieve.season_may.desc'), 1,
|
||||
('season_may', '5월 정착', '5월 영업일 모두 출근', 1,
|
||||
_make_month_full_attendance_eval(5), TIER_SILVER, '🌺'),
|
||||
('season_jun', tr('achieve.season_jun.name'), tr('achieve.season_jun.desc'), 1,
|
||||
('season_jun', '여름의 시작', '6월 첫 출근', 1,
|
||||
_make_month_first_eval(6), TIER_BRONZE, '☀️'),
|
||||
('season_jul', tr('achieve.season_jul.name'), tr('achieve.season_jul.desc'), 1,
|
||||
('season_jul', '7월 정착', '7월 한 달 출근', 1,
|
||||
_make_month_full_attendance_eval(7), TIER_BRONZE, '🌻'),
|
||||
('season_aug', tr('achieve.season_aug.name'), tr('achieve.season_aug.desc'), 1,
|
||||
('season_aug', '8월 정착', '8월 영업일 모두 출근', 1,
|
||||
_make_month_full_attendance_eval(8), TIER_SILVER, '🍦'),
|
||||
('season_sep', tr('achieve.season_sep.name'), tr('achieve.season_sep.desc'), 1,
|
||||
('season_sep', '가을의 시작', '9월 첫 출근', 1,
|
||||
_make_month_first_eval(9), TIER_BRONZE, '🍂'),
|
||||
('season_oct', tr('achieve.season_oct.name'), tr('achieve.season_oct.desc'), 1,
|
||||
('season_oct', '10월 정착', '10월 한 달 출근', 1,
|
||||
_make_month_full_attendance_eval(10), TIER_BRONZE, '🌾'),
|
||||
('season_nov', tr('achieve.season_nov.name'), tr('achieve.season_nov.desc'), 1,
|
||||
('season_nov', '11월 단풍', '11월 영업일 모두 출근', 1,
|
||||
_make_month_full_attendance_eval(11), TIER_SILVER, '🍁'),
|
||||
('season_dec', tr('achieve.season_dec.name'), tr('achieve.season_dec.desc'), 1,
|
||||
('season_dec', '겨울의 시작', '12월 첫 출근', 1,
|
||||
_make_month_first_eval(12), TIER_BRONZE, '❄️'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 12. 앱 사용 마일스톤 ----
|
||||
_MILESTONE_DEFS = [
|
||||
('mile_first', tr('achieve.mile_first.name'), tr('achieve.mile_first.desc'), 1,
|
||||
('mile_first', 'Hello, World!', '앱 첫 실행', 1,
|
||||
_bool_eval(lambda db: _count_work_records(db) >= 1 or _days_since_first_work(db) >= 0),
|
||||
TIER_BRONZE, '👋'),
|
||||
('mile_7days', tr('achieve.mile_7days.name'), tr('achieve.mile_7days.desc'), 7,
|
||||
('mile_7days', '일주일 사용', '7일 사용', 7,
|
||||
_make_count_eval(_days_since_first_work, 7), TIER_BRONZE, '🗓️'),
|
||||
('mile_30days', tr('achieve.mile_30days.name'), tr('achieve.mile_30days.desc'), 30,
|
||||
('mile_30days', '한 달 사용', '30일 사용', 30,
|
||||
_make_count_eval(_days_since_first_work, 30), TIER_SILVER, '📚'),
|
||||
('mile_365days', tr('achieve.mile_365days.name'), tr('achieve.mile_365days.desc'), 365,
|
||||
('mile_365days', '1주년', '365일 사용', 365,
|
||||
_make_count_eval(_days_since_first_work, 365), TIER_PLATINUM, '💎'),
|
||||
('mile_730days', tr('achieve.mile_730days.name'), tr('achieve.mile_730days.desc'), 730,
|
||||
('mile_730days', '2주년', '730일 사용', 730,
|
||||
_make_count_eval(_days_since_first_work, 730), TIER_LEGEND, '🌟'),
|
||||
('mile_1095days', tr('achieve.mile_1095days.name'), tr('achieve.mile_1095days.desc'), 1095,
|
||||
('mile_1095days', '3주년', '3년 사용', 1095,
|
||||
_make_count_eval(_days_since_first_work, 1095), TIER_LEGEND, '🎖️'),
|
||||
('mile_5years', tr('achieve.mile_5years.name'), tr('achieve.mile_5years.desc'), 1825,
|
||||
('mile_5years', '5년 사용자', '5년 사용', 1825,
|
||||
_make_count_eval(_days_since_first_work, 1825), TIER_LEGEND, '🏆'),
|
||||
('mile_10years', tr('achieve.mile_10years.name'), tr('achieve.mile_10years.desc'), 3650,
|
||||
('mile_10years', '10년 사용자', '10년 사용', 3650,
|
||||
_make_count_eval(_days_since_first_work, 3650), TIER_LEGEND, '🎖️'),
|
||||
]
|
||||
|
||||
|
||||
# ---- 13. 통계·분석 (view counter 기반) ----
|
||||
_STATS_DEFS = [
|
||||
('stat_weekly_10', tr('achieve.stat_weekly_10.name'), tr('achieve.stat_weekly_10.desc'), 10,
|
||||
('stat_weekly_10', '주간 통계러', '주간 탭 10회 조회', 10,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'stat_weekly_view_count'), 10),
|
||||
TIER_BRONZE, '📊'),
|
||||
('stat_monthly_10', tr('achieve.stat_monthly_10.name'), tr('achieve.stat_monthly_10.desc'), 10,
|
||||
('stat_monthly_10', '월간 통계러', '월간 탭 10회', 10,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'stat_monthly_view_count'), 10),
|
||||
TIER_BRONZE, '📈'),
|
||||
('stat_pattern_10', tr('achieve.stat_pattern_10.name'), tr('achieve.stat_pattern_10.desc'), 10,
|
||||
('stat_pattern_10', '패턴 분석가', '패턴 탭 10회', 10,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'stat_pattern_view_count'), 10),
|
||||
TIER_SILVER, '🔍'),
|
||||
('stat_calendar_30', tr('achieve.stat_calendar_30.name'), tr('achieve.stat_calendar_30.desc'), 30,
|
||||
('stat_calendar_30', '캘린더 챔프', '캘린더 30회 조회', 30,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'calendar_view_count'), 30),
|
||||
TIER_SILVER, '📅'),
|
||||
('stat_report_first', tr('achieve.stat_report_first.name'), tr('achieve.stat_report_first.desc'), 1,
|
||||
('stat_report_first', '일일 보고서 첫 생성', '일일 보고 1회', 1,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 1),
|
||||
TIER_BRONZE, '📋'),
|
||||
('stat_report_30', tr('achieve.stat_report_30.name'), tr('achieve.stat_report_30.desc'), 30,
|
||||
('stat_report_30', '보고서 챔프', '일일 보고 30회', 30,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'daily_report_count'), 30),
|
||||
TIER_SILVER, '📰'),
|
||||
('stat_chart_hover', tr('achieve.stat_chart_hover.name'), tr('achieve.stat_chart_hover.desc'), 1,
|
||||
('stat_chart_hover', '차트 호버 발견', '차트 hover 첫 발견', 1,
|
||||
_bool_eval(lambda db: db.get_setting('chart_hover_discovered', 'false').lower() == 'true'),
|
||||
TIER_BRONZE, '🎨'),
|
||||
('stat_achievements_open', tr('achieve.stat_achievements_open.name'), tr('achieve.stat_achievements_open.desc'), 50,
|
||||
('stat_achievements_open', '도전과제 박물관', '도전과제 뷰 50회', 50,
|
||||
_make_count_eval(lambda db: _setting_int(db, 'achievements_view_count'), 50),
|
||||
TIER_BRONZE, '🦄'),
|
||||
]
|
||||
@ -958,23 +957,23 @@ def _has_500_anniv_clockin(db) -> bool:
|
||||
|
||||
|
||||
_SECRET_DEFS = [
|
||||
('secret_palindrome', tr('achieve.secret_palindrome.name'), tr('achieve.secret_palindrome.desc'), 1,
|
||||
('secret_palindrome', '회문 시각', '출근 시각이 회문', 1,
|
||||
_bool_eval(_has_clock_in_palindrome), TIER_GOLD, '🪞'),
|
||||
('secret_jackpot', tr('achieve.secret_jackpot.name'), tr('achieve.secret_jackpot.desc'), 1,
|
||||
('secret_jackpot', '잭팟 시각', '출근 시각 모든 자릿수 동일', 1,
|
||||
_bool_eval(_has_clock_in_jackpot), TIER_PLATINUM, '🎰'),
|
||||
('secret_fri13', tr('achieve.secret_fri13.name'), tr('achieve.secret_fri13.desc'), 1,
|
||||
('secret_fri13', '13일 금요일', '13일 금요일 출근', 1,
|
||||
_bool_eval(_has_friday_13th_clockin), TIER_GOLD, '🌑'),
|
||||
('secret_777', tr('achieve.secret_777.name'), tr('achieve.secret_777.desc'), 1,
|
||||
('secret_777', '7-7-7', '7월 7일 7시 7분 출근', 1,
|
||||
_bool_eval(_has_777), TIER_LEGEND, '🔮'),
|
||||
('secret_exact_8h', tr('achieve.secret_exact_8h.name'), tr('achieve.secret_exact_8h.desc'), 1,
|
||||
('secret_exact_8h', '정확 8시간', '정확히 8h 0m 근무', 1,
|
||||
_bool_eval(_has_exact_8h), TIER_PLATINUM, '🎯'),
|
||||
('secret_pi_day', tr('achieve.secret_pi_day.name'), tr('achieve.secret_pi_day.desc'), 1,
|
||||
('secret_pi_day', '파이 데이', '3/14 01:59 출근', 1,
|
||||
_bool_eval(_has_pi_day), TIER_LEGEND, '🥧'),
|
||||
('secret_fibonacci', tr('achieve.secret_fibonacci.name'), tr('achieve.secret_fibonacci.desc'), 1,
|
||||
('secret_fibonacci', '피보나치', '출근 분이 피보나치 수', 1,
|
||||
_bool_eval(_has_fibonacci_minute), TIER_SILVER, '🔢'),
|
||||
('secret_double_six', tr('achieve.secret_double_six.name'), tr('achieve.secret_double_six.desc'), 1,
|
||||
('secret_double_six', '더블 식스', '6/6 18:06 출근', 1,
|
||||
_bool_eval(_has_double_six), TIER_LEGEND, '🎲'),
|
||||
('secret_anniversary', tr('achieve.secret_anniversary.name'), tr('achieve.secret_anniversary.desc'), 1,
|
||||
('secret_anniversary', '마법사', '가입 후 정확히 365일 후 출근', 1,
|
||||
_bool_eval(_has_500_anniv_clockin), TIER_LEGEND, '🧙'),
|
||||
]
|
||||
|
||||
@ -985,30 +984,30 @@ def _setting_changed_from_default(db, key: str, default_value: str) -> bool:
|
||||
|
||||
|
||||
_SETTINGS_DEFS = [
|
||||
('set_dark', tr('achieve.set_dark.name'), tr('achieve.set_dark.desc'), 1,
|
||||
('set_dark', '다크 사이드', '다크 테마 1회 사용', 1,
|
||||
_bool_eval(lambda db: _setting_changed_from_default(db, 'theme', 'light')),
|
||||
TIER_BRONZE, '🌗'),
|
||||
('set_lang', tr('achieve.set_lang.name'), tr('achieve.set_lang.desc'), 1,
|
||||
('set_lang', '이중언어', '언어 변경 (en 사용)', 1,
|
||||
_bool_eval(lambda db: db.get_setting('language', 'ko') == 'en'),
|
||||
TIER_BRONZE, '🌐'),
|
||||
('set_a11y', tr('achieve.set_a11y.name'), tr('achieve.set_a11y.desc'), 1,
|
||||
('set_a11y', '접근성 활용', '글꼴 크기≠100% 또는 고대비 ON', 1,
|
||||
_bool_eval(lambda db: db.get_setting('font_scale', '1.0') != '1.0'
|
||||
or db.get_setting('high_contrast', 'false').lower() == 'true'),
|
||||
TIER_BRONZE, '♿'),
|
||||
('set_overtime_unit', tr('achieve.set_overtime_unit.name'), tr('achieve.set_overtime_unit.desc'), 1,
|
||||
('set_overtime_unit', '단위 변경', 'overtime_unit 변경', 1,
|
||||
_bool_eval(lambda db: db.get_setting('overtime_unit', '30') != '30'),
|
||||
TIER_BRONZE, '⏱️'),
|
||||
('set_goal_full', tr('achieve.set_goal_full.name'), tr('achieve.set_goal_full.desc'), 1,
|
||||
('set_goal_full', '목표 마스터', '월 연장+일평균 둘 다 설정', 1,
|
||||
_bool_eval(lambda db: _setting_int(db, 'goal_overtime_max_monthly') > 0
|
||||
and float(db.get_setting('goal_avg_hours_daily', '0') or 0) > 0),
|
||||
TIER_SILVER, '🎯'),
|
||||
('set_discord_full', tr('achieve.set_discord_full.name'), tr('achieve.set_discord_full.desc'), 1,
|
||||
('set_discord_full', '풀 셋업', 'Discord URL + 모든 알림 ON', 1,
|
||||
_bool_eval(lambda db: bool(db.get_setting('discord_webhook_url', '') or '')
|
||||
and all(db.get_setting(k, 'true').lower() == 'true' for k in
|
||||
('notification_clock_out', 'notification_lunch',
|
||||
'notification_overtime', 'notification_health'))),
|
||||
TIER_SILVER, '🔔'),
|
||||
('set_cloud', tr('achieve.set_cloud.name'), tr('achieve.set_cloud.desc'), 1,
|
||||
('set_cloud', '클라우드 동기화', 'DB 경로 변경', 1,
|
||||
_bool_eval(lambda db: bool(db.get_setting('db_path_override', '') or '')),
|
||||
TIER_SILVER, '☁️'),
|
||||
]
|
||||
@ -1033,21 +1032,21 @@ def _earned_secret_count(db) -> int:
|
||||
|
||||
|
||||
_META_DEFS = [
|
||||
('meta_first', tr('achieve.meta_first.name'), tr('achieve.meta_first.desc'), 1,
|
||||
('meta_first', '첫 도전과제', '첫 도전과제 획득', 1,
|
||||
_make_count_eval(_earned_count, 1), TIER_BRONZE, '🏆'),
|
||||
('meta_10', tr('achieve.meta_10.name'), tr('achieve.meta_10.desc'), 10,
|
||||
('meta_10', '10개 달성', '10개 보유', 10,
|
||||
_make_count_eval(_earned_count, 10), TIER_BRONZE, '🎖️'),
|
||||
('meta_25', tr('achieve.meta_25.name'), tr('achieve.meta_25.desc'), 25,
|
||||
('meta_25', '25개 달성', '25개 보유', 25,
|
||||
_make_count_eval(_earned_count, 25), TIER_SILVER, '🥈'),
|
||||
('meta_50', tr('achieve.meta_50.name'), tr('achieve.meta_50.desc'), 50,
|
||||
('meta_50', '50개 달성', '50개 보유', 50,
|
||||
_make_count_eval(_earned_count, 50), TIER_GOLD, '🥇'),
|
||||
('meta_75', tr('achieve.meta_75.name'), tr('achieve.meta_75.desc'), 75,
|
||||
('meta_75', '75개 달성', '75개 보유', 75,
|
||||
_make_count_eval(_earned_count, 75), TIER_PLATINUM, '💎'),
|
||||
('meta_100', tr('achieve.meta_100.name'), tr('achieve.meta_100.desc'), 100,
|
||||
('meta_100', '100개 달성', '100개 보유', 100,
|
||||
_make_count_eval(_earned_count, 100), TIER_LEGEND, '🌟'),
|
||||
('meta_secret_1', tr('achieve.meta_secret_1.name'), tr('achieve.meta_secret_1.desc'), 1,
|
||||
('meta_secret_1', '시크릿 발견', '첫 시크릿 발견', 1,
|
||||
_make_count_eval(_earned_secret_count, 1), TIER_SILVER, '🔍'),
|
||||
('meta_secret_5', tr('achieve.meta_secret_5.name'), tr('achieve.meta_secret_5.desc'), 5,
|
||||
('meta_secret_5', '시크릿 헌터', '시크릿 5개 발견', 5,
|
||||
_make_count_eval(_earned_secret_count, 5), TIER_GOLD, '🌑'),
|
||||
]
|
||||
|
||||
|
||||
145
core/database.py
145
core/database.py
@ -12,7 +12,6 @@ from core.settings_keys import (
|
||||
WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS,
|
||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||
)
|
||||
from utils.debug_log import dlog
|
||||
|
||||
|
||||
class Database:
|
||||
@ -54,8 +53,8 @@ class Database:
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
dlog(f"connection close failed: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _enable_concurrency(self):
|
||||
"""WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화."""
|
||||
@ -90,55 +89,10 @@ class Database:
|
||||
self.migrate_v271_work_records_indexes()
|
||||
self.migrate_v280_achievements_columns()
|
||||
self.migrate_v280_hire_date()
|
||||
self.migrate_v290_holidays_auto_sync()
|
||||
|
||||
# 기본 설정 초기화
|
||||
self.init_default_settings()
|
||||
|
||||
def migrate_v290_holidays_auto_sync(self) -> None:
|
||||
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
|
||||
|
||||
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
|
||||
값이 오늘과 같으면 스킵 — 즉 같은 날 여러 번 켜도 호출 1회.
|
||||
|
||||
매일 호출하므로 정부가 임시공휴일 발표하면 다음 날 자동 반영.
|
||||
일일 한도 10000회, 사용자 50명 × 1회 = 0.5% 소비.
|
||||
|
||||
실제 동기화는 백그라운드 스레드에서 — 부트스트랩이 네트워크에 묶이지 않음.
|
||||
실패는 silent, 다음 실행 시 재시도.
|
||||
|
||||
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 로 비활성화.
|
||||
"""
|
||||
import os
|
||||
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
|
||||
return
|
||||
from datetime import datetime as _dt
|
||||
import threading
|
||||
try:
|
||||
today = _dt.now().date().isoformat()
|
||||
sentinel = self.get_setting('holidays_synced_date', '')
|
||||
if sentinel == today:
|
||||
return
|
||||
except Exception as e:
|
||||
dlog(f"holiday sync precheck failed: {e}")
|
||||
return
|
||||
|
||||
cur_year = _dt.now().year
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
|
||||
from core.database import Database
|
||||
db = Database(self.db_path)
|
||||
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
|
||||
if added >= 0:
|
||||
db.set_setting('holidays_synced_date', today)
|
||||
except Exception as e:
|
||||
dlog(f"holiday sync worker failed: {e}")
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
||||
t.start()
|
||||
|
||||
def _create_tables(self, conn) -> None:
|
||||
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
|
||||
cursor = conn.cursor()
|
||||
@ -772,7 +726,7 @@ class Database:
|
||||
'dinner_duration_minutes': '60',
|
||||
'auto_lunch': 'false',
|
||||
'auto_overtime': 'true',
|
||||
'theme': 'dark',
|
||||
'theme': 'light',
|
||||
'notification_before_minutes': '30',
|
||||
'notification_clock_out': 'true',
|
||||
'notification_lunch': 'true',
|
||||
@ -979,19 +933,6 @@ class Database:
|
||||
''', (work_record_id, earned_minutes, date))
|
||||
conn.commit()
|
||||
|
||||
def delete_overtime_earned(self, bank_id: int) -> bool:
|
||||
"""연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소.
|
||||
|
||||
Returns:
|
||||
bool: 실제로 삭제된 행이 있으면 True.
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted > 0
|
||||
|
||||
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
|
||||
date: str, reason: str = None):
|
||||
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
||||
@ -1072,8 +1013,7 @@ class Database:
|
||||
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
||||
recs = self.get_recurring_leaves(active_on=date_str)
|
||||
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
||||
except Exception as e:
|
||||
dlog(f"recurring leave expansion failed: {e}")
|
||||
except Exception:
|
||||
recurring_days = 0.0
|
||||
|
||||
return concrete_days + recurring_days
|
||||
@ -1359,21 +1299,17 @@ class Database:
|
||||
for row in rows:
|
||||
key = row['key']
|
||||
value = row['value']
|
||||
# 타입 변환 (bool 우선, 숫자, 문자열 순)
|
||||
lower = (value or '').lower()
|
||||
if lower in ('1', 'true', 'yes', 'on'):
|
||||
settings[key] = True
|
||||
continue
|
||||
if lower in ('0', 'false', 'no', 'off', ''):
|
||||
settings[key] = False
|
||||
continue
|
||||
try:
|
||||
settings[key] = int(value)
|
||||
except ValueError:
|
||||
# 타입 변환
|
||||
if value.lower() in ['true', 'false']:
|
||||
settings[key] = value.lower() == 'true'
|
||||
else:
|
||||
try:
|
||||
settings[key] = float(value)
|
||||
settings[key] = int(value)
|
||||
except ValueError:
|
||||
settings[key] = value
|
||||
try:
|
||||
settings[key] = float(value)
|
||||
except ValueError:
|
||||
settings[key] = value
|
||||
return settings
|
||||
|
||||
def save_settings(self, settings: Dict):
|
||||
@ -1395,8 +1331,7 @@ class Database:
|
||||
pass
|
||||
elif 'work_hours' in synced and 'work_minutes' not in synced:
|
||||
try:
|
||||
# 은행 반올림 회피: 명시적 반올림
|
||||
synced['work_minutes'] = int(float(synced['work_hours']) * 60 + 0.5)
|
||||
synced['work_minutes'] = int(round(float(synced['work_hours']) * 60))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@ -1682,8 +1617,7 @@ class Database:
|
||||
''', (date, leave_type, days, memo))
|
||||
conn.commit()
|
||||
# 잔여 개수 차감 (별도 트랜잭션 — set_leave_balance 내부 commit)
|
||||
# get_leave_balance()가 leave_records를 실시간으로 계산하므로
|
||||
# 별도로 leave_balance 설정값을 갱신할 필요가 없음.
|
||||
self.set_leave_balance(current_balance - days)
|
||||
|
||||
# ===== 공휴일 관련 메서드 =====
|
||||
|
||||
@ -1779,32 +1713,6 @@ class Database:
|
||||
for date, name in fixed_holidays:
|
||||
self.add_holiday(date, name, is_recurring=True)
|
||||
|
||||
def add_korean_holidays_from_api(self, year: int) -> int:
|
||||
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
|
||||
|
||||
임시공휴일 + 근로자의 날 등 holidays 패키지가 놓치는 항목까지 포함.
|
||||
네트워크 실패 시 -1 반환 → 호출자 fallback.
|
||||
|
||||
Returns:
|
||||
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 시 -1.
|
||||
"""
|
||||
try:
|
||||
from utils.holiday_api import fetch_korean_holidays
|
||||
except ImportError:
|
||||
return -1
|
||||
items = fetch_korean_holidays(year)
|
||||
if items is None:
|
||||
return -1
|
||||
added = 0
|
||||
for it in items:
|
||||
if not it.get('is_holiday'):
|
||||
continue
|
||||
date_str = it['date']
|
||||
if not self.is_holiday(date_str):
|
||||
self.add_holiday(date_str, it['name'], is_recurring=False)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
|
||||
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
|
||||
|
||||
@ -1830,33 +1738,30 @@ class Database:
|
||||
Returns:
|
||||
추가된 공휴일 개수. 패키지 미설치 시 -1.
|
||||
"""
|
||||
try:
|
||||
import holidays as _holidays
|
||||
except ImportError:
|
||||
return -1
|
||||
|
||||
years_to_add = [year]
|
||||
if include_next_year:
|
||||
years_to_add.append(year + 1)
|
||||
|
||||
added = 0
|
||||
for y in years_to_add:
|
||||
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
|
||||
api_count = self.add_korean_holidays_from_api(y)
|
||||
if api_count >= 0:
|
||||
added += api_count
|
||||
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
|
||||
continue
|
||||
|
||||
# 2차 fallback: holidays 패키지
|
||||
try:
|
||||
import holidays as _holidays
|
||||
kr = _holidays.country_holidays('KR', years=y)
|
||||
except Exception as e:
|
||||
dlog(f"holidays package fallback failed for {y}: {e}")
|
||||
continue # 둘 다 실패면 해당 연도만 스킵
|
||||
except Exception:
|
||||
continue # 패키지 내부 오류는 해당 연도만 스킵
|
||||
for d, name in kr.items():
|
||||
date_str = d.isoformat()
|
||||
if not self.is_holiday(date_str):
|
||||
self.add_holiday(date_str, name, is_recurring=False)
|
||||
added += 1
|
||||
|
||||
# holidays.KR이 누락하는 근로자의 날 명시적 보강
|
||||
# holidays.KR이 누락하는 한국 노동자 휴일 보강.
|
||||
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
|
||||
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
|
||||
extra = [(f"{y}-05-01", "근로자의 날")]
|
||||
for date_str, name in extra:
|
||||
if not self.is_holiday(date_str):
|
||||
|
||||
1932
core/i18n.py
1932
core/i18n.py
File diff suppressed because it is too large
Load Diff
@ -15,8 +15,6 @@ from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
'mon': 0, 'monday': 0,
|
||||
@ -27,7 +25,6 @@ _WEEKDAY_MAP = {
|
||||
'sat': 5, 'saturday': 5,
|
||||
'sun': 6, 'sunday': 6,
|
||||
}
|
||||
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -138,16 +135,19 @@ def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||
return None
|
||||
|
||||
|
||||
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
|
||||
def describe_pattern(pattern: str) -> str:
|
||||
"""사용자에게 보여줄 패턴 설명."""
|
||||
"""사용자에게 보여줄 패턴 설명. ko."""
|
||||
parsed = _parse_pattern(pattern)
|
||||
if parsed is None:
|
||||
return pattern
|
||||
kind, info = parsed
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
names = [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))
|
||||
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
||||
prefix = '매주' if kind == 'weekly' else '격주'
|
||||
return f"{prefix} {','.join(names)}요일"
|
||||
if kind == 'monthly':
|
||||
return tr('recurring.pattern_monthly', day=info)
|
||||
return f"매월 {info}일"
|
||||
return pattern
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.12.0'
|
||||
__version__ = '2.9.0'
|
||||
|
||||
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.
5
main.py
5
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():
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
"""
|
||||
pytest 공통 설정.
|
||||
|
||||
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
@ -4,15 +4,13 @@ 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
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
||||
|
||||
|
||||
class TestNormalizeTime:
|
||||
@ -127,54 +125,3 @@ class TestParseCsv:
|
||||
assert '줄 3' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
class TestImportRecords:
|
||||
def _db(self):
|
||||
p = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||
p.close()
|
||||
db = Database(p.name)
|
||||
db.save_settings({
|
||||
'work_minutes': '480',
|
||||
'lunch_duration_minutes': '60',
|
||||
'dinner_duration_minutes': '60',
|
||||
})
|
||||
return db, p.name
|
||||
|
||||
def test_overwrite_clears_overtime_usage(self):
|
||||
"""CSV 덮어쓰기 시 overtime_usage도 삭제되어 잔액이 일관성을 유지해야 함."""
|
||||
db, path = self._db()
|
||||
try:
|
||||
date_str = '2026-04-01'
|
||||
# 기존 기록 + 연장근무 사용 기록 생성
|
||||
wid = db.add_work_record(date_str, '09:00:00')
|
||||
db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120)
|
||||
db.add_overtime_earned(wid, 120, date_str)
|
||||
db.add_overtime_usage(wid, 30, date_str, '테스트')
|
||||
|
||||
# 덮어쓰기 전 잔액
|
||||
balance_before = db.get_total_overtime_balance()
|
||||
assert balance_before == 90 # 120 적립 - 30 사용
|
||||
|
||||
rows = [{
|
||||
'date': date_str,
|
||||
'clock_in': '09:00:00',
|
||||
'clock_out': '18:00:00',
|
||||
'lunch_minutes': 60,
|
||||
'dinner_minutes': 0,
|
||||
'memo': '',
|
||||
}]
|
||||
import_records(db, rows, on_conflict='overwrite')
|
||||
|
||||
# 덮어쓰기 후 연장근무 사용 기록은 삭제되어야 함
|
||||
with db._conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,))
|
||||
assert cur.fetchone()[0] == 0
|
||||
balance_after = db.get_total_overtime_balance()
|
||||
assert balance_after == 0 # 새 기록은 연장근무 없음
|
||||
finally:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@ -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
|
||||
@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save')
|
||||
assert label.text() == '저장'
|
||||
assert label.text() == '💾 저장'
|
||||
|
||||
|
||||
def test_retranslate_after_language_change(qapp, i18n):
|
||||
@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
|
||||
set_language('ko')
|
||||
dlg = QDialog()
|
||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||
assert dlg.windowTitle() == '설정'
|
||||
assert dlg.windowTitle() == '⚙️ 설정'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert dlg.windowTitle() == 'Settings'
|
||||
assert dlg.windowTitle() == '⚙️ Settings'
|
||||
|
||||
|
||||
def test_post_callback_applied(qapp, i18n):
|
||||
@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||
assert label.text() == '[저장]'
|
||||
assert label.text() == '[💾 저장]'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == '[Save]'
|
||||
assert label.text() == '[💾 Save]'
|
||||
|
||||
|
||||
def test_dead_widget_pruned(qapp, i18n):
|
||||
|
||||
@ -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
|
||||
@ -16,9 +16,7 @@ from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
from core.achievements import get_all_with_status, get_stats
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||
|
||||
|
||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||
@ -30,6 +28,7 @@ TIER_THEMES = {
|
||||
'bg_bot': '#241810',
|
||||
'text': '#ffd9a8',
|
||||
'label': '🥉',
|
||||
'name': '브론즈',
|
||||
},
|
||||
'silver': {
|
||||
'border': '#a8a8a8',
|
||||
@ -38,6 +37,7 @@ TIER_THEMES = {
|
||||
'bg_bot': '#1c1c22',
|
||||
'text': '#e8e8f0',
|
||||
'label': '🥈',
|
||||
'name': '실버',
|
||||
},
|
||||
'gold': {
|
||||
'border': '#ffb700',
|
||||
@ -46,6 +46,7 @@ TIER_THEMES = {
|
||||
'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0',
|
||||
'label': '🥇',
|
||||
'name': '골드',
|
||||
},
|
||||
'platinum': {
|
||||
'border': '#7fdbff',
|
||||
@ -54,6 +55,7 @@ TIER_THEMES = {
|
||||
'bg_bot': '#0e1f28',
|
||||
'text': '#c5ecff',
|
||||
'label': '💎',
|
||||
'name': '플래티넘',
|
||||
},
|
||||
'legend': {
|
||||
'border': '#ff6b9d',
|
||||
@ -62,9 +64,20 @@ TIER_THEMES = {
|
||||
'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4',
|
||||
'label': '🌟',
|
||||
'name': '레전드',
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
||||
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
||||
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
||||
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
||||
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
||||
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
||||
'ambition': '야망', 'meta': '메타',
|
||||
}
|
||||
|
||||
|
||||
class AchievementsView(QDialog):
|
||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||
@ -72,13 +85,13 @@ class AchievementsView(QDialog):
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('achieve.title'))
|
||||
self.setWindowTitle("🏆 도전과제")
|
||||
self.setMinimumSize(960, 720)
|
||||
self.resize(1100, 800)
|
||||
self._increment_view_count()
|
||||
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
|
||||
self.setStyleSheet("QDialog { background: #0e0e14; }")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
def _increment_view_count(self) -> None:
|
||||
try:
|
||||
@ -107,23 +120,30 @@ class AchievementsView(QDialog):
|
||||
if a['earned_date'] is None and not a['is_secret']]
|
||||
secret_items = [a for a in all_items if a['is_secret']]
|
||||
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
||||
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||
tr('achieve.tab_in_progress', count=len(in_progress)))
|
||||
f"⚡ 진행 중 · {len(in_progress)}")
|
||||
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||
tr('achieve.tab_completed', count=len(earned_items)))
|
||||
f"✓ 완료 · {len(earned_items)}")
|
||||
self.tabs.addTab(
|
||||
self._build_grid_tab(secret_items, secret_mode=True),
|
||||
tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total'])
|
||||
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
|
||||
)
|
||||
layout.addWidget(self.tabs, 1)
|
||||
|
||||
# === 닫기 버튼 ===
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.setMinimumWidth(100)
|
||||
close_btn.setStyleSheet(button_qss('default'))
|
||||
close_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: #2a2a36; color: #e0e0e8;
|
||||
border: 1px solid #44446a; border-radius: 6px;
|
||||
padding: 8px 20px; font-size: 10pt;
|
||||
}
|
||||
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
|
||||
""")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
@ -133,13 +153,14 @@ class AchievementsView(QDialog):
|
||||
# ----- 헤더 -----
|
||||
def _build_header(self, stats: dict) -> QWidget:
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {tc('panel')};
|
||||
border: 1px solid {tc('border')};
|
||||
container.setStyleSheet("""
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
||||
}
|
||||
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
@ -151,28 +172,22 @@ class AchievementsView(QDialog):
|
||||
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 = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
||||
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>")
|
||||
big.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet(f"color: {tc('border')};")
|
||||
spacer.setStyleSheet("color: #3a3a5a;")
|
||||
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"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
|
||||
f"</div>"
|
||||
)
|
||||
secret_lbl.setTextFormat(Qt.RichText)
|
||||
@ -182,8 +197,8 @@ class AchievementsView(QDialog):
|
||||
|
||||
pct_lbl = QLabel(
|
||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
||||
f"{pct:.1f}%</span></div>"
|
||||
)
|
||||
pct_lbl.setTextFormat(Qt.RichText)
|
||||
@ -199,17 +214,17 @@ class AchievementsView(QDialog):
|
||||
bar.setTextVisible(False)
|
||||
bar.setMinimumHeight(8)
|
||||
bar.setMaximumHeight(8)
|
||||
bar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {tc('panel2')};
|
||||
bar.setStyleSheet("""
|
||||
QProgressBar {
|
||||
background: #1a1a26;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
}
|
||||
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)
|
||||
|
||||
@ -220,7 +235,17 @@ class AchievementsView(QDialog):
|
||||
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet(scroll_qss())
|
||||
scroll.setStyleSheet("""
|
||||
QScrollArea { background: transparent; border: none; }
|
||||
QScrollBar:vertical {
|
||||
background: #1a1a24; width: 10px; border-radius: 5px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #44446a; border-radius: 5px; min-height: 30px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: #6b9eff; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
""")
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
grid = QGridLayout()
|
||||
@ -228,10 +253,10 @@ class AchievementsView(QDialog):
|
||||
grid.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
if not items:
|
||||
empty = QLabel(tr('achieve.empty'))
|
||||
empty = QLabel("(아직 없음)")
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
)
|
||||
grid.addWidget(empty, 0, 0)
|
||||
else:
|
||||
@ -254,18 +279,11 @@ class AchievementsView(QDialog):
|
||||
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')
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
||||
border = '#3a3a4a'
|
||||
text_color = '#666'
|
||||
else:
|
||||
bg_top = theme['bg_top']
|
||||
bg_bot = theme['bg_bot']
|
||||
@ -324,19 +342,19 @@ class AchievementsView(QDialog):
|
||||
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"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
name.setWordWrap(True)
|
||||
name_box.addWidget(name)
|
||||
|
||||
cat_text = tr(f"achieve.cat_{item['category']}")
|
||||
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
|
||||
if not is_locked_secret:
|
||||
cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} · {cat_text} ")
|
||||
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
||||
cat_label.setStyleSheet(
|
||||
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"color: {theme['border_strong']}; "
|
||||
f"background: rgba(255,255,255,0.05); "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 8px; "
|
||||
f"padding: 1px 4px;"
|
||||
@ -354,24 +372,24 @@ class AchievementsView(QDialog):
|
||||
|
||||
# 2행: 설명
|
||||
if is_locked_secret:
|
||||
desc_text = tr('achieve.secret_locked')
|
||||
desc_text = "🔒 달성하면 공개됩니다"
|
||||
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"color: #a0a0b8; 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 = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
||||
earned.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||
f"color: {theme['border_strong']}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
|
||||
f"background: rgba(255,255,255,0.08); "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 6px; padding: 4px 8px;"
|
||||
)
|
||||
@ -397,7 +415,7 @@ class AchievementsView(QDialog):
|
||||
pb.setMaximumHeight(10)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}}
|
||||
@ -411,7 +429,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
num = QLabel(f"{progress} / {target}")
|
||||
num.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
|
||||
f"color: {theme['border_strong']}; font-size: 9pt; "
|
||||
f"font-weight: bold; background: transparent; border: none;"
|
||||
)
|
||||
num.setMinimumWidth(60)
|
||||
@ -435,5 +453,32 @@ class AchievementsView(QDialog):
|
||||
|
||||
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||
def _tabs_qss(self) -> str:
|
||||
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
|
||||
return tabs_qss(ACCENT_GOLD)
|
||||
return """
|
||||
QTabWidget::pane {
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #1c1c28;
|
||||
color: #a0a0b8;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #14141c;
|
||||
color: #ffd24a;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ffd24a;
|
||||
}
|
||||
QTabBar::tab:hover:!selected {
|
||||
background: #2a2a36;
|
||||
color: #e0e0e8;
|
||||
}
|
||||
"""
|
||||
|
||||
@ -12,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)
|
||||
@ -56,16 +55,15 @@ class CalendarView(QDialog):
|
||||
# 범례
|
||||
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 +77,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 +94,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)
|
||||
@ -165,23 +163,21 @@ class CalendarView(QDialog):
|
||||
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))
|
||||
edit_action = menu.addAction(f"✏️ {date_str} 편집")
|
||||
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
|
||||
else:
|
||||
add_action = menu.addAction(tr('cal.context_add', date=date_str))
|
||||
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
|
||||
|
||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||
if action is None:
|
||||
return
|
||||
|
||||
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||
if action == edit_action:
|
||||
if existing and action.text().startswith("✏️"):
|
||||
self._open_edit_dialog(date_str)
|
||||
elif action == delete_action:
|
||||
elif existing and action.text().startswith("🗑️"):
|
||||
self._delete_record(date_str)
|
||||
elif action == add_action:
|
||||
elif not existing and action.text().startswith("➕"):
|
||||
self._add_past_record(date_str)
|
||||
|
||||
def _add_past_record(self, date_str: str):
|
||||
@ -218,9 +214,9 @@ class CalendarView(QDialog):
|
||||
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))
|
||||
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e))
|
||||
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
|
||||
|
||||
def _open_edit_dialog(self, date_str: str):
|
||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||
@ -232,8 +228,8 @@ class CalendarView(QDialog):
|
||||
|
||||
def _delete_record(self, date_str: str):
|
||||
reply = QMessageBox.question(
|
||||
tr('cal.delete_confirm_title'),
|
||||
tr('cal.delete_confirm_body', date=date_str),
|
||||
self, "삭제 확인",
|
||||
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
@ -246,10 +242,10 @@ class CalendarView(QDialog):
|
||||
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))
|
||||
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
|
||||
QMessageBox.critical(self, "오류", str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@ -271,33 +267,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 +303,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 +316,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 +329,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 +350,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 +397,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 +406,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 +429,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 +438,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 +455,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 +464,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 +510,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 +594,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 +610,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,52 +9,25 @@ 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
|
||||
# 다크 테마 색상 (dark_components 톤과 일치)
|
||||
_CHART_BG = '#14141c'
|
||||
_CHART_GRID = '#2a2a3a'
|
||||
_CHART_TEXT = '#c0c0d0'
|
||||
_CHART_BAR_NORMAL = '#6b9eff' # blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink
|
||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold
|
||||
_CHART_AVG_LINE = '#4ade80' # green
|
||||
|
||||
|
||||
def _apply_dark_axes(ax) -> None:
|
||||
@ -70,8 +43,7 @@ def _apply_dark_axes(ax) -> None:
|
||||
|
||||
|
||||
def _apply_dark_figure(fig) -> None:
|
||||
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
|
||||
_refresh_chart_colors()
|
||||
"""figure 배경을 다크 톤으로."""
|
||||
fig.patch.set_facecolor(_CHART_BG)
|
||||
|
||||
|
||||
@ -83,7 +55,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,8 +63,7 @@ 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()
|
||||
@ -117,7 +88,7 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
@ -129,10 +100,10 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'),
|
||||
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
||||
color=_CHART_BAR_OVERTIME)
|
||||
ax.set_ylabel(tr('chart.ylabel_hours'))
|
||||
ax.set_ylabel('시간')
|
||||
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||
@ -159,10 +130,9 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
for i, bar in enumerate(bars):
|
||||
if bar.contains(event)[0]:
|
||||
h = hours[i]; ot = overtimes[i]
|
||||
text = tr('chart.hover_text',
|
||||
date=full_dates[i], hours=f"{h:.1f}")
|
||||
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
||||
if ot > 0:
|
||||
text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}")
|
||||
text += f"\n연장 +{ot:.1f}h"
|
||||
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
||||
annot.set_text(text)
|
||||
annot.set_visible(True)
|
||||
@ -194,7 +164,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
@ -214,7 +184,7 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
if not minutes_list:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
@ -228,13 +198,12 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||
edgecolor=_CHART_BG, linewidth=1)
|
||||
avg = sum(minutes_list) / len(minutes_list)
|
||||
avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}"
|
||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||
label=tr('chart.avg_line', time=avg_time))
|
||||
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||
ax.set_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'))
|
||||
ax.set_ylabel('일수')
|
||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
_apply_dark_axes(ax)
|
||||
@ -261,13 +230,11 @@ 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 # 주말 골드 강조
|
||||
ax.bar(labels, avg, color=colors)
|
||||
ax.set_ylabel(tr('chart.ylabel_avg_hours'))
|
||||
ax.set_ylabel('평균 시간')
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
@ -134,8 +134,8 @@ if __name__ == "__main__":
|
||||
dialog = ClockInDialog()
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
selected_time = dialog.get_time()
|
||||
print(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:
|
||||
@ -62,23 +61,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()
|
||||
|
||||
@ -10,7 +10,6 @@ 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
|
||||
|
||||
|
||||
@ -55,12 +54,12 @@ class NotificationOrchestrator:
|
||||
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
||||
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
||||
|
||||
title = 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)
|
||||
title = "📊 지난주 요약"
|
||||
body = (f"기간: {last_mon} ~ {last_sun}\n"
|
||||
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
|
||||
f"일 평균: {avg_h:.1f}시간\n"
|
||||
f"연장근무: {ot_h}시간 {ot_m}분\n"
|
||||
f"가장 긴 날: {longest_str}")
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
self.db.log_notification('system', 'weekly_report')
|
||||
|
||||
@ -71,17 +70,13 @@ class NotificationOrchestrator:
|
||||
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},
|
||||
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
|
||||
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
|
||||
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
|
||||
{"name": "가장 긴 날", "value": longest_str, "inline": False},
|
||||
]
|
||||
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),
|
||||
ok = send(url, "📊 지난주 요약",
|
||||
f"기간: {last_mon} ~ {last_sun}",
|
||||
color=COLOR_BLUE, fields=fields)
|
||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||
except Exception as e:
|
||||
@ -152,8 +147,8 @@ class NotificationOrchestrator:
|
||||
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)
|
||||
title = f"{a.badge_icon} 도전과제 달성!"
|
||||
body = f"{a.name}\n{a.description}"
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
# Discord 통합 push (여러 개면 묶어서)
|
||||
self._discord_achievements(unlocked)
|
||||
@ -172,8 +167,8 @@ class NotificationOrchestrator:
|
||||
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),
|
||||
f"🏆 도전과제 {len(unlocked)}개 달성!",
|
||||
f"새로 잠금 해제된 도전과제 입니다.{extra}",
|
||||
color=discord_webhook.COLOR_YELLOW,
|
||||
fields=fields,
|
||||
)
|
||||
|
||||
@ -19,24 +19,22 @@ 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'
|
||||
DARK_BG = '#0e0e14'
|
||||
DARK_PANEL = '#14141c'
|
||||
DARK_PANEL_2 = '#1c1c28'
|
||||
DARK_BORDER = '#2a2a3a'
|
||||
DARK_BORDER_STRONG = '#44446a'
|
||||
DARK_TEXT = '#e8e8f4'
|
||||
DARK_TEXT_DIM = '#a0a0b8'
|
||||
DARK_TEXT_FAINT = '#666680'
|
||||
|
||||
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||
ACCENT_GOLD = '#ffd24a'
|
||||
ACCENT_BLUE = '#4DABF7'
|
||||
ACCENT_BLUE = '#6b9eff'
|
||||
ACCENT_CYAN = '#4adef0'
|
||||
ACCENT_PINK = '#ff90b8'
|
||||
ACCENT_GREEN = '#51CF66'
|
||||
ACCENT_GREEN = '#4ade80'
|
||||
ACCENT_ORANGE = '#fcd34d'
|
||||
ACCENT_RED = '#FA5252'
|
||||
ACCENT_RED = '#fb7185'
|
||||
|
||||
# 카드 테마 (등급/상태별)
|
||||
CARD_THEMES = {
|
||||
@ -78,59 +76,26 @@ CARD_THEMES = {
|
||||
}
|
||||
|
||||
|
||||
# ── 테마 연동 ──────────────────────────────────────────────────
|
||||
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
||||
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
|
||||
|
||||
def _pal() -> dict:
|
||||
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
|
||||
from ui.styles import ThemeColors
|
||||
g = ThemeColors.get
|
||||
return {
|
||||
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
|
||||
'border': g('border_subtle'), 'border_strong': g('border_default'),
|
||||
'text': g('text_primary'), 'text_dim': g('text_secondary'),
|
||||
'text_faint': g('text_tertiary'),
|
||||
'blue': g('accent_primary'), 'green': g('accent_success'),
|
||||
'red': g('accent_danger'),
|
||||
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
|
||||
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
|
||||
}
|
||||
|
||||
|
||||
def _is_dark() -> bool:
|
||||
from ui.styles import ThemeColors, DARK_COLORS
|
||||
return ThemeColors.current is DARK_COLORS
|
||||
|
||||
|
||||
def tc(role: str) -> str:
|
||||
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
|
||||
return _pal().get(role, '#FF00FF')
|
||||
|
||||
|
||||
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
def dialog_qss() -> str:
|
||||
"""다이얼로그 전체 배경 (현재 테마)."""
|
||||
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
||||
"""다이얼로그 전체 배경."""
|
||||
return f"QDialog {{ background: {DARK_BG}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = None) -> str:
|
||||
p = _pal()
|
||||
if accent is None:
|
||||
accent = p['blue']
|
||||
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
background: {DARK_PANEL};
|
||||
border: 1px solid {DARK_BORDER};
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {p['panel2']};
|
||||
color: {p['text_dim']};
|
||||
background: {DARK_PANEL_2};
|
||||
color: {DARK_TEXT_DIM};
|
||||
padding: 9px 18px;
|
||||
border: 1px solid {p['border']};
|
||||
border: 1px solid {DARK_BORDER};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
@ -138,90 +103,88 @@ def tabs_qss(accent: str = None) -> str:
|
||||
font-size: 10pt;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {p['panel']};
|
||||
background: {DARK_PANEL};
|
||||
color: {accent};
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid {accent};
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
background: {p['border_strong']};
|
||||
color: {p['text']};
|
||||
background: #2a2a36;
|
||||
color: {DARK_TEXT};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scroll_qss() -> str:
|
||||
p = _pal()
|
||||
return f"""
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
QScrollBar:vertical {{
|
||||
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
||||
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
||||
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
|
||||
|
||||
def button_qss(variant: str = 'default') -> str:
|
||||
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
||||
p = _pal()
|
||||
""" variant: default | primary | success | danger | ghost """
|
||||
if variant == 'primary':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['blue']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
background: {ACCENT_BLUE}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
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']}; }}
|
||||
QPushButton:hover {{ background: #82b0ff; }}
|
||||
QPushButton:pressed {{ background: #5a8eee; }}
|
||||
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['green']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
background: {ACCENT_GREEN}; color: #0e2a1a;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['green_hover']}; }}
|
||||
QPushButton:hover {{ background: #6ae899; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['red']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
background: {ACCENT_RED}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['red_hover']}; }}
|
||||
QPushButton:hover {{ background: #fc8896; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {p['text_dim']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
background: transparent; color: {DARK_TEXT_DIM};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
||||
border-color: {p['blue']}; }}
|
||||
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border-color: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
# default
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['panel2']}; color: {p['text']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
|
||||
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
|
||||
|
||||
@ -239,15 +202,15 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
big_color: 큰 숫자 색
|
||||
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||
"""
|
||||
p = _pal()
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 8px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
|
||||
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
||||
""")
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(20, 14, 20, 14)
|
||||
@ -259,13 +222,13 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
if title:
|
||||
t = QLabel(title)
|
||||
t.setStyleSheet(
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"font-size: 9pt; color: {DARK_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"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
|
||||
f" {subtitle}</span>" if subtitle else '')
|
||||
)
|
||||
big.setTextFormat(Qt.RichText)
|
||||
@ -289,49 +252,29 @@ 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};
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||
border: 1px solid {t['border']};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||
""")
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(16, 12, 16, 12)
|
||||
outer.setSpacing(12)
|
||||
|
||||
if icon:
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl = QLabel(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
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()
|
||||
@ -339,13 +282,13 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 9.5pt; color: {p['text_dim']}; "
|
||||
f"font-size: 9.5pt; color: {DARK_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"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
val_lbl.setTextFormat(Qt.RichText)
|
||||
@ -355,7 +298,7 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
if subtitle:
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setStyleSheet(
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
sub_lbl.setWordWrap(True)
|
||||
@ -370,25 +313,16 @@ 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};
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||
border: 1px solid {t['border']};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 14)
|
||||
@ -396,20 +330,15 @@ def build_section_card(title: str, content: QWidget,
|
||||
|
||||
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;"
|
||||
)
|
||||
i = QLabel(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"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(title_lbl)
|
||||
@ -443,11 +372,9 @@ def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
||||
|
||||
|
||||
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||
color: str = None) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
||||
color: str = DARK_TEXT) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
|
||||
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}; "
|
||||
|
||||
@ -9,8 +9,6 @@ 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):
|
||||
"""월간 목표 진행률 표시."""
|
||||
@ -22,13 +20,13 @@ class GoalWidget(QWidget):
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title = QLabel(tr('goal.title'))
|
||||
title = QLabel("🎯 이번 달 목표")
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
self.ot_label = QLabel(tr('goal.overtime'))
|
||||
self.ot_label = QLabel("연장근무:")
|
||||
self.ot_label.setFixedWidth(100)
|
||||
self.ot_bar = QProgressBar()
|
||||
self.ot_bar.setTextVisible(True)
|
||||
@ -39,7 +37,7 @@ class GoalWidget(QWidget):
|
||||
|
||||
# 일평균
|
||||
avg_row = QHBoxLayout()
|
||||
self.avg_label = QLabel(tr('goal.avg_daily'))
|
||||
self.avg_label = QLabel("일평균:")
|
||||
self.avg_label.setFixedWidth(100)
|
||||
self.avg_bar = QProgressBar()
|
||||
self.avg_bar.setTextVisible(True)
|
||||
@ -80,7 +78,7 @@ class GoalWidget(QWidget):
|
||||
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')
|
||||
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
|
||||
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.ot_label.setVisible(False)
|
||||
@ -95,7 +93,7 @@ class GoalWidget(QWidget):
|
||||
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')
|
||||
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
|
||||
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.avg_label.setVisible(False)
|
||||
|
||||
@ -10,7 +10,10 @@ 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
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss,
|
||||
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
|
||||
)
|
||||
|
||||
|
||||
class HelpView(QDialog):
|
||||
@ -34,7 +37,7 @@ class HelpView(QDialog):
|
||||
self.resize(820, 760)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
@ -42,14 +45,15 @@ class HelpView(QDialog):
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# 다크 타이틀
|
||||
title = QLabel(tr('window.help'))
|
||||
title = QLabel(f"📖 {tr('window.help')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
main_layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setDocumentMode(True)
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
for html_key, tab_label_key in self._TABS:
|
||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
||||
@ -59,7 +63,7 @@ class HelpView(QDialog):
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton(tr('help.onboarding_button'))
|
||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
@ -85,7 +89,7 @@ class HelpView(QDialog):
|
||||
|
||||
def _make_tab(self, html: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background: {tc('panel')};")
|
||||
container.setStyleSheet(f"background: {DARK_PANEL};")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
@ -96,21 +100,21 @@ class HelpView(QDialog):
|
||||
browser.setHtml(styled_html)
|
||||
browser.setStyleSheet(f"""
|
||||
QTextBrowser {{
|
||||
background: {tc('panel')};
|
||||
color: {tc('text')};
|
||||
background: {DARK_PANEL};
|
||||
color: {DARK_TEXT};
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 10.5pt;
|
||||
selection-background-color: {tc('blue')};
|
||||
selection-color: #ffffff;
|
||||
selection-background-color: {ACCENT_GOLD};
|
||||
selection-color: #1a1a26;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: {tc('panel')}; width: 10px; border-radius: 5px;
|
||||
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
|
||||
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
|
||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
""")
|
||||
layout.addWidget(browser)
|
||||
@ -119,67 +123,61 @@ class HelpView(QDialog):
|
||||
|
||||
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};
|
||||
color: #e8e8f4;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
h1, h2, h3, h4 {{
|
||||
color: {blue};
|
||||
color: #ffd24a;
|
||||
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}; }}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: #6b9eff; }}
|
||||
h4 {{ font-size: 11pt; color: #4ade80; }}
|
||||
b, strong {{ color: #ff90b8; }}
|
||||
code {{
|
||||
background: {panel2};
|
||||
color: {blue};
|
||||
background: #1c1c28;
|
||||
color: #ffd24a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
pre {{
|
||||
background: {panel2};
|
||||
border: 1px solid {border};
|
||||
background: #1c1c28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: {text};
|
||||
color: #e8e8f4;
|
||||
}}
|
||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
a {{ color: {blue}; text-decoration: none; }}
|
||||
a {{ color: #4adef0; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||
th {{
|
||||
background: {panel2};
|
||||
color: {text};
|
||||
background: #2a2a3a;
|
||||
color: #ffd24a;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid {border};
|
||||
border: 1px solid #44446a;
|
||||
text-align: left;
|
||||
}}
|
||||
td {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid {border};
|
||||
color: {text};
|
||||
border: 1px solid #2a2a3a;
|
||||
color: #e8e8f4;
|
||||
}}
|
||||
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
|
||||
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
|
||||
blockquote {{
|
||||
border-left: 3px solid {blue};
|
||||
border-left: 3px solid #6b9eff;
|
||||
margin-left: 0;
|
||||
padding: 4px 16px;
|
||||
color: {dim};
|
||||
color: #a0a0b8;
|
||||
background: rgba(107, 158, 255, 0.05);
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
|
||||
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()
|
||||
@ -13,7 +13,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -23,7 +22,7 @@ class LeaveCalendarView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('leave_cal.title'))
|
||||
self.setWindowTitle("📅 연차 캘린더")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
@ -38,7 +37,7 @@ class LeaveCalendarView(QDialog):
|
||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||
used = total - balance
|
||||
title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
|
||||
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@ -46,11 +45,10 @@ class LeaveCalendarView(QDialog):
|
||||
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
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;")
|
||||
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
|
||||
"🔵 예정", "🔘 종일+예정"]:
|
||||
l = QLabel(label)
|
||||
l.setStyleSheet(f"padding: 2px 6px;")
|
||||
legend.addWidget(l)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
@ -63,13 +61,13 @@ class LeaveCalendarView(QDialog):
|
||||
|
||||
# 선택 일자 정보
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
|
||||
layout.addWidget(self.detail_label)
|
||||
|
||||
# 닫기 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
@ -110,12 +108,12 @@ class LeaveCalendarView(QDialog):
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
match = [r for r in records if r['date'] == date_str]
|
||||
if not match:
|
||||
self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
|
||||
self.detail_label.setText(f"{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))
|
||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
||||
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
|
||||
|
||||
@ -90,8 +90,8 @@ class LeaveView(QDialog):
|
||||
cal_button.clicked.connect(self._show_calendar)
|
||||
button_layout.addWidget(cal_button)
|
||||
|
||||
schedule_button = QPushButton(tr('view.leave.btn_schedule'))
|
||||
schedule_button.setToolTip(tr('view.leave.schedule_tooltip'))
|
||||
schedule_button = QPushButton("🗓️ 스케줄")
|
||||
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||
schedule_button.clicked.connect(self._show_schedule)
|
||||
button_layout.addWidget(schedule_button)
|
||||
|
||||
@ -137,16 +137,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 "")
|
||||
|
||||
@ -365,17 +365,17 @@ class AddLeaveDialog(QDialog):
|
||||
if date_dt.weekday() in (5, 6): # 토/일
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.weekend_register_forbidden_title'),
|
||||
tr('view.leave.weekend_register_forbidden_body')
|
||||
"주말 등록 불가",
|
||||
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
|
||||
)
|
||||
return
|
||||
if self.db.is_holiday(date):
|
||||
holiday = self.db.get_holiday(date)
|
||||
name = (holiday or {}).get('name', tr('label.holiday_default'))
|
||||
name = (holiday or {}).get('name', '공휴일')
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.holiday_register_forbidden_title'),
|
||||
tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
|
||||
"공휴일 등록 불가",
|
||||
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
|
||||
)
|
||||
return
|
||||
|
||||
@ -385,8 +385,9 @@ class AddLeaveDialog(QDialog):
|
||||
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"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
|
||||
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QMessageBox)
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -36,8 +35,8 @@ class MealTimeDialog(QDialog):
|
||||
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))
|
||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 260)
|
||||
|
||||
@ -45,12 +44,13 @@ class MealTimeDialog(QDialog):
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
|
||||
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
|
||||
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||
if clock_in_time is not None:
|
||||
info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M'))
|
||||
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
||||
info = QLabel(info_text)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 합리적 기본값: 출근 이후로 보정
|
||||
@ -63,7 +63,7 @@ class MealTimeDialog(QDialog):
|
||||
|
||||
# 시작
|
||||
start_row = QHBoxLayout()
|
||||
start_row.addWidget(QLabel(tr('meal.label_start')))
|
||||
start_row.addWidget(QLabel("시작:"))
|
||||
self.start_edit = QTimeEdit()
|
||||
self.start_edit.setDisplayFormat("HH:mm")
|
||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||
@ -73,7 +73,7 @@ class MealTimeDialog(QDialog):
|
||||
|
||||
# 종료
|
||||
end_row = QHBoxLayout()
|
||||
end_row.addWidget(QLabel(tr('meal.label_end')))
|
||||
end_row.addWidget(QLabel("종료:"))
|
||||
self.end_edit = QTimeEdit()
|
||||
self.end_edit.setDisplayFormat("HH:mm")
|
||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||
@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
|
||||
|
||||
# 미리보기 라벨
|
||||
self.preview = QLabel("")
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
|
||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
|
||||
layout.addWidget(self.preview)
|
||||
self._update_preview()
|
||||
self.start_edit.timeChanged.connect(self._update_preview)
|
||||
@ -92,10 +92,10 @@ class MealTimeDialog(QDialog):
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
@ -137,24 +137,24 @@ class MealTimeDialog(QDialog):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
self.preview.setText(reason)
|
||||
self.preview.setStyleSheet("color: #FA5252;")
|
||||
self.preview.setText(f"⚠️ {reason}")
|
||||
self.preview.setStyleSheet("color: #f44336;")
|
||||
else:
|
||||
self.preview.setText(tr('meal.preview_total', minutes=minutes))
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
self.preview.setText(f"총 {minutes}분")
|
||||
self.preview.setStyleSheet("color: #4caf50; 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')
|
||||
return False, "시작이 종료보다 늦습니다"
|
||||
if minutes > 8 * 60:
|
||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||
return False, tr('meal.error_too_long')
|
||||
return False, "식사 시간이 8시간을 초과합니다"
|
||||
if self._clock_in is not None and start_dt < self._clock_in:
|
||||
return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M'))
|
||||
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다"
|
||||
if self._clock_out is not None and end_dt > self._clock_out:
|
||||
return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M'))
|
||||
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다"
|
||||
return True, ""
|
||||
|
||||
def accept(self):
|
||||
@ -162,7 +162,7 @@ class MealTimeDialog(QDialog):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, tr('meal.input_error_title'), reason)
|
||||
QMessageBox.warning(self, "입력 오류", reason)
|
||||
return
|
||||
super().accept()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -32,10 +32,17 @@ WORK_PRESETS = [
|
||||
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,8 +51,8 @@ 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)
|
||||
@ -120,20 +127,22 @@ class WorkPatternPage(QWizardPage):
|
||||
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 +159,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 +195,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 +218,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 +257,39 @@ 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')
|
||||
self, "URL 형식 오류",
|
||||
"Discord 웹훅 URL 형식이 아닙니다.\n"
|
||||
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
|
||||
)
|
||||
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 +301,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)
|
||||
@ -300,7 +323,7 @@ class OnboardingWizard(QWizard):
|
||||
# 1. 근무 패턴
|
||||
wm, lm, dm = 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 = {
|
||||
|
||||
@ -66,8 +66,6 @@ class OvertimeView(QDialog):
|
||||
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'))
|
||||
@ -128,7 +126,7 @@ class OvertimeView(QDialog):
|
||||
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, ob.work_record_id, wr.memo
|
||||
FROM overtime_bank ob
|
||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||
ORDER BY ob.date DESC
|
||||
@ -140,7 +138,6 @@ class OvertimeView(QDialog):
|
||||
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
|
||||
@ -151,7 +148,7 @@ class OvertimeView(QDialog):
|
||||
time_str = tr('view.break.duration_min_only', m=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 "")
|
||||
@ -186,7 +183,7 @@ class OvertimeView(QDialog):
|
||||
time_str = tr('view.break.duration_min_only', m=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 "")
|
||||
|
||||
@ -252,46 +249,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)
|
||||
|
||||
@ -9,7 +9,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -19,7 +18,7 @@ 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.setWindowTitle(f"기록 추가 — {date_str}")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 320)
|
||||
|
||||
@ -27,13 +26,13 @@ class PastRecordDialog(QDialog):
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(tr('past_record.info', date=date_str))
|
||||
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
|
||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 출근
|
||||
ci_row = QHBoxLayout()
|
||||
ci_row.addWidget(QLabel(tr('past_record.label_clock_in')))
|
||||
ci_row.addWidget(QLabel("출근:"))
|
||||
self.clock_in_edit = QTimeEdit()
|
||||
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||
self.clock_in_edit.setTime(QTime(9, 0))
|
||||
@ -43,8 +42,8 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 퇴근
|
||||
co_row = QHBoxLayout()
|
||||
co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
|
||||
self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
|
||||
co_row.addWidget(QLabel("퇴근:"))
|
||||
self.clock_out_check = QCheckBox("입력")
|
||||
self.clock_out_check.setChecked(True)
|
||||
self.clock_out_edit = QTimeEdit()
|
||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||
@ -57,18 +56,18 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
||||
self.lunch_check = QCheckBox("🍱 점심시간 포함")
|
||||
self.lunch_check.setChecked(True)
|
||||
self.dinner_check = QCheckBox(tr('past_record.check_dinner'))
|
||||
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
|
||||
meal_row.addWidget(self.lunch_check)
|
||||
meal_row.addWidget(self.dinner_check)
|
||||
meal_row.addStretch()
|
||||
layout.addLayout(meal_row)
|
||||
|
||||
# 메모
|
||||
layout.addWidget(QLabel(tr('past_record.label_memo')))
|
||||
layout.addWidget(QLabel("메모 (선택):"))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
|
||||
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
||||
layout.addWidget(self.memo_edit)
|
||||
|
||||
layout.addStretch()
|
||||
@ -76,10 +75,10 @@ class PastRecordDialog(QDialog):
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self._validate_and_accept)
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
@ -93,8 +92,8 @@ class PastRecordDialog(QDialog):
|
||||
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'))
|
||||
QMessageBox.warning(self, "입력 오류",
|
||||
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
||||
return
|
||||
self.accept()
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
from PyQt5.QtCore import QDate, Qt
|
||||
|
||||
from core.recurring_leaves import describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@ -28,7 +27,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('recurring.title'))
|
||||
self.setWindowTitle("🔁 반복 연차 관리")
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._reload_list()
|
||||
@ -38,29 +37,29 @@ class RecurringLeaveDialog(QDialog):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 기존 패턴 목록
|
||||
list_group = QGroupBox(tr('recurring.list_group'))
|
||||
list_group = QGroupBox("등록된 반복 패턴")
|
||||
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 = QPushButton("선택 삭제")
|
||||
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'))
|
||||
add_group = QGroupBox("신규 패턴 추가")
|
||||
ag = QVBoxLayout()
|
||||
|
||||
# 패턴 종류
|
||||
kind_row = QHBoxLayout()
|
||||
kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
|
||||
kind_row.addWidget(QLabel("주기:"))
|
||||
self.kind_group = QButtonGroup(self)
|
||||
self.rb_weekly = QRadioButton(tr('recurring.weekly'))
|
||||
self.rb_weekly = QRadioButton("매주")
|
||||
self.rb_weekly.setChecked(True)
|
||||
self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
|
||||
self.rb_monthly = QRadioButton(tr('recurring.monthly'))
|
||||
self.rb_biweekly = QRadioButton("격주")
|
||||
self.rb_monthly = QRadioButton("매월 N일")
|
||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||
self.kind_group.addButton(rb)
|
||||
kind_row.addWidget(rb)
|
||||
@ -69,10 +68,10 @@ class RecurringLeaveDialog(QDialog):
|
||||
|
||||
# 요일 체크박스 (weekly/biweekly)
|
||||
wd_row = QHBoxLayout()
|
||||
wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
|
||||
wd_row.addWidget(QLabel("요일:"))
|
||||
self.weekday_checks = []
|
||||
for ko, en in _KO_WEEKDAYS:
|
||||
cb = QCheckBox(tr(f'label.weekday_{en}'))
|
||||
cb = QCheckBox(ko)
|
||||
self.weekday_checks.append((cb, en))
|
||||
wd_row.addWidget(cb)
|
||||
wd_row.addStretch()
|
||||
@ -80,40 +79,40 @@ class RecurringLeaveDialog(QDialog):
|
||||
|
||||
# 매월 N일
|
||||
month_row = QHBoxLayout()
|
||||
month_row.addWidget(QLabel(tr('recurring.label_monthly_day')))
|
||||
month_row.addWidget(QLabel("매월:"))
|
||||
self.day_of_month = QSpinBox()
|
||||
self.day_of_month.setRange(1, 31)
|
||||
self.day_of_month.setValue(15)
|
||||
self.day_of_month.setSuffix(tr('recurring.day_suffix'))
|
||||
self.day_of_month.setSuffix("일")
|
||||
month_row.addWidget(self.day_of_month)
|
||||
month_row.addStretch()
|
||||
ag.addLayout(month_row)
|
||||
|
||||
# 차감 일수
|
||||
days_row = QHBoxLayout()
|
||||
days_row.addWidget(QLabel(tr('recurring.label_deduction')))
|
||||
days_row.addWidget(QLabel("차감:"))
|
||||
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)
|
||||
self.days_combo.addItem("1.0일 (종일)", 1.0)
|
||||
self.days_combo.addItem("0.5일 (반차)", 0.5)
|
||||
self.days_combo.addItem("0.25일 (반반차)", 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')))
|
||||
date_row.addWidget(QLabel("시작:"))
|
||||
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')))
|
||||
date_row.addWidget(QLabel("종료:"))
|
||||
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 = QCheckBox("종료 없음 (무기한)")
|
||||
self.no_end_check.toggled.connect(
|
||||
lambda v: self.end_edit.setEnabled(not v)
|
||||
)
|
||||
@ -123,14 +122,14 @@ class RecurringLeaveDialog(QDialog):
|
||||
|
||||
# 메모
|
||||
memo_row = QHBoxLayout()
|
||||
memo_row.addWidget(QLabel(tr('recurring.label_memo')))
|
||||
memo_row.addWidget(QLabel("메모:"))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
|
||||
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
|
||||
memo_row.addWidget(self.memo_edit)
|
||||
ag.addLayout(memo_row)
|
||||
|
||||
# 추가 버튼
|
||||
add_btn = QPushButton(tr('recurring.btn_add'))
|
||||
add_btn = QPushButton("➕ 추가")
|
||||
add_btn.setObjectName("btn_primary")
|
||||
add_btn.clicked.connect(self._save)
|
||||
ag.addWidget(add_btn)
|
||||
@ -139,7 +138,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
layout.addWidget(add_group)
|
||||
|
||||
# 닫기
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
@ -149,7 +148,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
self.list_widget.clear()
|
||||
for r in self.db.get_recurring_leaves():
|
||||
desc = describe_pattern(r['pattern'])
|
||||
end = r.get('end_date') or tr('recurring.no_end')
|
||||
end = r.get('end_date') or '무기한'
|
||||
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||
f"· {r['start_date']} ~ {end}")
|
||||
if r.get('memo'):
|
||||
@ -164,8 +163,8 @@ class RecurringLeaveDialog(QDialog):
|
||||
return
|
||||
rec_id = item.data(Qt.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, tr('recurring.delete_confirm_title'),
|
||||
tr('recurring.delete_confirm_body', item=item.text()),
|
||||
self, "삭제 확인",
|
||||
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -185,7 +184,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
def _save(self):
|
||||
pattern = self._build_pattern()
|
||||
if not pattern:
|
||||
QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
|
||||
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
|
||||
return
|
||||
days = self.days_combo.currentData()
|
||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||
@ -194,7 +193,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
memo = self.memo_edit.text().strip()
|
||||
|
||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||
QMessageBox.information(self, tr('recurring.add_done_title'),
|
||||
tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
|
||||
QMessageBox.information(self, "추가 완료",
|
||||
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
|
||||
self.memo_edit.clear()
|
||||
self._reload_list()
|
||||
|
||||
@ -18,7 +18,6 @@ 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
|
||||
|
||||
|
||||
@ -39,7 +38,7 @@ class ScheduleView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('schedule.title'))
|
||||
self.setWindowTitle("🗓️ 스케줄")
|
||||
self.setMinimumSize(820, 560)
|
||||
self._build_ui()
|
||||
self._reload()
|
||||
@ -50,16 +49,16 @@ class ScheduleView(QDialog):
|
||||
|
||||
# 상단 툴바
|
||||
bar = QHBoxLayout()
|
||||
title = QLabel(tr('schedule.header'))
|
||||
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
bar.addWidget(title)
|
||||
bar.addStretch()
|
||||
|
||||
rec_btn = QPushButton(tr('schedule.btn_recurring'))
|
||||
rec_btn = QPushButton("🔁 반복 패턴 관리")
|
||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||
bar.addWidget(rec_btn)
|
||||
|
||||
add_btn = QPushButton(tr('schedule.btn_add_leave'))
|
||||
add_btn = QPushButton("➕ 연차 등록")
|
||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||
bar.addWidget(add_btn)
|
||||
|
||||
@ -67,11 +66,11 @@ class ScheduleView(QDialog):
|
||||
|
||||
# 범례
|
||||
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)]:
|
||||
for label, color in [("공휴일", _C_HOLIDAY),
|
||||
("연차 사용", _C_LEAVE_FULL_PAST),
|
||||
("연차 예정", _C_LEAVE_FULL_PLAN),
|
||||
("반차/반반차", _C_LEAVE_HALF_PAST),
|
||||
("반복 패턴", _C_RECURRING)]:
|
||||
sw = QLabel(f" {label} ")
|
||||
sw.setStyleSheet(
|
||||
f"background-color: {color.name()}; color: white; "
|
||||
@ -95,7 +94,7 @@ class ScheduleView(QDialog):
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
|
||||
self.detail_title = QLabel(tr('schedule.detail_placeholder'))
|
||||
self.detail_title = QLabel("날짜를 선택하세요")
|
||||
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
right_layout.addWidget(self.detail_title)
|
||||
|
||||
@ -110,7 +109,7 @@ class ScheduleView(QDialog):
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
@ -190,19 +189,18 @@ class ScheduleView(QDialog):
|
||||
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')})")
|
||||
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
|
||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
|
||||
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 = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
|
||||
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()]))
|
||||
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 연차 (구체)
|
||||
@ -210,7 +208,7 @@ class ScheduleView(QDialog):
|
||||
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)
|
||||
label = f"📌 {t} {days}일"
|
||||
if memo:
|
||||
label += f" — {memo}"
|
||||
label += f" [id={r['id']}]"
|
||||
@ -223,14 +221,13 @@ class ScheduleView(QDialog):
|
||||
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)
|
||||
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({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'))
|
||||
self.detail_list.addItem("일정 없음")
|
||||
|
||||
def _on_page_change(self, year: int, month: int):
|
||||
self._reload()
|
||||
@ -244,7 +241,7 @@ class ScheduleView(QDialog):
|
||||
return
|
||||
kind, _id = data
|
||||
menu = QMenu(self)
|
||||
del_act = menu.addAction(tr('schedule.delete'))
|
||||
del_act = menu.addAction("삭제")
|
||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._delete_record(kind, _id)
|
||||
@ -252,8 +249,8 @@ class ScheduleView(QDialog):
|
||||
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'),
|
||||
self, "삭제 확인",
|
||||
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -264,8 +261,8 @@ class ScheduleView(QDialog):
|
||||
self._on_date_click(d)
|
||||
elif kind == 'recurring':
|
||||
reply = QMessageBox.question(
|
||||
self, tr('schedule.delete_recurring_confirm_title'),
|
||||
tr('schedule.delete_recurring_confirm_body'),
|
||||
self, "삭제 확인",
|
||||
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
|
||||
@ -58,7 +58,7 @@ class SettingsView(QDialog):
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# 제목
|
||||
title = QLabel(tr('settings.title'))
|
||||
title = QLabel("설정")
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title)
|
||||
@ -137,16 +137,16 @@ class SettingsView(QDialog):
|
||||
|
||||
# 근무 패턴 프리셋
|
||||
preset_layout = QHBoxLayout()
|
||||
preset_label = QLabel(tr('settings.work_pattern'))
|
||||
preset_label = QLabel("근무 패턴:")
|
||||
preset_label.setFixedWidth(130)
|
||||
self.work_preset_combo = QComboBox()
|
||||
# (label, work_minutes, lunch_minutes)
|
||||
self.work_preset_combo.addItem(tr('settings.preset.standard_8h'), (480, 60))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.short_7h30m'), (450, 30))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.short_7h'), (420, 60))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.short_6h'), (360, 30))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.half_4h'), (240, 0))
|
||||
self.work_preset_combo.addItem(tr('settings.preset.custom'), None)
|
||||
self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60))
|
||||
self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30))
|
||||
self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60))
|
||||
self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30))
|
||||
self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0))
|
||||
self.work_preset_combo.addItem("사용자 정의", None)
|
||||
self.work_preset_combo.setFixedWidth(260)
|
||||
self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed)
|
||||
preset_layout.addWidget(preset_label)
|
||||
@ -156,18 +156,18 @@ class SettingsView(QDialog):
|
||||
|
||||
# 하루 기본 근무 시간 (시 + 분 분리 입력)
|
||||
work_hours_layout = QHBoxLayout()
|
||||
work_hours_label = QLabel(tr('settings.daily_work'))
|
||||
work_hours_label = QLabel("하루 기본 근무:")
|
||||
work_hours_label.setFixedWidth(130)
|
||||
self.work_hours_spin = QSpinBox()
|
||||
self.work_hours_spin.setRange(0, 12)
|
||||
self.work_hours_spin.setValue(8)
|
||||
self.work_hours_spin.setSuffix(tr('settings.suffix_hour'))
|
||||
self.work_hours_spin.setSuffix(" 시간")
|
||||
self.work_hours_spin.setFixedWidth(100)
|
||||
self.work_minutes_spin = QSpinBox()
|
||||
self.work_minutes_spin.setRange(0, 59)
|
||||
self.work_minutes_spin.setValue(0)
|
||||
self.work_minutes_spin.setSingleStep(15)
|
||||
self.work_minutes_spin.setSuffix(tr('settings.suffix_minute'))
|
||||
self.work_minutes_spin.setSuffix(" 분")
|
||||
self.work_minutes_spin.setFixedWidth(100)
|
||||
# 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로
|
||||
self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||
@ -180,34 +180,34 @@ class SettingsView(QDialog):
|
||||
|
||||
# 점심시간 기본값
|
||||
lunch_layout = QHBoxLayout()
|
||||
lunch_label = QLabel(tr('settings.lunch_default'))
|
||||
lunch_label = QLabel("점심시간 기본:")
|
||||
lunch_label.setFixedWidth(130)
|
||||
self.lunch_spin = QSpinBox()
|
||||
self.lunch_spin.setRange(0, 120)
|
||||
self.lunch_spin.setValue(60)
|
||||
self.lunch_spin.setSingleStep(5)
|
||||
self.lunch_spin.setSuffix(tr('settings.suffix_minute'))
|
||||
self.lunch_spin.setSuffix(" 분")
|
||||
self.lunch_spin.setFixedWidth(110)
|
||||
self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit)
|
||||
lunch_layout.addWidget(lunch_label)
|
||||
lunch_layout.addWidget(self.lunch_spin)
|
||||
|
||||
# 점심시간 자동 적용
|
||||
self.auto_lunch_check = QCheckBox(tr('settings.auto_apply'))
|
||||
self.auto_lunch_check.setToolTip(tr('settings.auto_apply_tooltip'))
|
||||
self.auto_lunch_check = QCheckBox("자동 적용")
|
||||
self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용")
|
||||
lunch_layout.addWidget(self.auto_lunch_check)
|
||||
lunch_layout.addStretch()
|
||||
layout.addLayout(lunch_layout)
|
||||
|
||||
# 저녁시간 기본값
|
||||
dinner_layout = QHBoxLayout()
|
||||
dinner_label = QLabel(tr('settings.dinner_default'))
|
||||
dinner_label = QLabel("저녁시간 기본:")
|
||||
dinner_label.setFixedWidth(130)
|
||||
self.dinner_spin = QSpinBox()
|
||||
self.dinner_spin.setRange(0, 120)
|
||||
self.dinner_spin.setValue(60)
|
||||
self.dinner_spin.setSingleStep(5)
|
||||
self.dinner_spin.setSuffix(tr('settings.suffix_minute'))
|
||||
self.dinner_spin.setSuffix(" 분")
|
||||
self.dinner_spin.setFixedWidth(110)
|
||||
dinner_layout.addWidget(dinner_label)
|
||||
dinner_layout.addWidget(self.dinner_spin)
|
||||
@ -306,30 +306,30 @@ class SettingsView(QDialog):
|
||||
|
||||
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||
check_row1 = QHBoxLayout()
|
||||
self.clock_out_notification_check = QCheckBox(tr('settings.notif_clock_out'))
|
||||
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
||||
self.clock_out_notification_check.setChecked(True)
|
||||
self.lunch_notification_check = QCheckBox(tr('settings.notif_lunch'))
|
||||
self.lunch_notification_check = QCheckBox("점심시간 등록 알림")
|
||||
self.lunch_notification_check.setChecked(True)
|
||||
check_row1.addWidget(self.clock_out_notification_check)
|
||||
check_row1.addWidget(self.lunch_notification_check)
|
||||
layout.addLayout(check_row1)
|
||||
|
||||
check_row2 = QHBoxLayout()
|
||||
self.dinner_notification_check = QCheckBox(tr('settings.notif_dinner'))
|
||||
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
||||
self.dinner_notification_check.setChecked(True)
|
||||
self.overtime_notification_check = QCheckBox(tr('settings.notif_overtime'))
|
||||
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
||||
self.overtime_notification_check.setChecked(True)
|
||||
check_row2.addWidget(self.dinner_notification_check)
|
||||
check_row2.addWidget(self.overtime_notification_check)
|
||||
layout.addLayout(check_row2)
|
||||
|
||||
check_row3 = QHBoxLayout()
|
||||
self.health_notification_check = QCheckBox(tr('settings.notif_health'))
|
||||
self.health_notification_check = QCheckBox("건강 경고 알림")
|
||||
self.health_notification_check.setChecked(True)
|
||||
self.health_break_notification_check = QCheckBox(tr('settings.notif_break'))
|
||||
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
|
||||
self.health_break_notification_check.setChecked(True)
|
||||
self.health_break_notification_check.setToolTip(
|
||||
tr('settings.notif_break_tooltip')
|
||||
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
|
||||
)
|
||||
check_row3.addWidget(self.health_notification_check)
|
||||
check_row3.addWidget(self.health_break_notification_check)
|
||||
@ -337,75 +337,75 @@ class SettingsView(QDialog):
|
||||
|
||||
# 퇴근 N분 전 알림 시점 설정
|
||||
before_row = QHBoxLayout()
|
||||
before_label = QLabel(tr('settings.notif_before'))
|
||||
before_label = QLabel("퇴근 알림 시점:")
|
||||
before_label.setFixedWidth(110)
|
||||
self.notif_before_spin = QSpinBox()
|
||||
self.notif_before_spin.setRange(1, 120)
|
||||
self.notif_before_spin.setSingleStep(5)
|
||||
self.notif_before_spin.setValue(30)
|
||||
self.notif_before_spin.setSuffix(' ' + tr('settings.notif_before_spin_suffix'))
|
||||
self.notif_before_spin.setSuffix(" 분 전")
|
||||
self.notif_before_spin.setFixedWidth(110)
|
||||
self.notif_before_spin.setToolTip(tr('settings.notif_before_tooltip'))
|
||||
self.notif_before_spin.setToolTip("퇴근 임박 알림이 표시될 시점 (분 단위)")
|
||||
before_row.addWidget(before_label)
|
||||
before_row.addWidget(self.notif_before_spin)
|
||||
before_row.addStretch()
|
||||
layout.addLayout(before_row)
|
||||
|
||||
# === 고급 임계값 (접이식 그룹박스) ===
|
||||
adv_box = QGroupBox(tr('settings.advanced_thresholds'))
|
||||
adv_box = QGroupBox("고급 임계값")
|
||||
adv_box.setCheckable(True)
|
||||
adv_box.setChecked(False) # 기본 접힘
|
||||
adv_box.setToolTip(tr('settings.advanced_thresholds_tooltip'))
|
||||
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
|
||||
adv_layout = QVBoxLayout()
|
||||
adv_layout.setSpacing(4)
|
||||
|
||||
# 점심 알림 임계 (출근 후 N시간)
|
||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
||||
self.lunch_reminder_spin.setToolTip(tr('settings.lunch_alert_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.lunch_alert_after'), self.lunch_reminder_spin))
|
||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
|
||||
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
|
||||
|
||||
# 저녁 알림 임계 (출근 후 N시간)
|
||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, tr('settings.suffix_hour'))
|
||||
self.dinner_reminder_spin.setToolTip(tr('settings.dinner_alert_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.dinner_alert_after'), self.dinner_reminder_spin))
|
||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
|
||||
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
|
||||
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
|
||||
|
||||
# 연장근무 누적 임계
|
||||
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, tr('settings.suffix_hour'))
|
||||
self.overtime_threshold_spin.setToolTip(tr('settings.overtime_alert_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.overtime_alert_at'), self.overtime_threshold_spin))
|
||||
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
|
||||
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
|
||||
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
|
||||
|
||||
# 주 X시간 임계
|
||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, tr('settings.suffix_hour'))
|
||||
self.weekly_hours_spin.setToolTip(tr('settings.weekly_limit_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.weekly_limit'), self.weekly_hours_spin))
|
||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
|
||||
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
|
||||
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
|
||||
|
||||
# 연속 연장근무 일수
|
||||
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, tr('label.unit_day'))
|
||||
self.health_consecutive_spin.setToolTip(tr('settings.consecutive_ot_tooltip', fallback=''))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.consecutive_ot'), self.health_consecutive_spin))
|
||||
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, " 일")
|
||||
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
|
||||
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
|
||||
|
||||
# 휴식 권고 (연속 근무 시간)
|
||||
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, tr('settings.suffix_hour'))
|
||||
self.health_break_hours_spin.setToolTip(tr('settings.break_after_tooltip'))
|
||||
adv_layout.addLayout(self._labeled_row(tr('settings.break_after'), self.health_break_hours_spin))
|
||||
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
|
||||
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
|
||||
|
||||
adv_box.setLayout(adv_layout)
|
||||
layout.addWidget(adv_box)
|
||||
|
||||
# 시간 형식 + 테마 한 줄에
|
||||
format_row = QHBoxLayout()
|
||||
time_format_label = QLabel(tr('settings.time_format'))
|
||||
time_format_label = QLabel("시간 형식:")
|
||||
time_format_label.setFixedWidth(70)
|
||||
self.time_format_combo = QComboBox()
|
||||
self.time_format_combo.addItem(tr('settings.time_format_24'), "24")
|
||||
self.time_format_combo.addItem(tr('settings.time_format_12'), "12")
|
||||
self.time_format_combo.addItem("24시간 (17:30)", "24")
|
||||
self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12")
|
||||
self.time_format_combo.setFixedWidth(180)
|
||||
|
||||
theme_label = QLabel(tr('settings.theme'))
|
||||
theme_label = QLabel("테마:")
|
||||
theme_label.setFixedWidth(40)
|
||||
self.theme_combo = QComboBox()
|
||||
self.theme_combo.addItem(tr('settings.theme_light'), "light")
|
||||
self.theme_combo.addItem(tr('settings.theme_dark'), "dark")
|
||||
self.theme_combo.addItem("라이트", "light")
|
||||
self.theme_combo.addItem("다크", "dark")
|
||||
self.theme_combo.setFixedWidth(90)
|
||||
self.theme_combo.currentIndexChanged.connect(self.on_theme_changed)
|
||||
|
||||
@ -419,7 +419,7 @@ class SettingsView(QDialog):
|
||||
|
||||
# 접근성: 글꼴 크기 + 고대비
|
||||
a11y_row = QHBoxLayout()
|
||||
a11y_row.addWidget(QLabel(tr('settings.font_scale')))
|
||||
a11y_row.addWidget(QLabel("글꼴 크기:"))
|
||||
self.font_scale_combo = QComboBox()
|
||||
self.font_scale_combo.addItem("100%", "1.0")
|
||||
self.font_scale_combo.addItem("125%", "1.25")
|
||||
@ -427,8 +427,8 @@ class SettingsView(QDialog):
|
||||
self.font_scale_combo.setFixedWidth(90)
|
||||
a11y_row.addWidget(self.font_scale_combo)
|
||||
a11y_row.addSpacing(16)
|
||||
self.high_contrast_check = QCheckBox(tr('settings.high_contrast'))
|
||||
self.high_contrast_check.setToolTip(tr('settings.high_contrast_tooltip'))
|
||||
self.high_contrast_check = QCheckBox("고대비 모드")
|
||||
self.high_contrast_check.setToolTip("검정 배경 + 노란 텍스트 (시각약자/야간)")
|
||||
a11y_row.addWidget(self.high_contrast_check)
|
||||
a11y_row.addStretch()
|
||||
layout.addLayout(a11y_row)
|
||||
@ -436,13 +436,13 @@ class SettingsView(QDialog):
|
||||
# 언어 선택
|
||||
from core.i18n import available_languages, language_label
|
||||
lang_row = QHBoxLayout()
|
||||
lang_label = QLabel(tr('label.language'))
|
||||
lang_label = QLabel("언어 / Language:")
|
||||
lang_label.setFixedWidth(120)
|
||||
self.language_combo = QComboBox()
|
||||
for code in available_languages():
|
||||
self.language_combo.addItem(language_label(code), code)
|
||||
self.language_combo.setFixedWidth(140)
|
||||
self.language_combo.setToolTip(tr('group.language_restart_tooltip', fallback=''))
|
||||
self.language_combo.setToolTip("언어 변경은 재시작 후 완전히 적용됩니다.")
|
||||
lang_row.addWidget(lang_label)
|
||||
lang_row.addWidget(self.language_combo)
|
||||
lang_row.addStretch()
|
||||
@ -459,16 +459,16 @@ class SettingsView(QDialog):
|
||||
|
||||
# 잔액 + 계산 단위 한 줄
|
||||
top_row = QHBoxLayout()
|
||||
self.current_overtime_label = QLabel(tr('settings.current_balance'))
|
||||
self.current_overtime_label = QLabel("현재 잔액: 계산 중...")
|
||||
self.current_overtime_label.setObjectName("badge_success")
|
||||
top_row.addWidget(self.current_overtime_label)
|
||||
top_row.addStretch()
|
||||
|
||||
unit_label = QLabel(tr('settings.calc_unit'))
|
||||
unit_label = QLabel("계산 단위:")
|
||||
self.overtime_unit_combo = QComboBox()
|
||||
self.overtime_unit_combo.addItem(tr('view.overtime.minute_30'), 30)
|
||||
self.overtime_unit_combo.addItem(tr('label.time_hours_minutes', hours=1, minutes=0), 60)
|
||||
self.overtime_unit_combo.addItem(tr('view.overtime.minute_0'), 15)
|
||||
self.overtime_unit_combo.addItem("30분", 30)
|
||||
self.overtime_unit_combo.addItem("1시간", 60)
|
||||
self.overtime_unit_combo.addItem("15분", 15)
|
||||
self.overtime_unit_combo.setFixedWidth(100)
|
||||
top_row.addWidget(unit_label)
|
||||
top_row.addWidget(self.overtime_unit_combo)
|
||||
@ -476,28 +476,28 @@ class SettingsView(QDialog):
|
||||
|
||||
# 초기 연장근무 설정
|
||||
initial_overtime_layout = QHBoxLayout()
|
||||
initial_overtime_label = QLabel(tr('settings.initial_overtime'))
|
||||
initial_overtime_label = QLabel("기존 연장근무:")
|
||||
initial_overtime_label.setFixedWidth(100)
|
||||
self.initial_overtime_hours = QSpinBox()
|
||||
self.initial_overtime_hours.setRange(0, 200)
|
||||
self.initial_overtime_hours.setValue(0)
|
||||
self.initial_overtime_hours.setSuffix(tr('settings.suffix_hour'))
|
||||
self.initial_overtime_hours.setSuffix(" 시간")
|
||||
self.initial_overtime_hours.setFixedWidth(110)
|
||||
|
||||
self.initial_overtime_mins = QSpinBox()
|
||||
self.initial_overtime_mins.setRange(0, 59)
|
||||
self.initial_overtime_mins.setValue(0)
|
||||
self.initial_overtime_mins.setSuffix(tr('settings.suffix_minute'))
|
||||
self.initial_overtime_mins.setSuffix(" 분")
|
||||
self.initial_overtime_mins.setFixedWidth(100)
|
||||
|
||||
apply_overtime_btn = QPushButton(tr('btn.apply'))
|
||||
apply_overtime_btn = QPushButton("적용")
|
||||
apply_overtime_btn.setObjectName("btn_small")
|
||||
apply_overtime_btn.setFixedWidth(50)
|
||||
apply_overtime_btn.clicked.connect(self.apply_initial_overtime)
|
||||
|
||||
self.auto_overtime_check = QCheckBox(tr('settings.auto_bank'))
|
||||
self.auto_overtime_check = QCheckBox("자동 적립")
|
||||
self.auto_overtime_check.setChecked(True)
|
||||
self.auto_overtime_check.setToolTip(tr('settings.auto_bank_tooltip'))
|
||||
self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립")
|
||||
|
||||
initial_overtime_layout.addWidget(initial_overtime_label)
|
||||
initial_overtime_layout.addWidget(self.initial_overtime_hours)
|
||||
@ -507,7 +507,7 @@ class SettingsView(QDialog):
|
||||
initial_overtime_layout.addWidget(self.auto_overtime_check)
|
||||
layout.addLayout(initial_overtime_layout)
|
||||
|
||||
initial_overtime_note = QLabel(tr('settings.initial_overtime_note', fallback=''))
|
||||
initial_overtime_note = QLabel("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)")
|
||||
initial_overtime_note.setObjectName("note_text")
|
||||
layout.addWidget(initial_overtime_note)
|
||||
|
||||
@ -516,22 +516,22 @@ class SettingsView(QDialog):
|
||||
|
||||
def create_goal_group(self) -> QGroupBox:
|
||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||
group = QGroupBox(tr('settings.goal_group'))
|
||||
group = QGroupBox("🎯 월간 목표 (0=비활성)")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
ot_label = QLabel(tr('settings.monthly_ot_cap'))
|
||||
ot_label = QLabel("월 연장근무 상한:")
|
||||
ot_label.setFixedWidth(150)
|
||||
self.goal_ot_h = QSpinBox()
|
||||
self.goal_ot_h.setRange(0, 100)
|
||||
self.goal_ot_h.setSuffix(tr('settings.suffix_hour'))
|
||||
self.goal_ot_h.setSuffix(" 시간")
|
||||
self.goal_ot_h.setFixedWidth(100)
|
||||
self.goal_ot_m = QSpinBox()
|
||||
self.goal_ot_m.setRange(0, 59)
|
||||
self.goal_ot_m.setSingleStep(30)
|
||||
self.goal_ot_m.setSuffix(tr('settings.suffix_minute'))
|
||||
self.goal_ot_m.setSuffix(" 분")
|
||||
self.goal_ot_m.setFixedWidth(90)
|
||||
ot_row.addWidget(ot_label)
|
||||
ot_row.addWidget(self.goal_ot_h)
|
||||
@ -541,18 +541,18 @@ class SettingsView(QDialog):
|
||||
|
||||
# 일평균 목표
|
||||
avg_row = QHBoxLayout()
|
||||
avg_label = QLabel(tr('settings.daily_avg_goal'))
|
||||
avg_label = QLabel("일 평균 근무 목표:")
|
||||
avg_label.setFixedWidth(150)
|
||||
self.goal_avg = QDoubleSpinBox() if False else QSpinBox() # int*10 방식
|
||||
self.goal_avg.setRange(0, 24)
|
||||
self.goal_avg.setSuffix(tr('settings.suffix_hour'))
|
||||
self.goal_avg.setSuffix(" 시간")
|
||||
self.goal_avg.setFixedWidth(100)
|
||||
avg_row.addWidget(avg_label)
|
||||
avg_row.addWidget(self.goal_avg)
|
||||
avg_row.addStretch()
|
||||
layout.addLayout(avg_row)
|
||||
|
||||
note = QLabel(tr('settings.goal_note', fallback=''))
|
||||
note = QLabel("※ 통계 → 월간 탭에서 진행률 확인")
|
||||
note.setObjectName("note_text")
|
||||
layout.addWidget(note)
|
||||
|
||||
@ -567,40 +567,40 @@ class SettingsView(QDialog):
|
||||
|
||||
# 연차 개수 + 남은 연차 한 줄
|
||||
top_row = QHBoxLayout()
|
||||
annual_leave_label = QLabel(tr('settings.annual_leave'))
|
||||
annual_leave_label = QLabel("연간 연차:")
|
||||
annual_leave_label.setFixedWidth(70)
|
||||
self.annual_leave_days = QSpinBox()
|
||||
self.annual_leave_days.setRange(0, 30)
|
||||
self.annual_leave_days.setValue(15)
|
||||
self.annual_leave_days.setSuffix(tr('label.unit_day'))
|
||||
self.annual_leave_days.setSuffix(" 일")
|
||||
self.annual_leave_days.setFixedWidth(100)
|
||||
top_row.addWidget(annual_leave_label)
|
||||
top_row.addWidget(self.annual_leave_days)
|
||||
top_row.addStretch()
|
||||
|
||||
self.remaining_leave_label = QLabel(tr('settings.remaining_leave'))
|
||||
self.remaining_leave_label = QLabel("남은 연차: 계산 중...")
|
||||
self.remaining_leave_label.setObjectName("badge_leave")
|
||||
top_row.addWidget(self.remaining_leave_label)
|
||||
layout.addLayout(top_row)
|
||||
|
||||
# 기존 사용 연차 설정
|
||||
used_leave_layout = QHBoxLayout()
|
||||
used_leave_label = QLabel(tr('settings.used_leave'))
|
||||
used_leave_label = QLabel("기존 사용:")
|
||||
used_leave_label.setFixedWidth(70)
|
||||
self.used_leave_hours = QSpinBox()
|
||||
self.used_leave_hours.setRange(0, 200)
|
||||
self.used_leave_hours.setValue(0)
|
||||
self.used_leave_hours.setSuffix(tr('settings.suffix_hour'))
|
||||
self.used_leave_hours.setSuffix(" 시간")
|
||||
self.used_leave_hours.setFixedWidth(110)
|
||||
|
||||
self.used_leave_mins = QSpinBox()
|
||||
self.used_leave_mins.setRange(0, 59)
|
||||
self.used_leave_mins.setValue(0)
|
||||
self.used_leave_mins.setSuffix(tr('settings.suffix_minute'))
|
||||
self.used_leave_mins.setSuffix(" 분")
|
||||
self.used_leave_mins.setSingleStep(30)
|
||||
self.used_leave_mins.setFixedWidth(100)
|
||||
|
||||
apply_used_leave_btn = QPushButton(tr('btn.apply'))
|
||||
apply_used_leave_btn = QPushButton("적용")
|
||||
apply_used_leave_btn.setObjectName("btn_small")
|
||||
apply_used_leave_btn.setFixedWidth(50)
|
||||
apply_used_leave_btn.clicked.connect(self.apply_used_leave)
|
||||
@ -612,7 +612,7 @@ class SettingsView(QDialog):
|
||||
used_leave_layout.addStretch()
|
||||
layout.addLayout(used_leave_layout)
|
||||
|
||||
used_leave_note = QLabel(tr('settings.used_leave_note', fallback=''))
|
||||
used_leave_note = QLabel("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)")
|
||||
used_leave_note.setObjectName("note_text")
|
||||
layout.addWidget(used_leave_note)
|
||||
|
||||
@ -627,33 +627,33 @@ class SettingsView(QDialog):
|
||||
|
||||
# 공휴일 목록 + 버튼 한 줄
|
||||
button_layout = QHBoxLayout()
|
||||
holiday_list_label = QLabel(tr('settings.registered'))
|
||||
holiday_list_label = QLabel("등록:")
|
||||
button_layout.addWidget(holiday_list_label)
|
||||
|
||||
self.holiday_count_label = QLabel(tr('settings.holiday_count', count=0, year=datetime.now().year))
|
||||
self.holiday_count_label = QLabel("0개")
|
||||
self.holiday_count_label.setObjectName("info_text")
|
||||
button_layout.addWidget(self.holiday_count_label)
|
||||
button_layout.addStretch()
|
||||
|
||||
add_korean_btn = QPushButton(tr('settings.add_korean_holidays'))
|
||||
add_korean_btn = QPushButton("한국 공휴일 (자동)")
|
||||
add_korean_btn.setObjectName("btn_small")
|
||||
add_korean_btn.setToolTip(tr('settings.add_korean_holidays_tooltip'))
|
||||
add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록")
|
||||
add_korean_btn.clicked.connect(self.add_korean_holidays_auto)
|
||||
button_layout.addWidget(add_korean_btn)
|
||||
|
||||
add_custom_btn = QPushButton(tr('btn.add'))
|
||||
add_custom_btn = QPushButton("추가")
|
||||
add_custom_btn.setObjectName("btn_small")
|
||||
add_custom_btn.clicked.connect(self.add_custom_holiday)
|
||||
button_layout.addWidget(add_custom_btn)
|
||||
|
||||
view_holidays_btn = QPushButton(tr('settings.list'))
|
||||
view_holidays_btn = QPushButton("목록")
|
||||
view_holidays_btn.setObjectName("btn_small")
|
||||
view_holidays_btn.clicked.connect(self.view_holidays)
|
||||
button_layout.addWidget(view_holidays_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
holiday_note = QLabel(tr('settings.holiday_note', fallback=''))
|
||||
holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다")
|
||||
holiday_note.setObjectName("note_text")
|
||||
layout.addWidget(holiday_note)
|
||||
|
||||
@ -668,7 +668,7 @@ class SettingsView(QDialog):
|
||||
"""공휴일 개수 표시 업데이트"""
|
||||
current_year = datetime.now().year
|
||||
holidays = self.db.get_holidays_by_year(current_year)
|
||||
self.holiday_count_label.setText(tr('settings.holiday_count', count=len(holidays), year=current_year))
|
||||
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
||||
|
||||
def add_korean_holidays_auto(self):
|
||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||
@ -680,13 +680,18 @@ class SettingsView(QDialog):
|
||||
current_year = now.year
|
||||
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
||||
include_next = now.month >= 11
|
||||
target_label = (tr('settings.korean_holidays_years_label', start=current_year, end=current_year + 1)
|
||||
if include_next else tr('settings.korean_holidays_years_label_single', year=current_year))
|
||||
target_label = (f"{current_year}년 + {current_year + 1}년"
|
||||
if include_next else f"{current_year}년")
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('settings.korean_holidays_title'),
|
||||
tr('settings.korean_holidays_body', years=target_label),
|
||||
"한국 공휴일 자동 추가",
|
||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
"포함:\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||
"• 정부 지정 대체·임시공휴일\n\n"
|
||||
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
@ -701,16 +706,18 @@ class SettingsView(QDialog):
|
||||
self.update_holiday_count()
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('settings.package_not_installed'),
|
||||
tr('settings.package_fallback_body', hint=tr('settings.package_install_hint'))
|
||||
"패키지 미설치",
|
||||
"'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n"
|
||||
"음력/임시공휴일 자동 등록을 원하시면:\n"
|
||||
" pip install holidays"
|
||||
)
|
||||
return
|
||||
|
||||
self.update_holiday_count()
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.add_done'),
|
||||
tr('settings.korean_holidays_added', year=current_year, count=added)
|
||||
"추가 완료",
|
||||
f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다."
|
||||
)
|
||||
|
||||
def add_custom_holiday(self):
|
||||
@ -721,8 +728,8 @@ class SettingsView(QDialog):
|
||||
today = datetime.now().date().isoformat()
|
||||
date_str, ok = QInputDialog.getText(
|
||||
self,
|
||||
tr('settings.holiday_add_title'),
|
||||
tr('settings.holiday_date_prompt'),
|
||||
"공휴일 추가",
|
||||
"공휴일 날짜를 입력하세요 (YYYY-MM-DD):",
|
||||
QLineEdit.Normal,
|
||||
today
|
||||
)
|
||||
@ -736,16 +743,16 @@ class SettingsView(QDialog):
|
||||
except ValueError:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('msg.input_error.title'),
|
||||
tr('msg.input_error.date_format')
|
||||
"입력 오류",
|
||||
"날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)"
|
||||
)
|
||||
return
|
||||
|
||||
# 공휴일 이름 입력
|
||||
name, ok = QInputDialog.getText(
|
||||
self,
|
||||
tr('settings.holiday_add_title'),
|
||||
tr('settings.holiday_name_prompt'),
|
||||
"공휴일 추가",
|
||||
"공휴일 이름을 입력하세요:",
|
||||
QLineEdit.Normal,
|
||||
""
|
||||
)
|
||||
@ -759,8 +766,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.add_done'),
|
||||
tr('settings.holiday_added', date=date_str, name=name)
|
||||
"추가 완료",
|
||||
f"공휴일이 추가되었습니다.\n{date_str}: {name}"
|
||||
)
|
||||
|
||||
def view_holidays(self):
|
||||
@ -771,28 +778,26 @@ class SettingsView(QDialog):
|
||||
if not holidays:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.holiday_list_title'),
|
||||
tr('stats.no_data')
|
||||
"공휴일 목록",
|
||||
f"{current_year}년에 등록된 공휴일이 없습니다."
|
||||
)
|
||||
return
|
||||
|
||||
# 목록 생성
|
||||
holiday_list = tr('settings.holiday_list_header', year=current_year)
|
||||
holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n"
|
||||
for h in holidays:
|
||||
date_obj = datetime.strptime(h['date'], "%Y-%m-%d")
|
||||
weekday = tr(f"label.weekday_{['mon','tue','wed','thu','fri','sat','sun'][date_obj.weekday()]}")
|
||||
recurring = tr('label.recurring_yearly') if h['is_recurring'] else ""
|
||||
holiday_list += tr('settings.holiday_list_item',
|
||||
date=h['date'], weekday=weekday,
|
||||
name=h['name'], recurring=recurring)
|
||||
weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()]
|
||||
recurring = " (매년)" if h['is_recurring'] else ""
|
||||
holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n"
|
||||
|
||||
holiday_list += '\n' + tr('settings.holiday_total', count=len(holidays))
|
||||
holiday_list += f"\n총 {len(holidays)}개"
|
||||
|
||||
# 삭제 옵션 제공
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('settings.holiday_list_title'),
|
||||
holiday_list + tr('settings.holiday_delete_confirm'),
|
||||
"공휴일 목록",
|
||||
holiday_list + "\n\n공휴일을 삭제하시겠습니까?",
|
||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel
|
||||
)
|
||||
|
||||
@ -813,8 +818,8 @@ class SettingsView(QDialog):
|
||||
items = [f"{h['date']}: {h['name']}" for h in holidays]
|
||||
item, ok = QInputDialog.getItem(
|
||||
self,
|
||||
tr('settings.holiday_delete_title'),
|
||||
tr('settings.holiday_delete_prompt'),
|
||||
"공휴일 삭제",
|
||||
"삭제할 공휴일을 선택하세요:",
|
||||
items,
|
||||
0,
|
||||
False
|
||||
@ -824,7 +829,7 @@ class SettingsView(QDialog):
|
||||
date_str = item.split(":")[0]
|
||||
self.db.delete_holiday_by_date(date_str)
|
||||
self.update_holiday_count()
|
||||
QMessageBox.information(self, tr('settings.delete_done'), tr('settings.holiday_deleted', item=item))
|
||||
QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.")
|
||||
|
||||
def create_data_group(self) -> QGroupBox:
|
||||
"""데이터 관리 그룹"""
|
||||
@ -835,22 +840,22 @@ class SettingsView(QDialog):
|
||||
# CSV 내보내기 버튼들 한 줄
|
||||
export_layout = QHBoxLayout()
|
||||
|
||||
export_work_btn = QPushButton(tr('settings.export_work'))
|
||||
export_work_btn = QPushButton("근무기록")
|
||||
export_work_btn.setObjectName("btn_small")
|
||||
export_work_btn.clicked.connect(self.export_work_records)
|
||||
export_layout.addWidget(export_work_btn)
|
||||
|
||||
export_overtime_btn = QPushButton(tr('settings.export_overtime'))
|
||||
export_overtime_btn = QPushButton("연장근무")
|
||||
export_overtime_btn.setObjectName("btn_small")
|
||||
export_overtime_btn.clicked.connect(self.export_overtime_summary)
|
||||
export_layout.addWidget(export_overtime_btn)
|
||||
|
||||
monthly_btn = QPushButton(tr('settings.export_monthly'))
|
||||
monthly_btn = QPushButton("월간 요약")
|
||||
monthly_btn.setObjectName("btn_small")
|
||||
monthly_btn.clicked.connect(self.export_monthly_summary)
|
||||
export_layout.addWidget(monthly_btn)
|
||||
|
||||
export_label = QLabel(tr('settings.export_csv'))
|
||||
export_label = QLabel("CSV 내보내기")
|
||||
export_label.setObjectName("note_text")
|
||||
export_layout.addWidget(export_label)
|
||||
export_layout.addStretch()
|
||||
@ -859,12 +864,12 @@ class SettingsView(QDialog):
|
||||
|
||||
# CSV 가져오기
|
||||
import_layout = QHBoxLayout()
|
||||
import_btn = QPushButton(tr('settings.import_csv'))
|
||||
import_btn = QPushButton("📥 CSV 가져오기")
|
||||
import_btn.setObjectName("btn_small")
|
||||
import_btn.setToolTip(tr('settings.import_tooltip'))
|
||||
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
||||
import_btn.clicked.connect(self._import_csv)
|
||||
import_layout.addWidget(import_btn)
|
||||
import_label = QLabel(tr('settings.import_format'))
|
||||
import_label = QLabel("우리 표준 포맷 (헤더: date,clock_in,clock_out,lunch_minutes,memo)")
|
||||
import_label.setObjectName("note_text")
|
||||
import_layout.addWidget(import_label)
|
||||
import_layout.addStretch()
|
||||
@ -872,14 +877,14 @@ class SettingsView(QDialog):
|
||||
|
||||
# DB 경로 설정 (클라우드 동기화 가능)
|
||||
db_path_layout = QHBoxLayout()
|
||||
db_path_label = QLabel(tr('settings.db_path_label'))
|
||||
db_path_label = QLabel("DB 경로:")
|
||||
db_path_label.setFixedWidth(60)
|
||||
self.db_path_edit = QLineEdit()
|
||||
self.db_path_edit.setReadOnly(True)
|
||||
self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db')
|
||||
db_path_btn = QPushButton(tr('settings.change'))
|
||||
db_path_btn = QPushButton("변경...")
|
||||
db_path_btn.setObjectName("btn_small")
|
||||
db_path_btn.setToolTip(tr('settings.db_path_tooltip'))
|
||||
db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.")
|
||||
db_path_btn.clicked.connect(self._change_db_path)
|
||||
db_path_layout.addWidget(db_path_label)
|
||||
db_path_layout.addWidget(self.db_path_edit, 1)
|
||||
@ -887,35 +892,39 @@ class SettingsView(QDialog):
|
||||
layout.addLayout(db_path_layout)
|
||||
|
||||
# 자동 외출 (화면 잠금 시)
|
||||
self.auto_break_check = QCheckBox(tr('settings.auto_break_lock'))
|
||||
self.auto_break_check.setToolTip(tr('settings.auto_break_lock_tooltip', fallback=''))
|
||||
self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀")
|
||||
self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.")
|
||||
layout.addWidget(self.auto_break_check)
|
||||
|
||||
# Gitea 피드백 토큰 (옵션, crash 자동 보고용)
|
||||
feedback_layout = QHBoxLayout()
|
||||
feedback_label = QLabel(tr('settings.gitea_feedback_label', fallback=''))
|
||||
feedback_label = QLabel("Gitea 피드백:")
|
||||
feedback_label.setFixedWidth(80)
|
||||
self.gitea_token_edit = QLineEdit()
|
||||
self.gitea_token_edit.setEchoMode(QLineEdit.Password)
|
||||
self.gitea_token_edit.setPlaceholderText(tr('settings.gitea_token_placeholder', fallback=''))
|
||||
self.gitea_token_edit.setPlaceholderText("PAT (issue 쓰기 권한, 옵션)")
|
||||
feedback_layout.addWidget(feedback_label)
|
||||
feedback_layout.addWidget(self.gitea_token_edit, 1)
|
||||
layout.addLayout(feedback_layout)
|
||||
|
||||
self.gitea_feedback_enabled_check = QCheckBox(tr('settings.gitea_feedback'))
|
||||
self.gitea_feedback_enabled_check = QCheckBox(
|
||||
"오류 발생 시 'Gitea에 보고' 버튼 활성화"
|
||||
)
|
||||
layout.addWidget(self.gitea_feedback_enabled_check)
|
||||
|
||||
# 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용)
|
||||
self.clock_in_unlock_check = QCheckBox(tr('settings.clock_in_unlock'))
|
||||
self.clock_in_unlock_check.setToolTip(tr('settings.clock_in_unlock_tooltip', fallback=''))
|
||||
self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용")
|
||||
self.clock_in_unlock_check.setToolTip(
|
||||
"PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다."
|
||||
)
|
||||
layout.addWidget(self.clock_in_unlock_check)
|
||||
|
||||
# 업데이트 확인
|
||||
update_layout = QHBoxLayout()
|
||||
from core.version import __version__
|
||||
version_label = QLabel(tr('settings.version', version=__version__))
|
||||
version_label = QLabel(f"버전: v{__version__}")
|
||||
version_label.setObjectName("note_text")
|
||||
update_btn = QPushButton(tr('settings.check_update'))
|
||||
update_btn = QPushButton("업데이트 확인 (F5)")
|
||||
update_btn.setObjectName("btn_small")
|
||||
update_btn.clicked.connect(self._check_updates)
|
||||
update_layout.addWidget(version_label)
|
||||
@ -931,7 +940,7 @@ class SettingsView(QDialog):
|
||||
current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db'
|
||||
new_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
tr('settings.select_db'),
|
||||
"데이터베이스 파일 선택",
|
||||
current,
|
||||
"SQLite Database (*.db)"
|
||||
)
|
||||
@ -942,8 +951,10 @@ class SettingsView(QDialog):
|
||||
self.db_path_edit.setText(new_path)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.db_path_label')[:-1],
|
||||
tr('settings.db_path_saved', path=new_path)
|
||||
"DB 경로 변경",
|
||||
f"새 경로가 저장되었습니다:\n{new_path}\n\n"
|
||||
"기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n"
|
||||
"프로그램을 재시작하세요."
|
||||
)
|
||||
|
||||
def load_settings(self):
|
||||
@ -1056,7 +1067,7 @@ class SettingsView(QDialog):
|
||||
self.time_format_combo.setCurrentIndex(index)
|
||||
|
||||
# 테마
|
||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
|
||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
|
||||
|
||||
# 언어 선택 적용
|
||||
if hasattr(self, 'language_combo'):
|
||||
@ -1093,7 +1104,7 @@ class SettingsView(QDialog):
|
||||
def _import_csv(self):
|
||||
"""CSV 파일에서 근무 기록 일괄 가져오기."""
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr('settings.import_csv'),
|
||||
self, "CSV 가져오기",
|
||||
os.path.expanduser("~"),
|
||||
"CSV files (*.csv);;All files (*.*)",
|
||||
)
|
||||
@ -1103,17 +1114,19 @@ class SettingsView(QDialog):
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
rows = parse_csv(path)
|
||||
except (FileNotFoundError, ValueError) as e:
|
||||
QMessageBox.critical(self, tr('settings.parse_failed'), str(e))
|
||||
QMessageBox.critical(self, "파싱 실패", str(e))
|
||||
return
|
||||
|
||||
if not rows:
|
||||
QMessageBox.information(self, tr('settings.empty_file'), tr('settings.empty_file_body'))
|
||||
QMessageBox.information(self, "빈 파일", "유효한 행이 없습니다.")
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('settings.conflict_title'),
|
||||
tr('settings.import_rows_intro', count=len(rows)) + tr('settings.conflict_body_detailed'),
|
||||
"충돌 처리",
|
||||
f"{len(rows)}건의 행을 가져오겠습니다.\n\n"
|
||||
"기존 일자와 충돌하면 어떻게 처리할까요?\n"
|
||||
"Yes = 덮어쓰기\nNo = 건너뛰기\nCancel = 취소",
|
||||
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
||||
)
|
||||
if reply == QMessageBox.Cancel:
|
||||
@ -1123,12 +1136,12 @@ class SettingsView(QDialog):
|
||||
try:
|
||||
added, updated, skipped = import_records(self.db, rows, on_conflict=policy)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, tr('settings.import_failed'), str(e))
|
||||
QMessageBox.critical(self, "가져오기 실패", str(e))
|
||||
return
|
||||
|
||||
QMessageBox.information(
|
||||
self, tr('settings.import_complete'),
|
||||
tr('settings.import_result', added=added, updated=updated, skipped=skipped)
|
||||
self, "완료",
|
||||
f"가져오기 결과:\n• 추가: {added}건\n• 갱신: {updated}건\n• 건너뜀: {skipped}건"
|
||||
)
|
||||
|
||||
def _check_updates(self):
|
||||
@ -1221,8 +1234,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.save_done'),
|
||||
tr('settings.save_done_body')
|
||||
"저장 완료",
|
||||
"설정이 저장되었습니다."
|
||||
)
|
||||
|
||||
# 부모 윈도우에 설정 변경 알림
|
||||
@ -1238,8 +1251,9 @@ class SettingsView(QDialog):
|
||||
set_language_and_retranslate(new_lang)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('settings.restart_title'),
|
||||
tr('settings.restart_body'),
|
||||
"재시작 / Restart",
|
||||
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
|
||||
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
@ -1271,8 +1285,10 @@ class SettingsView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('settings.initial_overtime_title'),
|
||||
tr('settings.initial_overtime_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
||||
"기존 연장근무 설정",
|
||||
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
||||
f"변경할 값: {hours}시간 {mins}분\n\n"
|
||||
f"기존 연장근무 시간을 변경하시겠습니까?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -1282,8 +1298,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.initial_overtime_done'),
|
||||
tr('settings.initial_overtime_done_body', hours=hours, mins=mins)
|
||||
"설정 완료",
|
||||
f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다."
|
||||
)
|
||||
|
||||
# 부모 윈도우 잔액 업데이트
|
||||
@ -1295,8 +1311,8 @@ class SettingsView(QDialog):
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
tr('settings.error'),
|
||||
tr('settings.initial_overtime_error', error=str(e))
|
||||
"오류",
|
||||
f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}"
|
||||
)
|
||||
|
||||
def update_overtime_balance_display(self):
|
||||
@ -1304,7 +1320,7 @@ class SettingsView(QDialog):
|
||||
balance_minutes = self.db.get_total_overtime_balance()
|
||||
hours = balance_minutes // 60
|
||||
minutes = balance_minutes % 60
|
||||
self.current_overtime_label.setText(tr('settings.current_overtime_balance', hours=hours, minutes=minutes, balance=balance_minutes))
|
||||
self.current_overtime_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance_minutes}분)")
|
||||
|
||||
def update_remaining_leave(self):
|
||||
"""남은 연차 계산 및 표시"""
|
||||
@ -1330,7 +1346,7 @@ class SettingsView(QDialog):
|
||||
remaining = total_annual - total_used
|
||||
|
||||
self.remaining_leave_label.setText(
|
||||
tr('settings.remaining_leave_fmt', remaining=remaining, total=total_annual, used=total_used)
|
||||
f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)"
|
||||
)
|
||||
|
||||
def export_work_records(self):
|
||||
@ -1341,14 +1357,14 @@ class SettingsView(QDialog):
|
||||
records = stats.get('records', [])
|
||||
|
||||
if not records:
|
||||
QMessageBox.warning(self, tr('settings.export_failed'), tr('settings.export_no_records'))
|
||||
QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.")
|
||||
return
|
||||
|
||||
# 파일 경로 선택
|
||||
default_filename = f"work_records_{now.year}{now.month:02d}.csv"
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
tr('settings.save_work_title'),
|
||||
"근무 기록 저장",
|
||||
default_filename,
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
@ -1358,17 +1374,17 @@ class SettingsView(QDialog):
|
||||
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.export_done'),
|
||||
tr('settings.work_exported', path=saved_path)
|
||||
"내보내기 완료",
|
||||
f"근무 기록이 저장되었습니다.\n{saved_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
||||
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
||||
|
||||
def export_overtime_summary(self):
|
||||
"""연장근무 내역 내보내기"""
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
tr('settings.save_ot_title'),
|
||||
"연장근무 내역 저장",
|
||||
f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv",
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
@ -1378,18 +1394,18 @@ class SettingsView(QDialog):
|
||||
saved_path = CSVExporter.export_overtime_summary(self.db, filename)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.export_done'),
|
||||
tr('settings.ot_exported', path=saved_path)
|
||||
"내보내기 완료",
|
||||
f"연장근무 내역이 저장되었습니다.\n{saved_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
||||
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
||||
|
||||
def export_monthly_summary(self):
|
||||
"""월간 요약 내보내기"""
|
||||
now = datetime.now()
|
||||
filename, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
tr('settings.save_monthly_title'),
|
||||
"월간 요약 저장",
|
||||
f"monthly_summary_{now.year}{now.month:02d}.csv",
|
||||
"CSV Files (*.csv)"
|
||||
)
|
||||
@ -1399,11 +1415,11 @@ class SettingsView(QDialog):
|
||||
saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.export_done'),
|
||||
tr('settings.monthly_exported', path=saved_path)
|
||||
"내보내기 완료",
|
||||
f"월간 요약이 저장되었습니다.\n{saved_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, tr('settings.export_failed'), tr('settings.export_error', error=str(e)))
|
||||
QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}")
|
||||
|
||||
def apply_used_leave(self):
|
||||
"""기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)"""
|
||||
@ -1418,8 +1434,10 @@ class SettingsView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('settings.initial_leave_title'),
|
||||
tr('settings.initial_leave_body', old_hours=old_hours, old_mins=old_mins, hours=hours, mins=mins),
|
||||
"기존 사용 연차 설정",
|
||||
f"현재 설정: {old_hours}시간 {old_mins}분\n"
|
||||
f"변경할 값: {hours}시간 {mins}분\n\n"
|
||||
f"기존 사용 연차를 변경하시겠습니까?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
@ -1429,8 +1447,8 @@ class SettingsView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
tr('settings.initial_leave_done'),
|
||||
tr('settings.initial_leave_done_body', hours=hours, mins=mins)
|
||||
"설정 완료",
|
||||
f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다."
|
||||
)
|
||||
|
||||
# 남은 연차 재계산
|
||||
|
||||
100
ui/stats_view.py
100
ui/stats_view.py
@ -15,7 +15,7 @@ from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||
transparent_label, tc,
|
||||
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM,
|
||||
)
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ class StatsView(QDialog):
|
||||
self.db = db if db else Database()
|
||||
self.init_ui()
|
||||
self.load_stats()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
@ -42,9 +42,9 @@ class StatsView(QDialog):
|
||||
layout.setContentsMargins(20, 16, 20, 14)
|
||||
|
||||
# 다크 톤 타이틀
|
||||
title = QLabel(f"{tr('stats.title')}")
|
||||
title = QLabel(f"📊 {tr('stats.title')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
@ -93,14 +93,14 @@ class StatsView(QDialog):
|
||||
# 카드 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')
|
||||
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||
theme='blue', icon='⏱️')
|
||||
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||
theme='cyan', icon='📅')
|
||||
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||
theme='green', icon='📊')
|
||||
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
||||
theme='gold', icon='🔥')
|
||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||
self.weekly_avg_card, self.weekly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
@ -109,8 +109,8 @@ class StatsView(QDialog):
|
||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||
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')
|
||||
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||
theme='gray', icon='📈')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
@ -127,14 +127,14 @@ class StatsView(QDialog):
|
||||
# 카드 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')
|
||||
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
||||
theme='blue', icon='⏱️')
|
||||
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||
theme='cyan', icon='📅')
|
||||
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||
theme='green', icon='📊')
|
||||
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
||||
theme='gold', icon='🔥')
|
||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||
self.monthly_avg_card, self.monthly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
@ -143,9 +143,9 @@ class StatsView(QDialog):
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
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"background: rgba(74, 222, 128, 0.12); "
|
||||
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; "
|
||||
f"color: {ACCENT_GREEN}; font-weight: bold; "
|
||||
f"padding: 10px 14px; font-size: 11pt;"
|
||||
)
|
||||
self.salary_label.setVisible(False)
|
||||
@ -159,8 +159,8 @@ class StatsView(QDialog):
|
||||
# 월간 차트
|
||||
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')
|
||||
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||
theme='gray', icon='📊')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
@ -179,17 +179,17 @@ class StatsView(QDialog):
|
||||
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"font-size: 11pt; color: {DARK_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'))
|
||||
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||
theme='cyan', icon='🔍'))
|
||||
|
||||
# 출근 시각 분포 차트
|
||||
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)
|
||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||
theme='gray', icon='⏰'), 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@ -225,15 +225,15 @@ class StatsView(QDialog):
|
||||
# 주간 통계
|
||||
weekly_stats = self.db.get_weekly_stats()
|
||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||
self._set_card_value(self.weekly_total_card, 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._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
|
||||
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}일")
|
||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||
self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}"))
|
||||
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간")
|
||||
|
||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_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._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
|
||||
|
||||
# 주간 차트
|
||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||
@ -251,21 +251,21 @@ class StatsView(QDialog):
|
||||
now = datetime.now()
|
||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||
self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
|
||||
work_days = monthly_stats.get('work_days', 0) or 0
|
||||
self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days))
|
||||
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
||||
|
||||
if work_days > 0:
|
||||
avg = total_hours / work_days
|
||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}"))
|
||||
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
|
||||
else:
|
||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0))
|
||||
self._set_card_value(self.monthly_avg_card, "0시간")
|
||||
|
||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_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))
|
||||
f"{overtime_hours}시간 {overtime_mins}분")
|
||||
|
||||
# 월간 차트 (요일별 평균)
|
||||
if hasattr(self, 'monthly_chart'):
|
||||
@ -300,10 +300,8 @@ 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)
|
||||
|
||||
@ -336,7 +334,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 +342,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 +364,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)
|
||||
@ -72,22 +70,22 @@ class TodaySummaryCard(QFrame):
|
||||
"""
|
||||
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))
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
if dinner_minutes > 0:
|
||||
details.append(tr('today.detail_dinner', minutes=dinner_minutes))
|
||||
details.append(f"저녁 {dinner_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)
|
||||
|
||||
68
updater.py
68
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':
|
||||
@ -105,7 +81,8 @@ def replace_file(new_path: Path, target_path: Path,
|
||||
try:
|
||||
backup.unlink()
|
||||
except OSError as e:
|
||||
_log(f"[updater] old backup unlink failed (continuing): {e}")
|
||||
print(f"[updater] old backup unlink failed (continuing): {e}",
|
||||
file=sys.stderr)
|
||||
|
||||
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
||||
for attempt in range(max_retries):
|
||||
@ -117,12 +94,13 @@ def replace_file(new_path: Path, target_path: Path,
|
||||
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")
|
||||
print(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
else:
|
||||
# 모든 재시도 실패
|
||||
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||
print(f"[updater] target move failed after {max_retries} attempts: {last_err}",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
|
||||
# 3단계: new → target 이동
|
||||
@ -133,18 +111,19 @@ def replace_file(new_path: Path, target_path: Path,
|
||||
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")
|
||||
print(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
||||
time.sleep(wait)
|
||||
|
||||
# new 이동 실패 → backup으로 롤백 시도
|
||||
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
|
||||
print(f"[updater] new move failed after {max_retries} attempts: {last_err}",
|
||||
file=sys.stderr)
|
||||
if backup.exists() and not target_path.exists():
|
||||
try:
|
||||
shutil.move(str(backup), str(target_path))
|
||||
_log("[updater] rolled back from backup")
|
||||
print("[updater] rolled back from backup", file=sys.stderr)
|
||||
except OSError as e:
|
||||
_log(f"[updater] rollback also failed: {e}")
|
||||
print(f"[updater] rollback also failed: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
@ -152,20 +131,13 @@ 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,17 +149,15 @@ 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 락을 잠시 유지하는 경우가 있음.
|
||||
@ -196,15 +166,13 @@ def main() -> int:
|
||||
|
||||
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 +181,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,
|
||||
|
||||
@ -138,7 +138,6 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
||||
conn = db.get_connection()
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("DELETE FROM overtime_usage WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM break_records WHERE date = ?", (row['date'],))
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (row['date'],))
|
||||
|
||||
@ -13,9 +13,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
|
||||
|
||||
@ -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,62 @@ 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)
|
||||
|
||||
schedule_action = QAction("🗓️ 스케줄", self)
|
||||
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
|
||||
menu.addAction(schedule_action)
|
||||
|
||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
||||
menu.addAction(help_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
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