Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8c6a9d784 | ||
|
|
71161b2707 | ||
|
|
63b0e324b9 | ||
|
|
f5751460e3 | ||
|
|
e7e85dcf7b | ||
|
|
130c61ea62 | ||
|
|
5fb8655a47 | ||
|
|
da5f91984b | ||
|
|
3db4ed2351 | ||
|
|
97dd4e39f7 | ||
|
|
47296dd35b | ||
|
|
c98ca361cd | ||
|
|
d41e5cb921 | ||
|
|
c5df37ca57 |
@@ -1,176 +1,476 @@
|
||||
# Project Conventions and Operational Gotchas
|
||||
# Clock-out Time Calculator — Agent Guide
|
||||
|
||||
## 🛠️ Setup & Execution
|
||||
- **Dependencies:** `pip install -r requirements.txt` (PyQt5, pywin32, dateutil, matplotlib, plyer, holidays).
|
||||
- **Run:** `python main.py`
|
||||
- **Module-level smoke:**
|
||||
- Event monitoring: `python core/event_monitor.py`
|
||||
- Time calculation: `python core/time_calculator.py`
|
||||
- **Integration tests** (all should be green before release):
|
||||
- `python _integration_test.py` — business-logic scenarios (35+ for v2.0–2.2 + 15+ for v2.3+)
|
||||
- `python _i18n_gui_test.py` — ko/en switch on real widgets
|
||||
- `python _gui_smoke_test.py` — widget instantiation
|
||||
- **Production build:** `python -m PyInstaller --clean updater.spec && python -m PyInstaller --clean main.spec` (or just `release.ps1 vX.Y.Z`).
|
||||
> Last verified against the working tree at version **2.11.2** (`core/version.py`).
|
||||
> This file is written for AI coding agents who need to understand, modify, build, or release the project. When in doubt, prefer the facts in this file over older documentation; this guide was produced by exploring the actual codebase, running the tests, and reading the build scripts.
|
||||
|
||||
## 🗄️ Architecture Notes (Core Business Logic)
|
||||
---
|
||||
|
||||
### Database (10+ tables in `database.db`)
|
||||
`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`, `notification_log` (dedupe), `crash_log` (auto crash report). Migrations chained from `init_database()` via sentinel-gated `migrate_*` methods.
|
||||
## 1. Project Overview
|
||||
|
||||
### Invariants
|
||||
- **`work_records.date` UNIQUE** — one row per workday.
|
||||
- **Overtime bank vs usage:** separate tables, both with NULLable `work_record_id` for manual entries — never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
||||
- **Time representation:** `TimeCalculator.work_minutes` is canonical (int). `work_hours` is a read-only property. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` via floor (`int(min) // 60`).
|
||||
- **Leave days:** `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). Single source of truth.
|
||||
- **Overtime balance:** `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` via `get_total_overtime_balance()`.
|
||||
- **WAL mode + 5s busy timeout** enabled in `init_database()` for cloud-sync friendliness (OneDrive/Dropbox).
|
||||
**Clock-out Time Calculator** (Korean: 퇴근시간 계산기) is a Windows desktop productivity application written in Python with PyQt5. It tracks a user's workday, automatically detects clock-in time from Windows Event Log / boot time, counts down to the expected clock-out time in real time, banks overtime in configurable units, manages annual leave, and provides statistics, notifications, Discord integration, and automatic self-updates.
|
||||
|
||||
### Settings system
|
||||
- Keys: `core/settings_keys.py` (35+ constants). Import constants — never use raw strings.
|
||||
- `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed).
|
||||
- Auto-sync pairs: `WORK_MINUTES ↔ WORK_HOURS`, `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`.
|
||||
- Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running.
|
||||
- **Primary language:** Python 3.9+
|
||||
- **GUI framework:** PyQt5
|
||||
- **Database:** SQLite (`database.db`) with WAL mode and a 5-second busy timeout
|
||||
- **Packaging:** PyInstaller (`main.exe` + `updater.exe`)
|
||||
- **Distribution:** Gitea Releases on a self-hosted instance
|
||||
- **Current version:** `2.11.2` (single source of truth: `core/version.py`)
|
||||
- **Repository:** `kindnick/Clock_out_Time_Calculator`
|
||||
|
||||
### i18n
|
||||
- `tr('key', **kwargs)` and `tr_html('help.html.X')` from `core/i18n.py`. ko/en `_DICT` (30+ categories).
|
||||
- Sentence formatting via Python `str.format(**kwargs)`.
|
||||
- Language change requires app restart for full effect (existing widgets keep original-language text). Runtime retranslate is on the roadmap.
|
||||
The project is single-file deployable: `main.exe` embeds `updater.exe` and extracts it on first launch, so end users only need `main.exe`.
|
||||
|
||||
## ⚠️ Critical Invariants (MUST PRESERVE)
|
||||
---
|
||||
|
||||
### 1. Time-off subtraction order in `update_display()`
|
||||
Pass actual `break_minutes` to `calculate_remaining_time`. Subtract `total_time_off = overtime_used + leave_used` from the resulting timedelta AFTER the call. NEVER mutate `break_minutes` to `break_minutes - overtime_used` — this caused a +29h display bug previously and was the original Phase 1 fix.
|
||||
## 2. Technology Stack
|
||||
|
||||
### 2. Hot-path caching
|
||||
`update_display()` runs at 1Hz. Any DB call inside must be cached (`_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly/long-work notifications) are gated by `now.minute % 5 == 0`.
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Language | Python 3.9+ |
|
||||
| GUI | PyQt5 ≥ 5.15 |
|
||||
| Charts | matplotlib (QtAgg backend) |
|
||||
| Windows integration | pywin32 (event log), ctypes (screen-lock detection) |
|
||||
| Date / recurrence | python-dateutil |
|
||||
| Notifications | plyer (system toast) + PyQt signals |
|
||||
| Holidays | optional `holidays` package; government API + fixed-date fallback |
|
||||
| Packaging | PyInstaller 2-step build |
|
||||
| Testing | pytest + standalone integration/GUI smoke scripts |
|
||||
| Fonts | Bundled NanumSquare TTF/OTF files; Malgun Gothic fallback |
|
||||
|
||||
### 3. Time format separation
|
||||
24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable).
|
||||
Dependencies are declared in `requirements.txt`:
|
||||
|
||||
### 4. Workday boundary
|
||||
`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. `start_new_workday()` only triggers when crossing this boundary. Don't naively use `date.today()` in time logic.
|
||||
```text
|
||||
PyQt5>=5.15.0
|
||||
pywin32>=305
|
||||
python-dateutil>=2.8.0
|
||||
matplotlib>=3.4.0
|
||||
plyer>=2.0.0
|
||||
holidays>=0.40
|
||||
```
|
||||
|
||||
### 5. Migration idempotency
|
||||
All `migrate_*` methods must early-return if already applied. Use sentinel keys — without them, every startup re-runs the migration query.
|
||||
|
||||
### 6. Single-file deployment
|
||||
`main.exe` embeds `updater.exe` via `main.spec` data files. `_ensure_updater_extracted()` in `main.py` extracts on first launch from `sys._MEIPASS`. Never break the staging copy at `build/staging/updater.exe` — `main.spec --clean` would otherwise wipe `dist/updater.exe` mid-build.
|
||||
|
||||
### 7. Updater handoff
|
||||
`updater.py` is standalone (no PyQt). Args: `--pid <main_pid> --new <new_main.exe> --target <current_main.exe>`. Waits for PID exit, swaps file with `.bak` rollback, relaunches. Don't add Qt deps to updater.
|
||||
|
||||
## 🧩 Module Map
|
||||
|
||||
### `core/`
|
||||
- `database.py` — SQLite schema + migrations + helpers (`get_setting_*`, `get_consecutive_overtime_days`, `add_korean_holidays_auto`, `log_notification`, `add_meal_record`).
|
||||
- `time_calculator.py` — `work_minutes` canonical, `calculate_overtime(unit_minutes=30)` (user-selectable unit).
|
||||
- `event_monitor.py` — Win Event IDs 6005/4624/6006.
|
||||
- `notifier.py` — 7 notifications, `_enabled()` reads NOTIF_* keys, `notification_before_minutes` configurable.
|
||||
- `i18n.py` — `_DICT` ko/en + `_HELP_HTML` (6 tabs).
|
||||
- `salary.py` — `estimate_pay(records, hourly_wage, overtime_rate=1.5)`.
|
||||
- `settings_keys.py` — All setting keys as constants.
|
||||
- `version.py` — `__version__` single source of truth.
|
||||
|
||||
### `ui/`
|
||||
- `main_window.py` — 1Hz `update_display()`, single-instance `QLocalServer`, 7 keyboard shortcuts.
|
||||
- `settings_view.py` — work pattern presets, hour+minute split spinboxes, font scale, high contrast, Discord, Gitea PAT, monthly goals.
|
||||
- `stats_view.py` — 3 tabs (weekly/monthly/patterns), matplotlib with hover annotation + clock-in distribution + weekday avg + goal widget.
|
||||
- `mini_widget.py` — always-on-top frameless.
|
||||
- `help_view.py` — 6 tabs from `_HELP_HTML`. Has "🚀 온보딩 다시 보기" button.
|
||||
- `onboarding_view.py` — 5-step QWizard (forced for new users; `ONBOARDING_COMPLETED` sentinel).
|
||||
- `today_summary.py` — post-clockout card.
|
||||
- `goal_widget.py` — monthly progress bars (overtime cap, daily avg).
|
||||
- `meal_time_dialog.py` — lunch/dinner real start-end input.
|
||||
- `past_record_dialog.py` — calendar right-click "add past record".
|
||||
- `leave_calendar_view.py` — color-coded leave (green/yellow/purple).
|
||||
- `accessibility.py` — `apply_font_scale(scale)`, `apply_high_contrast(enabled)`, `HIGH_CONTRAST_QSS`.
|
||||
- `chart_widget.py` — matplotlib QtAgg helpers, `_Fallback` widget if matplotlib missing.
|
||||
- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`.
|
||||
|
||||
### `ui/controllers/`
|
||||
- `lock_monitor.py` — Win32 OpenInputDesktop polling 5s for screen-lock auto-break.
|
||||
- `auto_lunch.py` — toggles lunch after 4 hours since clock-in.
|
||||
- `notification_orchestrator.py` — 5-min-tick orchestrator + `maybe_send_weekly_report()` for Mondays.
|
||||
|
||||
### `utils/`
|
||||
- `backup.py` — once/day, `~/.clockout_backups/`, 7-rotation, `sqlite3.Connection.backup` API.
|
||||
- `lock_detector.py` — `OpenInputDesktop` + `GetUserObjectInformation` for screen lock.
|
||||
- `http_api.py` — stdlib `http.server` on `127.0.0.1:17389`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. NEVER expose externally.
|
||||
- `discord_webhook.py` — browser User-Agent (`Mozilla/5.0 ... ClockOutCalculator/2.3`) for Cloudflare bypass. `send_test/clock_in/clock_out/health_warning`.
|
||||
- `csv_importer.py` — standard format `date,clock_in,clock_out,lunch_minutes,memo`. `parse_csv()` + `import_records(on_conflict)`.
|
||||
- `csv_exporter.py` — same format.
|
||||
- `crash_handler.py` — `install_global_handler()` registers `sys.excepthook`, dialog with copy/Gitea-report.
|
||||
- `updater_client.py` — Returns `(info, reason)` tuple. Reasons: `OK / NETWORK_ERROR / NO_RELEASE / UP_TO_DATE / NO_ASSET`.
|
||||
- `system_tray.py` — tray menu with i18n labels.
|
||||
- `time_format.py` — `format_hours_minutes(minutes)`.
|
||||
- `debug_log.py` — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`.
|
||||
- `resource_manager.py` — PyInstaller `_MEIPASS` aware path resolver.
|
||||
|
||||
### Top-level
|
||||
- `main.py` — Bootstraps DB, `_ensure_updater_extracted()`, crash handler, onboarding gate, MainWindow.
|
||||
- `updater.py` — Standalone PID wait + file replace + relaunch.
|
||||
- `main.spec` — Conditional updater embedding from `build/staging/updater.exe`.
|
||||
- `updater.spec` — Standalone updater build.
|
||||
- `release.ps1` — One-shot release: bump → tests → build → tag push → Gitea Release + assets, optional code signing.
|
||||
|
||||
## ⚙️ Build Process
|
||||
Install with:
|
||||
|
||||
```bash
|
||||
# Manual two-step
|
||||
python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB)
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`pywin32` is required for Windows Event Log access and screen-lock detection. The app is therefore Windows-centric; full functionality will not work on other platforms.
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure and Module Map
|
||||
|
||||
```text
|
||||
Clock-out Time Calculator/
|
||||
├── main.py # Application entry point / bootstrap
|
||||
├── updater.py # Standalone update helper process (stdlib only)
|
||||
├── main.spec # PyInstaller spec for main.exe
|
||||
├── updater.spec # PyInstaller spec for updater.exe
|
||||
├── release.ps1 # One-shot release script (PowerShell)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── pytest.ini # pytest configuration
|
||||
├── run_as_admin.bat # Convenience launcher
|
||||
├── core/ # Business logic and data access
|
||||
│ ├── database.py # SQLite schema, migrations, CRUD
|
||||
│ ├── time_calculator.py # Pure time-math engine
|
||||
│ ├── event_monitor.py # Windows Event Log clock-in detection
|
||||
│ ├── notifier.py # Notification rule engine
|
||||
│ ├── salary.py # Optional pay estimation
|
||||
│ ├── i18n.py # Korean/English translation dictionaries
|
||||
│ ├── settings_keys.py # Setting key constants
|
||||
│ ├── achievements.py # 357 achievement definitions + evaluator
|
||||
│ ├── recurring_leaves.py # Recurring leave pattern expansion
|
||||
│ └── version.py # __version__ single source of truth
|
||||
├── ui/ # PyQt5 views and widgets
|
||||
│ ├── main_window.py # Central 1 Hz main window
|
||||
│ ├── styles.py # Theme colors and QSS
|
||||
│ ├── dark_components.py # Reusable dark-styled widgets
|
||||
│ ├── icons.py # Icon resource helpers
|
||||
│ ├── i18n_runtime.py # Runtime retranslation registry
|
||||
│ ├── settings_view.py # Settings dialog
|
||||
│ ├── stats_view.py # Weekly/monthly/pattern statistics
|
||||
│ ├── chart_widget.py # matplotlib QtAgg wrapper + fallback
|
||||
│ ├── calendar_view.py # Work-record calendar
|
||||
│ ├── leave_calendar_view.py # Color-coded leave calendar
|
||||
│ ├── break_view.py # Break history dialog
|
||||
│ ├── overtime_view.py # Overtime bank/usage dialog
|
||||
│ ├── leave_view.py # Leave management dialog
|
||||
│ ├── recurring_leave_dialog.py
|
||||
│ ├── schedule_view.py # Schedule view
|
||||
│ ├── clock_in_dialog.py # Manual clock-in dialog
|
||||
│ ├── meal_time_dialog.py # Lunch/dinner start-end input
|
||||
│ ├── past_record_dialog.py # Add past record dialog
|
||||
│ ├── achievements_view.py # Achievements browser
|
||||
│ ├── onboarding_view.py # 5-step first-run wizard
|
||||
│ ├── today_summary.py # Post-clock-out card
|
||||
│ ├── goal_widget.py # Monthly goal progress bars
|
||||
│ ├── mini_widget.py # Always-on-top frameless widget
|
||||
│ ├── help_view.py # 6-tab help dialog
|
||||
│ ├── accessibility.py # Font scale / high-contrast helpers
|
||||
│ └── controllers/ # Thin controllers split from MainWindow
|
||||
│ ├── lock_monitor.py # Screen-lock auto-break / unlock clock-in
|
||||
│ ├── auto_lunch.py # Auto-lunch after 4 hours
|
||||
│ ├── notification_orchestrator.py # 5-min-tick orchestrator
|
||||
│ └── meal_controller.py # Lunch/dinner toggle handling
|
||||
├── utils/ # Helpers and integrations
|
||||
│ ├── backup.py # Daily SQLite backup with 7-rotation
|
||||
│ ├── lock_detector.py # Win32 screen-lock detection
|
||||
│ ├── discord_webhook.py # Discord embed pushes
|
||||
│ ├── csv_importer.py # CSV import (standard format)
|
||||
│ ├── csv_exporter.py # CSV export
|
||||
│ ├── crash_handler.py # Global excepthook + optional Gitea report
|
||||
│ ├── updater_client.py # Gitea Releases API client
|
||||
│ ├── system_tray.py # System tray menu
|
||||
│ ├── time_format.py # format_hours_minutes helper
|
||||
│ ├── font_loader.py # NanumSquare font loading
|
||||
│ ├── resource_manager.py # PyInstaller _MEIPASS path resolver
|
||||
│ ├── debug_log.py # CLOCKOUT_DEBUG gated logging
|
||||
│ └── holiday_api.py # Korean holiday API client
|
||||
├── tests/ # pytest unit tests (13 files)
|
||||
├── font/ # Bundled NanumSquare fonts
|
||||
├── resources/ # Icons and resource links
|
||||
├── analysis/ # Currently empty (only __init__.py)
|
||||
├── build/ # Build staging directory
|
||||
└── dist/ # Built EXEs and release ZIPs
|
||||
```
|
||||
|
||||
> **Note:** `utils/http_api.py` is referenced in older documentation but is **not present** in the current working tree.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to Build, Run, and Smoke-Test
|
||||
|
||||
### Development run
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
`main.py` inserts the project root into `sys.path`, bootstraps the database, loads fonts, installs the crash handler, shows onboarding if needed, and launches `MainWindow`.
|
||||
|
||||
A convenience batch file is provided:
|
||||
|
||||
```bash
|
||||
run_as_admin.bat
|
||||
```
|
||||
|
||||
Running as administrator is recommended because Windows Event Log access may be restricted for standard users.
|
||||
|
||||
### Module-level smoke tests
|
||||
|
||||
```bash
|
||||
python core/event_monitor.py
|
||||
python core/time_calculator.py
|
||||
```
|
||||
|
||||
These run lightweight self-tests when invoked as scripts.
|
||||
|
||||
### Production build (manual two-step)
|
||||
|
||||
```bash
|
||||
python -m PyInstaller --clean updater.spec
|
||||
mkdir -p build/staging && cp dist/updater.exe build/staging/
|
||||
python -m PyInstaller --clean main.spec # → dist/main.exe (~78MB, embeds updater)
|
||||
|
||||
# Or one-shot
|
||||
.\release.ps1 v2.7.0
|
||||
python -m PyInstaller --clean main.spec
|
||||
```
|
||||
|
||||
- `dist/main.exe` running → `PermissionError`. Kill it first.
|
||||
- `holidays` package only baked in if installed in build env.
|
||||
- Code signing optional via `$env:CODE_SIGN_CERT` (.pfx path) + `$env:CODE_SIGN_PASS`.
|
||||
- `updater.spec` builds `dist/updater.exe` (~6 MB, stdlib only, no Qt/matplotlib/win32/holidays).
|
||||
- `main.spec` builds `dist/main.exe` (~78 MB) and embeds `updater.exe` from `build/staging/updater.exe` (falling back to `dist/updater.exe`).
|
||||
- The staging copy is critical: `main.spec --clean` wipes `dist/`, so without `build/staging/updater.exe` the updater would be deleted mid-build.
|
||||
|
||||
## 🚦 External Integrations
|
||||
### Production build (one-shot)
|
||||
|
||||
- **Auto-update** (`utils/updater_client.py`): polls `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`. UA: `ClockOutCalculator/<version>`. Repo must be public.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): single-direction push, optional. Mozilla UA mandatory (Cloudflare blocks Python UA).
|
||||
- **Gitea Issues** for crash reports (`utils/crash_handler.py`): user opt-in via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1` only — never expose externally. Read-only.
|
||||
- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order.
|
||||
- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
|
||||
## 🐞 Past Incidents (do NOT re-introduce)
|
||||
|
||||
- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call.
|
||||
- **Manual overtime invisible**: previously filtered `work_record_id IS NOT NULL`. Now show all rows; label NULL as "수동 추가".
|
||||
- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. Auto-synced in `save_settings()`.
|
||||
- **Banker's rounding**: `round(450/60) = 8` in Python (round-half-even). Use `int(value) // 60` (floor).
|
||||
- **PRAGMA foreign_keys=ON conflict** with existing manual overtime records → IntegrityError. Rolled back FK enforcement, kept WAL+timeout.
|
||||
- **Discord 403 / Cloudflare 1010**: default `Python-urllib/3.x` User-Agent blocked. Fixed with browser UA in `discord_webhook.py`.
|
||||
- **Help dialog blank** (v2.3.1): `self.setLayout(main_layout)` accidentally indented into `_reopen_onboarding` method body. Same regression hit `leave_view.py` later. Always verify setLayout is at the END of `init_ui()` after method-body refactors.
|
||||
- **`dist/updater.exe` wiped by `main.spec --clean`**: solved by staging copy at `build/staging/updater.exe`.
|
||||
- **Onboarding wizard auto-skipped** for existing users (work_records present). Added "Re-run Onboarding" button to Help dialog.
|
||||
- **PowerShell 5.1 ANSI default**: `Get-Content -Raw` reads CHANGELOG.md as cp949, mangling Korean. Use `[System.IO.File]::ReadAllText(path, UTF8)`.
|
||||
- **PowerShell 5.1 NativeCommandError**: native commands' stderr triggers `$ErrorActionPreference='Stop'`. Use `Invoke-Native` helper with `Continue` and explicit `$LASTEXITCODE`.
|
||||
|
||||
## 🌐 i18n Coverage Status
|
||||
|
||||
- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 7 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels, onboarding wizard, today summary, goal widget, accessibility settings.
|
||||
- **Partially translated**: settings_view sub-labels, calendar_view detail labels, meal_time_dialog, past_record_dialog.
|
||||
- **Roadmap**: dialog inner labels in break_view/overtime_view/leave_view (window titles already translated). Runtime retranslate (no restart).
|
||||
|
||||
Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. For sentence interpolation use `tr('key', name=value)`.
|
||||
|
||||
## 🚢 Release Flow ([release.ps1](release.ps1))
|
||||
|
||||
```
|
||||
0. Pre-checks (PAT env var, no running main.exe, no existing tag, no uncommitted changes)
|
||||
1. Bump core/version.py
|
||||
2. Tests (pytest tests/ + python _integration_test.py) — skippable with --SkipTests
|
||||
3. PyInstaller (updater.spec → staging copy → main.spec)
|
||||
4. ZIP packaging (main.exe + updater.exe)
|
||||
5. Git commit (version.py + CHANGELOG.md) + tag + push
|
||||
6. Gitea Release POST (CHANGELOG.md UTF-8 read, regex extract section)
|
||||
7. Asset upload (main.exe, updater.exe, ZIP)
|
||||
```bash
|
||||
.\release.ps1 v2.11.2
|
||||
```
|
||||
|
||||
`--DryRun` previews without git push or API calls.
|
||||
The version argument must match `^v\d+\.\d+\.\d+$` and will be written into `core/version.py`.
|
||||
|
||||
### Build gotchas
|
||||
|
||||
- Kill any running `main.exe` before building or PyInstaller will fail with `PermissionError`.
|
||||
- The `holidays` package is only baked into the EXE if it is installed in the build environment.
|
||||
- Optional code signing is supported via `$env:CODE_SIGN_CERT` (.pfx path) and `$env:CODE_SIGN_PASS`.
|
||||
- `main.spec` lists several `hiddenimports` that are easy to break: `holidays`, `holidays.countries.south_korea`, `win32evtlog`, `win32evtlogutil`, `matplotlib.backends.backend_qtagg`, `matplotlib.backends.backend_qt5agg`, `PyQt5.QtSvg`, `PyQt5.sip`, and `numpy.core._multiarray_tests`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Strategy
|
||||
|
||||
The project uses three layers of tests.
|
||||
|
||||
### 5.1 Unit tests (pytest)
|
||||
|
||||
Configuration: `pytest.ini`
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
There are **13 test files** under `tests/`:
|
||||
|
||||
- `test_time_calculator.py` — clock-out, overtime truncation, holiday overtime, day-type detection
|
||||
- `test_database.py` — settings, migrations, leave calculations, consecutive OT days
|
||||
- `test_csv_importer.py` — CSV parsing, validation, conflict handling
|
||||
- `test_salary.py` — pay estimation and won formatting
|
||||
- `test_recurring_leaves.py` — pattern parsing and expansion
|
||||
- `test_crash_handler.py` — crash log insertion and Gitea reporting (mocked)
|
||||
- `test_discord_webhook.py` — URL validation, payload shape, network errors
|
||||
- `test_holiday_api.py` — API response parsing and error handling
|
||||
- `test_i18n.py` — language switching, missing-key fallback, interpolation
|
||||
- `test_i18n_runtime.py` — runtime retranslation of Qt widgets
|
||||
- `test_overtime_accrual_guard.py` — auto-overtime rollover guard
|
||||
- `test_updater.py` — version parsing, Gitea API URL, update logic
|
||||
|
||||
`tests/conftest.py` sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` so the background holiday-sync thread does not hold open temporary DB files during test cleanup.
|
||||
|
||||
**Current status:** `194 passed`.
|
||||
|
||||
### 5.2 Integration scenarios
|
||||
|
||||
```bash
|
||||
python _integration_test.py
|
||||
```
|
||||
|
||||
A standalone script with a custom `@case` decorator. It runs **53 scenarios** (S1–S52, S52A–E) covering fresh-install migrations, work-pattern calculations, overtime banking, leave, weekends/holidays, notifications, backup, settings sync, i18n, CSV import/export, salary, crash log, updater semver, Discord guards, goals, and accessibility keys.
|
||||
|
||||
**Current status:** `PASS: 53 FAIL: 0 WARN: 0`.
|
||||
|
||||
### 5.3 GUI smoke tests
|
||||
|
||||
```bash
|
||||
python _i18n_gui_test.py # Korean/English label switching
|
||||
python _gui_smoke_test.py # Widget instantiation
|
||||
```
|
||||
|
||||
Both use `QT_QPA_PLATFORM=offscreen` so no real windows appear.
|
||||
|
||||
- `_i18n_gui_test.py`: 5 cases, **all passing**.
|
||||
- `_gui_smoke_test.py`: 8 cases, **all passing**.
|
||||
|
||||
### 5.4 Pre-release test command summary
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -q
|
||||
python _integration_test.py
|
||||
python _i18n_gui_test.py
|
||||
python _gui_smoke_test.py
|
||||
```
|
||||
|
||||
`release.ps1` runs the first two by default (skippable with `-SkipTests`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Style and Conventions
|
||||
|
||||
### Identifiers
|
||||
|
||||
- Classes: `PascalCase`
|
||||
- Functions / variables: `snake_case`
|
||||
- Module-level constants (especially setting keys): `UPPER_CASE`
|
||||
|
||||
### Settings keys
|
||||
|
||||
All setting keys are defined as constants in `core/settings_keys.py`. Import and use the constants; **never use raw strings** for new logic. When adding a new key, also add a default value in `Database.init_default_settings()`.
|
||||
|
||||
### Imports
|
||||
|
||||
Order is typically: standard library → third-party → project modules. Several newer modules use `from __future__ import annotations`.
|
||||
|
||||
### Comments and docstrings
|
||||
|
||||
Code identifiers are English, but inline comments and docstrings are predominantly Korean. New code should follow the existing bilingual style: English identifiers, Korean explanatory comments.
|
||||
|
||||
### UI construction
|
||||
|
||||
- Build widgets in `init_ui()`.
|
||||
- Use helper methods such as `create_*_group()` for readability.
|
||||
- Set `objectName` for QSS styling.
|
||||
- Always verify `self.setLayout(main_layout)` is at the **end** of `init_ui()`, not accidentally indented into a method body.
|
||||
|
||||
### Type hints
|
||||
|
||||
Use type hints on public DB and helper methods (`-> int`, `-> Optional[Dict]`, `-> List[Dict]`, etc.).
|
||||
|
||||
### String formatting
|
||||
|
||||
Use f-strings for internal messages. For user-visible text, use the i18n API:
|
||||
|
||||
```python
|
||||
from core.i18n import tr
|
||||
tr('key', name=value)
|
||||
```
|
||||
|
||||
### No enforced linter
|
||||
|
||||
There is no `pyproject.toml`, `.flake8`, or `setup.cfg`. Formatting is informal; keep line lengths reasonable and match the surrounding style.
|
||||
|
||||
---
|
||||
|
||||
## 7. Critical Invariants (MUST PRESERVE)
|
||||
|
||||
### 7.1 Time-off subtraction order in `MainWindow.update_display()`
|
||||
|
||||
Pass the actual `break_minutes` to `TimeCalculator.calculate_remaining_time()`, then subtract `total_time_off = overtime_used + leave_used` from the resulting `timedelta` **after** the call. **Never** mutate `break_minutes` to `break_minutes - overtime_used` before calling. Doing so previously caused a "+29h remaining time" bug.
|
||||
|
||||
```python
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
overtime_used_today = self.db.get_today_overtime_usage()
|
||||
leave_used_today = self.db.get_today_leave_minutes()
|
||||
total_time_off = overtime_used_today + leave_used_today
|
||||
remaining = self.time_calc.calculate_remaining_time(..., break_minutes=break_minutes)
|
||||
remaining -= timedelta(minutes=total_time_off)
|
||||
```
|
||||
|
||||
### 7.2 Hot-path caching
|
||||
|
||||
`update_display()` runs at **1 Hz**. DB reads inside it must be cached or throttled:
|
||||
|
||||
- `cached_time_format` in `MainWindow`
|
||||
- `AutoLunchManager` caches enabled/non-working state
|
||||
- `NotificationOrchestrator` gates periodic checks to 5-minute buckets
|
||||
- Use `_set_text_if_changed()` to avoid useless repaints
|
||||
|
||||
### 7.3 24-hour internal time
|
||||
|
||||
All calculation uses 24-hour `datetime`. 12-hour conversion (Korean "오전"/"오후") happens only in `MainWindow.format_time()`.
|
||||
|
||||
### 7.4 Workday boundary
|
||||
|
||||
`workday_boundary_hour` defaults to 6. Overnight work stays on the previous day's record until that hour. Do not naively use `date.today()` in time logic.
|
||||
|
||||
### 7.5 Database invariants
|
||||
|
||||
- `work_records.date` is `UNIQUE`.
|
||||
- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are **NULLable** for manual entries. Never filter `WHERE work_record_id IS NOT NULL`. Render NULL rows as "수동 추가" / "Manual".
|
||||
- `leave_records.days` is `REAL` (1.0 / 0.5 / 0.25 / hourly).
|
||||
- Canonical time unit is **minutes** (`work_minutes`). `work_hours` is a derived/floor-synced property. Use `int(minutes) // 60` for hours, not `round()`.
|
||||
- Overtime balance = `SUM(bank.earned_minutes) - SUM(usage.used_minutes)` plus any `INITIAL_OVERTIME_MINUTES`.
|
||||
- WAL mode + 5-second busy timeout is enabled for cloud-sync friendliness (OneDrive/Dropbox).
|
||||
|
||||
### 7.6 Migration idempotency
|
||||
|
||||
Every `migrate_*()` method must early-return if already applied. Use sentinel settings or `IF NOT EXISTS`. Examples: `balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`.
|
||||
|
||||
### 7.7 Settings auto-sync pairs
|
||||
|
||||
`Database.save_settings()` automatically keeps these pairs in sync:
|
||||
|
||||
- `WORK_MINUTES ↔ WORK_HOURS` (floor division)
|
||||
- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL`
|
||||
|
||||
### 7.8 Single-file deployment and updater handoff
|
||||
|
||||
- `main.exe` embeds `updater.exe` via `main.spec` data files.
|
||||
- `_ensure_updater_extracted()` in `main.py` extracts the embedded updater on first launch from `sys._MEIPASS`.
|
||||
- Protect the staging copy at `build/staging/updater.exe`.
|
||||
- `updater.py` is **standalone** (no Qt/network deps). It accepts `--pid`, `--new`, and `--target`, waits for the PID, swaps files with `.bak` rollback, and relaunches.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
### 8.1 HTTP API
|
||||
|
||||
`utils/http_api.py` is **not present** in the current tree. If it is reintroduced, it must bind to `127.0.0.1` only and remain read-only. Never expose it externally.
|
||||
|
||||
### 8.2 Discord webhook
|
||||
|
||||
- URL is validated against official Discord webhook domains (`discord.com`, `discordapp.com`, canary/ptb).
|
||||
- A browser User-Agent is mandatory; Cloudflare blocks the default Python UA.
|
||||
- Push failures are silent so they do not break the app.
|
||||
|
||||
### 8.3 Auto-update
|
||||
|
||||
- Update check polls a self-hosted Gitea API: `https://kindnick-git.duckdns.org/api/v1/repos/kindnick/Clock_out_Time_Calculator/releases/latest`.
|
||||
- Downloads `main_new.exe` next to the running executable; `updater.exe` performs the swap with `.bak` rollback.
|
||||
- Update apply only works in frozen builds; development `.py` runs are notified but not modified.
|
||||
|
||||
### 8.4 DB path handling and cloud sync
|
||||
|
||||
- `main.py` and `MainWindow` both bootstrap with the default DB first, read `DB_PATH_OVERRIDE`, then reopen with the override path. Do not break this order.
|
||||
- WAL mode + busy timeout tolerates OneDrive/Dropbox sync.
|
||||
|
||||
### 8.5 Crash reporting
|
||||
|
||||
- Gitea issue reporting is **opt-in** via `GITEA_FEEDBACK_ENABLED` + `GITEA_FEEDBACK_TOKEN`.
|
||||
- Tokens are stored as plain strings in the SQLite `settings` table. The UI uses `QLineEdit.Password` echo mode.
|
||||
|
||||
### 8.6 Single instance
|
||||
|
||||
Multiple copies are prevented via `QLocalServer` named `ClockOutCalculatorInstance`.
|
||||
|
||||
### 8.7 Debug logging
|
||||
|
||||
`utils/debug_log.py` only emits output when `CLOCKOUT_DEBUG=1` (or `true`/`yes`).
|
||||
|
||||
---
|
||||
|
||||
## 9. External Integrations
|
||||
|
||||
- **Auto-update** (`utils/updater_client.py`): polls the Gitea Releases API. User-Agent: `ClockOutCalculator-Updater/1.0`.
|
||||
- **Discord webhook** (`utils/discord_webhook.py`): optional one-direction push. Browser User-Agent required.
|
||||
- **Gitea Issues** (`utils/crash_handler.py`): optional crash reporting; opt-in only.
|
||||
- **Cloud sync via `DB_PATH_OVERRIDE`**: settings stores the DB path; the app reopens the override path after reading it.
|
||||
- **`holidays` package**: `Database.add_korean_holidays_auto()` returns `-1` if the package is missing, and the UI falls back to `add_korean_holidays()` (8 fixed dates).
|
||||
- **Korean holiday API** (`utils/holiday_api.py`): 공공데이터포털 특일정보 API 키는 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수에서 읽음. 소스코드/바이너리에 키를 하드코딩하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 10. i18n Conventions
|
||||
|
||||
- API: `tr(key, **kwargs)` and `tr_html(key)` from `core/i18n.py`.
|
||||
- Translations live in `_DICT['ko']` and `_DICT['en']`. **Both languages must receive any new key.**
|
||||
- Sentence interpolation uses Python `str.format(**kwargs)`.
|
||||
- HelpView HTML content is in `_HELP_HTML`.
|
||||
- Runtime retranslation is supported via `ui/i18n_runtime.py` using a weakref registry. Register widgets with `register(widget, key, setter='setText', kwargs={}, post=None)` and call `set_language_and_retranslate(lang)` to update them.
|
||||
- UI files (`ui/`) and achievement metadata (`core/achievements.py`) are fully key-based.
|
||||
- Remaining P4 internal-data hardcoding (not user-facing labels) includes DB-stored `leave_type` values and Korean holiday names in `core/database.py`.
|
||||
- A language change may still prompt a restart for widgets not registered for runtime retranslate.
|
||||
|
||||
---
|
||||
|
||||
## 11. Release Flow
|
||||
|
||||
`release.ps1 vX.Y.Z` performs the full release locally:
|
||||
|
||||
1. **Pre-checks**: verify `$env:GITEA_TOKEN`, no running `main.exe`, no existing tag, no uncommitted changes (unless `-DryRun`).
|
||||
2. **Bump version**: rewrite `core/version.py`.
|
||||
3. **Tests**: run `pytest tests/` and `python _integration_test.py` (skippable with `-SkipTests`).
|
||||
4. **Build**: `updater.spec` → `build/staging/` → `main.spec`.
|
||||
5. **Code signing** (optional): sign both EXEs if `$env:CODE_SIGN_CERT` is set.
|
||||
6. **ZIP packaging**: `dist/ClockOutCalculator-vX.Y.Z.zip` containing `main.exe` and `updater.exe`.
|
||||
7. **Git commit + tag + push**: commit `core/version.py` and `CHANGELOG.md`.
|
||||
8. **Gitea Release POST**: read `CHANGELOG.md` with UTF-8 to avoid PowerShell 5.1 ANSI mangling, extract the matching section.
|
||||
9. **Asset upload**: `main.exe`, `updater.exe`, and the ZIP.
|
||||
|
||||
Use `-DryRun` to preview without git push or API calls.
|
||||
|
||||
---
|
||||
|
||||
## 12. Past Incidents (Do Not Re-introduce)
|
||||
|
||||
| Incident | Root cause / fix |
|
||||
|----------|------------------|
|
||||
| +29h remaining time | Mutating `break_minutes -= overtime_used` before calculation. Fixed by subtracting `total_time_off` after `calculate_remaining_time`. |
|
||||
| Manual overtime invisible | Filtering `work_record_id IS NOT NULL`. Now show all rows and label NULL as "수동 추가" / "Manual". |
|
||||
| `annual_leave_total` vs `annual_leave_days` | Two keys for the same value; auto-synced in `save_settings()`. |
|
||||
| Banker's rounding | `round(450/60) = 8` because Python rounds half to even. Use `int(minutes) // 60`. |
|
||||
| FK enforcement rollback | `PRAGMA foreign_keys=ON` conflicted with existing manual overtime records. Kept WAL + timeout, no FK enforcement. |
|
||||
| Discord 403 / Cloudflare 1010 | Default Python UA blocked. Fixed with browser UA. |
|
||||
| Help dialog blank | `self.setLayout(main_layout)` indented into `_reopen_onboarding`. Verify `setLayout()` is at the end of `init_ui()`. |
|
||||
| `dist/updater.exe` wiped by `--clean` | Solved by staging copy at `build/staging/updater.exe`. |
|
||||
| Onboarding auto-skipped | Existing users with `work_records` were auto-completed. Added "Re-run Onboarding" button to Help. |
|
||||
| PowerShell 5.1 ANSI | `Get-Content`/`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.
|
||||
|
||||
+217
@@ -4,6 +4,223 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.12.0] — 2026-06-16
|
||||
|
||||
### Added — 전체 i18n 키화 완료
|
||||
- **UI 전체 사용자 대면 문자열 i18n 키화** — `ui/` 디렉터리 22개 파일의 버튼/라벨/메시지박스/차트/온볼딩/설정/통계 등을 `tr()` 기반으로 전환, `core/i18n.py`에 ko/en 번역 추가.
|
||||
- **도전과제 메타데이터 i18n** — `core/achievements.py`의 모든 도전과제 이름/설명을 `achieve.{code}.name/desc` 키로 분리하고 영문 번역 추가.
|
||||
- **차트 위젯 라벨 키화** — matplotlib 차트의 축/툴팁/범례/빈 기록 메시지 등을 언어별로 표시.
|
||||
- **반복 연차 패턴 설명 키화** — `core/recurring_leaves.describe_pattern()`이 요일/주기 접두사를 i18n 키로 조합.
|
||||
|
||||
### Fixed
|
||||
- `ui/help_view.py`의 "온보팅 다시 보기" 버튼이 `tr()` 호출이 아닌 리터럴 문자열로 잘못 들어가던 버그 수정.
|
||||
- `core/i18n.py` 영문 achievement 번역 중 `Children's`/`Teachers'` 작은따옴표로 인한 SyntaxError 수정.
|
||||
- `_i18n_gui_test.py`에 `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 추가하여 백그라운드 휴일 동기화 스레드로 인한 세그멘테이션 폴트 방지.
|
||||
|
||||
### Changed
|
||||
- **공공데이터포털 공휴일 API 키 외부화** — `utils/holiday_api.py`가 `CLOCKOUT_HOLIDAY_API_KEY` 환경변수를 사용하도록 변경.
|
||||
- `ui/main_window.py` 1Hz 핫패스 DB 호출 캐싱 추가.
|
||||
- `utils/csv_importer.py` overwrite 시 `overtime_usage`도 함께 삭제.
|
||||
- `ui/controllers/lock_monitor.py` 컨텍스트 매니저 적용 및 race condition 처리 개선.
|
||||
|
||||
## [2.11.2] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
|
||||
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
|
||||
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
|
||||
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
|
||||
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
|
||||
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
|
||||
|
||||
## [2.11.1] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
|
||||
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget`이 `backend_qt5agg`를
|
||||
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
|
||||
폴백) + 실패 원인 로깅, `main.spec`에 `backend_qtagg`/`PyQt5.sip` 명시.
|
||||
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제** — `dark_components`와
|
||||
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
|
||||
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
|
||||
|
||||
## [2.11.0] — 2026-06-04
|
||||
|
||||
### Changed — UI 전면 다크 리디자인
|
||||
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
|
||||
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
|
||||
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
|
||||
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
|
||||
`main.spec`에 동봉
|
||||
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
|
||||
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
|
||||
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
|
||||
|
||||
### Added
|
||||
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
|
||||
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함)
|
||||
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
|
||||
(`Database.delete_overtime_earned`)
|
||||
|
||||
### Fixed
|
||||
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
|
||||
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
|
||||
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
|
||||
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
|
||||
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
|
||||
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
|
||||
|
||||
### Tests
|
||||
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
|
||||
|
||||
## [2.10.2] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
|
||||
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
|
||||
그대로 표시에 사용 → 초 정보 소실
|
||||
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
|
||||
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
|
||||
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
|
||||
|
||||
## [2.10.1] — 2026-05-01
|
||||
|
||||
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
|
||||
- **`updater.spec`**: `console=True` → `console=False` (windowed 빌드).
|
||||
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
|
||||
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
|
||||
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
|
||||
에 타임스탬프 + 결과 기록.
|
||||
- **`updater.py launch()`**: `subprocess.Popen` 에 `CREATE_NO_WINDOW` 플래그 추가
|
||||
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
|
||||
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
|
||||
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
|
||||
|
||||
## [2.10.0] — 2026-05-01
|
||||
|
||||
### Added — 정부 공휴일 API 자동 동기화
|
||||
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
|
||||
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
|
||||
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
|
||||
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
|
||||
- 키는 dev 본인 계정의 특일정보 API 한정 키
|
||||
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
|
||||
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
|
||||
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
|
||||
- sentinel: `settings['holidays_synced_date']`
|
||||
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
|
||||
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
|
||||
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
|
||||
|
||||
### Changed
|
||||
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
|
||||
|
||||
### Tests
|
||||
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
|
||||
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
|
||||
- pytest: 175 → **189**
|
||||
|
||||
### 주의
|
||||
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
|
||||
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
|
||||
|
||||
## [2.9.0] — 2026-05-01
|
||||
|
||||
### Fixed — 휴일 hot-path 버그 (사용자 보고)
|
||||
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
|
||||
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
|
||||
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
|
||||
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
|
||||
- 진행바: 휴일은 100% 고정 (의미 없음)
|
||||
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
|
||||
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
|
||||
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
|
||||
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
|
||||
|
||||
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
|
||||
|
||||
#### Phase 1 — 연차 미리등록 + 자동 적용
|
||||
- **DB:** `get_leave_minutes_for(date)` / `has_full_day_leave(date)` /
|
||||
`get_leave_records_by_date(date)` / `get_leave_records_by_range(start, end)`
|
||||
- **TimeCalculator:** `effective_work_minutes(date_obj, db)` — 부분 연차만큼 정규 근무 차감
|
||||
- **종일 연차일 자동 처리:**
|
||||
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
|
||||
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
|
||||
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
|
||||
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
|
||||
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
|
||||
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
|
||||
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
|
||||
|
||||
#### Phase 2 — 통합 스케줄 + 반복 연차
|
||||
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
|
||||
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
|
||||
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
|
||||
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
|
||||
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
|
||||
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
|
||||
|
||||
### Changed
|
||||
- **근로자의 날(5/1) 자동 추가** — `holidays.KR` 패키지가 누락하는 노동자 휴일을
|
||||
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
|
||||
|
||||
### Tests
|
||||
- pytest: 122 → **175** (+53)
|
||||
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
|
||||
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
|
||||
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
|
||||
- 통합 시나리오: 48 → **53** (+5)
|
||||
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
|
||||
|
||||
## [2.8.0] — 2026-05-01
|
||||
|
||||
### Added — 도전과제 시스템 + 디자인 리뉴얼
|
||||
- **🏆 도전과제 시스템** (153개 자동 평가) — 출근·퇴근·연장·연차·식사·외출·계절·시간대·시크릿 등 16개 카테고리.
|
||||
- `core/achievements.py`: `Achievement` dataclass + `evaluate_all(db)` + `sync_definitions_to_db(db)`.
|
||||
- 5분 throttle로 자동 평가, 신규 잠금 해제 시 시스템 알림 + Discord embed push.
|
||||
- `achievements` 테이블 확장: `code`(UNIQUE), `category`, `tier`, `is_secret`, `progress`, `target`, `created_at` 컬럼 추가 (idempotent 마이그레이션).
|
||||
- 5단계 등급: 🥉 브론즈 / 🥈 실버 / 🥇 골드 / 💎 플래티넘 / 🌟 레전드.
|
||||
- 시크릿 9개 (회문, 잭팟 시각, 13일의 금요일, 7-7-7, 정확 8시간, π day, 피보나치 등).
|
||||
- 메타 도전과제 (도전과제 자체 달성 카운트).
|
||||
- **`ui/achievements_view.py`** — 4탭 다이얼로그 (전체 / 진행 중 / 완료 / 시크릿).
|
||||
- 등급별 그라디언트 카드, 진행 게이지, 카테고리 태그, 시크릿 ❓ 처리.
|
||||
- **자동 hire_date 추적** — 첫 `add_work_record` 호출 시 settings에 자동 기록 (1주년, 365일 후 출근 등 도전과제 활성화).
|
||||
- **뷰 진입 카운터** — `stat_*_view_count`, `calendar_view_count`, `daily_report_count` 등 8개 settings 키. 도전과제 + 사용 통계용.
|
||||
|
||||
### Changed — 다크 테마 디자인 리뉴얼
|
||||
- **`ui/dark_components.py`** 신설 — 재사용 가능한 다크 디자인 컴포넌트:
|
||||
- `dialog_qss()`, `tabs_qss()`, `scroll_qss()`, `button_qss(variant)`.
|
||||
- `build_gradient_header()`, `build_stat_card()`, `build_section_card()`.
|
||||
- `style_progressbar()`, `transparent_label()` — 글로벌 QSS 충돌 회피.
|
||||
- 카드 테마 7종: blue / cyan / green / gold / pink / red / gray.
|
||||
- **`ui/stats_view.py`** — 4분할 카드 + 차트 섹션 카드. 골드 강조 탭. ghost 닫기 버튼.
|
||||
- **`ui/help_view.py`** — 다크 톤 + HelpHTML 내부에 다크 CSS 주입 (h1/h2/h3, code, blockquote, table 컬러).
|
||||
- **`ui/chart_widget.py`** — 모든 matplotlib 차트 다크 테마 적용 (figure/axes facecolor, grid, ticks, legend).
|
||||
- **온보딩 위저드** — 저녁 분(minutes) 입력 옵션 + i18n화 (Korean/English 프리셋 라벨).
|
||||
|
||||
### Fixed — 안정성·일관성
|
||||
- **타임존 자정 경계 버그**: `has_notification_today`가 `CURRENT_TIMESTAMP`(UTC) vs `DATE('now', 'localtime')` mismatch로 KST 0~9시 사이 알림 중복 발송 가능 — `DATE(sent_at, 'localtime')` 양쪽 적용.
|
||||
- **DB 연결 누수 가드** — `_conn()` 컨텍스트 매니저 도입, 40+ 메서드 변환 (직접 `get_connection()` 호출 0건).
|
||||
- **DB 이중 부트스트랩 제거** — `MainWindow(db=None)` 옵션 인자 추가, main.py가 만든 db를 재사용.
|
||||
- **crash_handler 폴백** — DB 로깅/다이얼로그 단계 분리, 모두 실패해도 `~/.clockout_logs/crashes.log`에 기록.
|
||||
- **updater PID race window** — 지수 backoff 재시도 (0.3→4.8s, 총 ~9초). Windows Defender 락 해제 대기 충분.
|
||||
- **Discord URL 형식 검증** — Snowflake ID 17~20자리 + 50+자 토큰 정규식.
|
||||
- **MealTimeDialog** — 출근 시각 범위 검증 + 야간 출근자 자정 경계 자동 처리.
|
||||
- **CSV importer/exporter** — `dinner_minutes` 컬럼 round-trip 지원.
|
||||
- **일일 보고서** — 저녁 섹션 추가 (이전엔 점심만 표시), `break_type` 분리, 자정 경계 처리.
|
||||
- **저녁 알림** — `check_dinner_reminder()` 신규 (점심 알림과 대칭).
|
||||
- **알림 임계값 설정화** — 5개 하드코딩 값(점심/저녁 알림 시간, 연장 누적, 주간 한도, 연속 야근)을 settings로 노출.
|
||||
- **closeEvent 정리** — `aboutToQuit` 시그널로 timer/notifier/tray 정리.
|
||||
- **마이그레이션 일관성** — 12개 마이그레이션 모두 `_conn()` + try/finally 보장.
|
||||
|
||||
### DB 인덱스 (성능)
|
||||
- `idx_break_records_date_type`, `idx_break_records_date`.
|
||||
- `idx_overtime_bank_date`, `idx_overtime_usage_date`, `idx_leave_records_date`.
|
||||
|
||||
### Tests
|
||||
- pytest 116개 PASS, 통합 시나리오 44/48 PASS (PyQt 환경 4개 제외).
|
||||
- 도전과제 한 사이클 시나리오 검증 (30일 시뮬레이션 → 19개 자동 잠금 해제).
|
||||
|
||||
## [2.7.0] — 2026-04-30
|
||||
|
||||
### Added — 폴리싱 릴리스 (사용자 가시 변화는 작지만 i18n + 테스트 + 구조 개선)
|
||||
|
||||
@@ -67,6 +67,11 @@ $env:GITEA_TOKEN = '<PAT>'
|
||||
- **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot).
|
||||
- **[auto_lunch.py](ui/controllers/auto_lunch.py)** — 4-hour-since-clock-in auto-toggle lunch. Setting cache + non-working-day cache.
|
||||
- **[notification_orchestrator.py](ui/controllers/notification_orchestrator.py)** — 1Hz tick orchestrates 7 notifications. 5-min throttle for health/weekly/threshold. Monday weekly report + Discord push.
|
||||
- **[meal_controller.py](ui/controllers/meal_controller.py)** — Lunch/dinner toggle + label refresh, extracted from `main_window.py` in v2.7.0. Same controller pattern as Lock/AutoLunch/Notification.
|
||||
|
||||
### ui/ (cross-cutting)
|
||||
- **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally.
|
||||
- **[styles.py](ui/styles.py)** — Shared QSS / color tokens.
|
||||
|
||||
### utils/
|
||||
- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API.
|
||||
@@ -74,6 +79,8 @@ $env:GITEA_TOKEN = '<PAT>'
|
||||
- **[discord_webhook.py](utils/discord_webhook.py)** — `send_test/clock_in/clock_out/health_warning`. Browser User-Agent (Cloudflare bypass).
|
||||
- **[updater_client.py](utils/updater_client.py)** — Gitea Releases API. `check_for_update()` returns `(info, reason)` tuple — reasons: `UP_TO_DATE`/`NETWORK_ERROR`/`NO_RELEASE`/`NO_ASSET`. `apply_update()` invokes updater.exe.
|
||||
- **[csv_importer.py](utils/csv_importer.py)** — `parse_csv()` + `import_records(on_conflict='skip'|'overwrite')`. Standard format: `date,clock_in,clock_out,lunch_minutes,memo`.
|
||||
- **[csv_exporter.py](utils/csv_exporter.py)** — Same standard format as importer. Round-trips with `csv_importer`.
|
||||
- **[resource_manager.py](utils/resource_manager.py)** — PyInstaller `_MEIPASS`-aware path resolver for icons / assets.
|
||||
- **[crash_handler.py](utils/crash_handler.py)** — `install_global_handler(db, version)` registers `sys.excepthook`. Logs to crash_log + shows dialog with copy/Gitea-report buttons.
|
||||
- **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`.
|
||||
- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper.
|
||||
@@ -137,7 +144,9 @@ Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via
|
||||
- [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt).
|
||||
- [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`.
|
||||
- [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets.
|
||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler` (v2.7.0+).
|
||||
- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`).
|
||||
|
||||
Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`.
|
||||
|
||||
All should be green before any release.
|
||||
|
||||
|
||||
+21
-5
@@ -11,6 +11,9 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Qt platform plugin: offscreen으로 실제 창 안 뜨게
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
|
||||
# 백그라운드 휴일 동기화 스레드 비활성화 (DB lock / segfault 방지)
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
@@ -87,7 +90,7 @@ def test_stats_view():
|
||||
from ui.stats_view import StatsView
|
||||
dlg = StatsView(db=db)
|
||||
# 데이터 없어도 정상 로드
|
||||
assert dlg.weekly_total_hours.text() is not None
|
||||
assert dlg.weekly_total_card is not None
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
@@ -101,12 +104,25 @@ def test_main_window_init():
|
||||
"""MainWindow 초기화 — 가장 무거운 케이스"""
|
||||
# QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인
|
||||
from ui.main_window import MainWindow
|
||||
w = MainWindow()
|
||||
from datetime import date as _date
|
||||
# MainWindow load_today_data에서 QMessageBox를 띄우지 않도록 오늘 출근 기록을 미리 삽입
|
||||
today = _date.today().isoformat()
|
||||
conn = db.get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM work_records WHERE date = ?", (today,))
|
||||
conn.execute(
|
||||
"INSERT INTO work_records (date, clock_in, clock_out, lunch_break, dinner_break) VALUES (?, ?, ?, ?, ?)",
|
||||
(today, '09:00:00', '18:00:00', 0, 0)
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
w = MainWindow(db=db)
|
||||
# 기본 상태
|
||||
assert w.is_clocked_in == False
|
||||
assert w.is_clocked_in == False # 퇴근 완료 기록이므로 False
|
||||
assert w.lunch_break_enabled == False
|
||||
# auto_lunch 캐시 초기 None
|
||||
assert w._auto_lunch_enabled_cache is None
|
||||
# auto_lunch 캐시 초기 None (AutoLunchManager 낶)
|
||||
assert w._auto_lunch._enabled_cache is None
|
||||
# 단축키 7개 등록되었는지
|
||||
from PyQt5.QtWidgets import QShortcut
|
||||
shortcuts = w.findChildren(QShortcut)
|
||||
|
||||
@@ -9,6 +9,7 @@ import sys
|
||||
import tempfile
|
||||
|
||||
os.environ['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox
|
||||
|
||||
@@ -15,6 +15,11 @@ 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 = []
|
||||
@@ -696,6 +701,74 @@ def s51_accessibility_keys():
|
||||
assert db.get_setting_bool('high_contrast') is True
|
||||
|
||||
|
||||
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
|
||||
def s52b_planned_leave():
|
||||
db = fresh_db('s52b')
|
||||
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||
assert db.has_full_day_leave('2026-05-15')
|
||||
assert db.get_leave_minutes_for('2026-05-15') == 480
|
||||
# 다른 날엔 영향 없음
|
||||
assert not db.has_full_day_leave('2026-05-16')
|
||||
|
||||
|
||||
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
|
||||
def s52c_recurring_leave():
|
||||
db = fresh_db('s52c')
|
||||
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
# 2026-05-01 = Friday
|
||||
assert db.get_leave_minutes_for('2026-05-01') == 240
|
||||
# Monday
|
||||
assert db.get_leave_minutes_for('2026-05-04') == 0
|
||||
# 종일 아님
|
||||
assert not db.has_full_day_leave('2026-05-01')
|
||||
|
||||
|
||||
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
|
||||
def s52d_effective():
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52d')
|
||||
db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||
calc = TimeCalculator(work_minutes=480)
|
||||
target = datetime(2026, 5, 15)
|
||||
assert calc.effective_work_minutes(target, db) == 240
|
||||
# 다른 날엔 변화 없음
|
||||
other = datetime(2026, 5, 16)
|
||||
assert calc.effective_work_minutes(other, db) == 480
|
||||
|
||||
|
||||
@case("S52E. effective_work_minutes: 종일 연차 시 0")
|
||||
def s52e_full_day():
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52e')
|
||||
db.add_leave_record('2026-05-15', '연차', 1.0)
|
||||
calc = TimeCalculator(work_minutes=480)
|
||||
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
|
||||
|
||||
|
||||
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
|
||||
def s52a_holiday_hotpath():
|
||||
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52a')
|
||||
holiday_date = '2026-05-01' # 근로자의 날
|
||||
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
|
||||
|
||||
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
# 휴일 인식
|
||||
assert calc.is_non_working_day(ci, db)
|
||||
assert calc.get_day_type(ci, db) == 'holiday'
|
||||
|
||||
# 출근 1분 후: 적립 0 (30분 단위 절삭)
|
||||
now1 = ci + timedelta(minutes=1)
|
||||
actual, earned = calc.calculate_holiday_overtime(ci, now1)
|
||||
assert actual == 1 and earned == 0
|
||||
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
|
||||
now30 = ci + timedelta(minutes=30)
|
||||
actual, earned = calc.calculate_holiday_overtime(ci, now30)
|
||||
assert actual == 30 and earned == 30
|
||||
|
||||
|
||||
@case("S52. CSV import + overtime 적립까지 정상 동작")
|
||||
def s52_csv_overtime():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
@@ -764,6 +837,11 @@ def main():
|
||||
s49_discord_empty()
|
||||
s50_goals()
|
||||
s51_accessibility_keys()
|
||||
s52a_holiday_hotpath()
|
||||
s52b_planned_leave()
|
||||
s52c_recurring_leave()
|
||||
s52d_effective()
|
||||
s52e_full_day()
|
||||
s52_csv_overtime()
|
||||
|
||||
print()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+613
-510
File diff suppressed because it is too large
Load Diff
+1916
-52
File diff suppressed because it is too large
Load Diff
+58
-37
@@ -7,11 +7,25 @@ from typing import Optional
|
||||
from PyQt5.QtCore import QTimer, QObject, pyqtSignal
|
||||
|
||||
from core.settings_keys import (
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
|
||||
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
|
||||
OVERTIME_UNIT,
|
||||
)
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
def _get_int_setting(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||
"""db에서 정수 설정값을 안전하게 읽어 [lo, hi]로 클램프."""
|
||||
if db is None:
|
||||
return default
|
||||
try:
|
||||
v = int(db.get_setting(key, str(default)) or default)
|
||||
except (ValueError, TypeError):
|
||||
v = default
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
class Notifier(QObject):
|
||||
"""알림 시스템 클래스"""
|
||||
|
||||
@@ -27,6 +41,7 @@ class Notifier(QObject):
|
||||
# 알림 상태 추적
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_dinner = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
@@ -85,46 +100,52 @@ class Notifier(QObject):
|
||||
|
||||
def check_lunch_reminder(self, clock_in_time: datetime, lunch_enabled: bool,
|
||||
current_time: Optional[datetime] = None):
|
||||
"""
|
||||
점심시간 등록 알림
|
||||
Args:
|
||||
clock_in_time: 출근 시간
|
||||
lunch_enabled: 점심시간 등록 여부
|
||||
current_time: 현재 시간
|
||||
"""
|
||||
"""점심시간 등록 알림. 출근 후 LUNCH_REMINDER_HOURS 경과 시."""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
if not self._enabled(NOTIF_LUNCH):
|
||||
return
|
||||
|
||||
# 이미 점심 등록했거나, 이미 알림 보냈으면 스킵
|
||||
if lunch_enabled or self.notified_lunch:
|
||||
return
|
||||
|
||||
# 출근 후 4시간 경과 (점심시간으로 추정)
|
||||
time_since_clock_in = current_time - clock_in_time
|
||||
if time_since_clock_in.total_seconds() >= 4 * 3600:
|
||||
threshold_hours = _get_int_setting(self.db, LUNCH_REMINDER_HOURS, 4, 1, 12)
|
||||
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.lunch_reminder.title'),
|
||||
tr('notif.lunch_reminder.body'),
|
||||
)
|
||||
self.notified_lunch = True
|
||||
|
||||
def check_dinner_reminder(self, clock_in_time: datetime, dinner_enabled: bool,
|
||||
current_time: Optional[datetime] = None):
|
||||
"""저녁시간 등록 알림. 출근 후 DINNER_REMINDER_HOURS 경과 시.
|
||||
|
||||
야근(연장근무) 사용자가 저녁을 깜빡 잊는 패턴 대응.
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
if not self._enabled(NOTIF_DINNER):
|
||||
return
|
||||
if dinner_enabled or self.notified_dinner:
|
||||
return
|
||||
|
||||
threshold_hours = _get_int_setting(self.db, DINNER_REMINDER_HOURS, 8, 1, 16)
|
||||
if (current_time - clock_in_time).total_seconds() >= threshold_hours * 3600:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.dinner_reminder.title'),
|
||||
tr('notif.dinner_reminder.body'),
|
||||
)
|
||||
self.notified_dinner = True
|
||||
|
||||
def check_overtime_earning(self, overtime_minutes: int):
|
||||
"""
|
||||
연장근무 적립 알림
|
||||
Args:
|
||||
overtime_minutes: 예상 연장근무 시간 (분)
|
||||
"""
|
||||
"""연장근무 적립 알림. OVERTIME_UNIT 이상 적립 예정 시 한 번."""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
if overtime_minutes >= 30 and not self.notified_overtime:
|
||||
hours = overtime_minutes // 60
|
||||
mins = overtime_minutes % 60
|
||||
|
||||
# overtime_unit 설정값을 임계로 사용 (15/30/60 — 사용자가 선택한 단위)
|
||||
unit = _get_int_setting(self.db, OVERTIME_UNIT, 30, 1, 240)
|
||||
if overtime_minutes >= unit and not self.notified_overtime:
|
||||
from utils.time_format import format_hours_minutes
|
||||
time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True)
|
||||
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_earning.title'),
|
||||
tr('notif.overtime_earning.body', time_str=time_str),
|
||||
@@ -132,10 +153,11 @@ class Notifier(QObject):
|
||||
self.notified_overtime = True
|
||||
|
||||
def notify_overtime_threshold(self, total_overtime_hours: float):
|
||||
"""연장근무 누적 알림 (20시간 이상)"""
|
||||
"""연장근무 누적 알림 (OVERTIME_THRESHOLD_HOURS 이상)"""
|
||||
if not self._enabled(NOTIF_OVERTIME):
|
||||
return
|
||||
if total_overtime_hours >= 20 and not self.notified_threshold:
|
||||
threshold = _get_int_setting(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||
if total_overtime_hours >= threshold and not self.notified_threshold:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.overtime_threshold.title'),
|
||||
tr('notif.overtime_threshold.body', hours=total_overtime_hours),
|
||||
@@ -143,10 +165,11 @@ class Notifier(QObject):
|
||||
self.notified_threshold = True
|
||||
|
||||
def notify_health_warning(self, consecutive_overtime_days: int):
|
||||
"""건강 경고 (연속 연장근무 일수)"""
|
||||
"""건강 경고 (연속 연장근무 HEALTH_CONSECUTIVE_OT_DAYS일 이상)"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
return
|
||||
if consecutive_overtime_days >= 3 and not self.notified_health:
|
||||
threshold = _get_int_setting(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||
if consecutive_overtime_days >= threshold and not self.notified_health:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.health.title'),
|
||||
tr('notif.health.body', days=consecutive_overtime_days),
|
||||
@@ -154,10 +177,11 @@ class Notifier(QObject):
|
||||
self.notified_health = True
|
||||
|
||||
def notify_weekly_hours(self, total_hours: float):
|
||||
"""주 52시간 경고"""
|
||||
"""주 X시간 경고 (WEEKLY_HOURS_THRESHOLD)"""
|
||||
if not self._enabled(NOTIF_HEALTH):
|
||||
return
|
||||
if total_hours > 52 and not self.notified_weekly:
|
||||
threshold = _get_int_setting(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||
if total_hours > threshold and not self.notified_weekly:
|
||||
self.notification_signal.emit(
|
||||
tr('notif.weekly_52.title'),
|
||||
tr('notif.weekly_52.body', hours=total_hours),
|
||||
@@ -167,7 +191,7 @@ class Notifier(QObject):
|
||||
def check_health_break(self, clock_in_time, break_minutes: int, current_time=None):
|
||||
"""장시간 연속 근무 휴식 알림.
|
||||
|
||||
조건: HEALTH_BREAK_ENABLED=true, 출근 후 (HEALTH_BREAK_HOURS - break_minutes/60)시간 경과,
|
||||
조건: HEALTH_BREAK_ENABLED=true, (출근 경과 - 외출시간) >= HEALTH_BREAK_HOURS,
|
||||
오늘 미발송. 5분 throttle은 호출자(NotificationOrchestrator)에서.
|
||||
"""
|
||||
if current_time is None:
|
||||
@@ -178,16 +202,12 @@ class Notifier(QObject):
|
||||
return
|
||||
if self.db.has_notification_today('system', 'health_break'):
|
||||
return
|
||||
try:
|
||||
threshold_hours = max(1, min(12, int(self.db.get_setting('health_break_hours', '4') or '4')))
|
||||
except (ValueError, TypeError):
|
||||
threshold_hours = 4
|
||||
threshold_hours = _get_int_setting(self.db, 'health_break_hours', 4, 1, 12)
|
||||
elapsed_sec = (current_time - clock_in_time).total_seconds() - break_minutes * 60
|
||||
if elapsed_sec >= threshold_hours * 3600:
|
||||
from core.i18n import tr
|
||||
self.notification_signal.emit(
|
||||
tr('notif.health_break.title') if False else "🌿 휴식 권고",
|
||||
f"{threshold_hours}시간 이상 자리에 계셨습니다.\n잠시 일어나서 스트레칭하세요.",
|
||||
tr('notif.health_break.title'),
|
||||
tr('notif.health_break.body', hours=threshold_hours),
|
||||
)
|
||||
self.db.log_notification('system', 'health_break')
|
||||
|
||||
@@ -195,6 +215,7 @@ class Notifier(QObject):
|
||||
"""알림 상태 리셋 (날짜 변경 시)"""
|
||||
self.notified_30min = False
|
||||
self.notified_lunch = False
|
||||
self.notified_dinner = False
|
||||
self.notified_overtime = False
|
||||
self.notified_health = False
|
||||
self.notified_weekly = False
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
반복 연차 패턴 파싱 + 일자 확장.
|
||||
|
||||
지원 패턴:
|
||||
- 'weekly:friday' — 매주 금요일
|
||||
- 'weekly:mon,wed,fri' — 매주 월·수·금
|
||||
- 'biweekly:friday' — 격주 금요일 (start_date 기준)
|
||||
- 'monthly:15' — 매월 15일 (해당 월에 그 일이 없으면 스킵)
|
||||
|
||||
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range()로 펼친다.
|
||||
같은 날짜에 leave_records(구체 인스턴스)가 이미 있으면 호출자가 합산 로직 책임.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
'mon': 0, 'monday': 0,
|
||||
'tue': 1, 'tuesday': 1,
|
||||
'wed': 2, 'wednesday': 2,
|
||||
'thu': 3, 'thursday': 3,
|
||||
'fri': 4, 'friday': 4,
|
||||
'sat': 5, 'saturday': 5,
|
||||
'sun': 6, 'sunday': 6,
|
||||
}
|
||||
_WEEKDAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
|
||||
|
||||
@dataclass
|
||||
class Occurrence:
|
||||
"""반복 패턴이 펼친 한 인스턴스."""
|
||||
date: date
|
||||
leave_type: str
|
||||
days: float
|
||||
pattern: str
|
||||
memo: str = ''
|
||||
recurring_id: Optional[int] = None
|
||||
|
||||
|
||||
def _parse_pattern(pattern: str):
|
||||
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
|
||||
if not pattern or ':' not in pattern:
|
||||
return None
|
||||
kind, rest = pattern.split(':', 1)
|
||||
kind = kind.strip().lower()
|
||||
rest = rest.strip().lower()
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
days = [d.strip() for d in rest.split(',') if d.strip()]
|
||||
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
|
||||
if not weekdays:
|
||||
return None
|
||||
return (kind, weekdays)
|
||||
if kind == 'monthly':
|
||||
try:
|
||||
day_of_month = int(rest)
|
||||
if 1 <= day_of_month <= 31:
|
||||
return (kind, day_of_month)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def matches(rec: Dict, target_date: date) -> bool:
|
||||
"""단일 패턴 rec이 target_date에 매치되는지."""
|
||||
start = _parse_date(rec.get('start_date'))
|
||||
end = _parse_date(rec.get('end_date'))
|
||||
if start is None:
|
||||
return False
|
||||
if target_date < start:
|
||||
return False
|
||||
if end is not None and target_date > end:
|
||||
return False
|
||||
|
||||
parsed = _parse_pattern(rec.get('pattern', ''))
|
||||
if parsed is None:
|
||||
return False
|
||||
kind, info = parsed
|
||||
|
||||
if kind == 'weekly':
|
||||
return target_date.weekday() in info
|
||||
|
||||
if kind == 'biweekly':
|
||||
if target_date.weekday() not in info:
|
||||
return False
|
||||
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
|
||||
weeks = (target_date - start).days // 7
|
||||
return weeks % 2 == 0
|
||||
|
||||
if kind == 'monthly':
|
||||
return target_date.day == info
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
|
||||
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
|
||||
|
||||
반환은 날짜 오름차순. 같은 날 여러 패턴이 매치되면 모두 포함.
|
||||
"""
|
||||
out: List[Occurrence] = []
|
||||
if start > end:
|
||||
return out
|
||||
cur = start
|
||||
while cur <= end:
|
||||
for r in records:
|
||||
try:
|
||||
if matches(r, cur):
|
||||
out.append(Occurrence(
|
||||
date=cur,
|
||||
leave_type=r.get('leave_type') or '연차',
|
||||
days=float(r.get('days') or 0),
|
||||
pattern=r.get('pattern', ''),
|
||||
memo=r.get('memo') or '',
|
||||
recurring_id=r.get('id'),
|
||||
))
|
||||
except Exception:
|
||||
# 잘못된 패턴 1개가 전체를 망치지 않도록
|
||||
continue
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
|
||||
"""단일 날짜에 매치되는 인스턴스만."""
|
||||
return expand_for_range(records, target_date, target_date)
|
||||
|
||||
|
||||
def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def describe_pattern(pattern: str) -> str:
|
||||
"""사용자에게 보여줄 패턴 설명."""
|
||||
parsed = _parse_pattern(pattern)
|
||||
if parsed is None:
|
||||
return pattern
|
||||
kind, info = parsed
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
names = [tr(f'label.weekday_{_WEEKDAY_KEYS[w]}') for w in info]
|
||||
prefix = tr('recurring.weekly') if kind == 'weekly' else tr('recurring.biweekly')
|
||||
return tr('recurring.pattern_weekly', prefix=prefix, weekdays=','.join(names))
|
||||
if kind == 'monthly':
|
||||
return tr('recurring.pattern_monthly', day=info)
|
||||
return pattern
|
||||
@@ -22,9 +22,17 @@ CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로
|
||||
NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes'
|
||||
NOTIF_CLOCK_OUT = 'notification_clock_out'
|
||||
NOTIF_LUNCH = 'notification_lunch'
|
||||
NOTIF_DINNER = 'notification_dinner'
|
||||
NOTIF_OVERTIME = 'notification_overtime'
|
||||
NOTIF_HEALTH = 'notification_health'
|
||||
|
||||
# 알림 임계값 (설정화 — 이전엔 하드코딩이던 것)
|
||||
LUNCH_REMINDER_HOURS = 'lunch_reminder_hours' # 출근 후 N시간 경과 시 점심 미등록 알림 (기본 4)
|
||||
DINNER_REMINDER_HOURS = 'dinner_reminder_hours' # 출근 후 N시간 경과 시 저녁 미등록 알림 (기본 8)
|
||||
OVERTIME_THRESHOLD_HOURS = 'overtime_threshold_hours' # 누적 적립 알림 시간 (기본 20)
|
||||
WEEKLY_HOURS_THRESHOLD = 'weekly_hours_threshold' # 주 X시간 경고 (기본 52, 한국 노동법)
|
||||
HEALTH_CONSECUTIVE_OT_DAYS = 'health_consecutive_ot_days' # 연속 연장근무 일수 경고 (기본 3)
|
||||
|
||||
# 연차
|
||||
ANNUAL_LEAVE_TOTAL = 'annual_leave_total'
|
||||
ANNUAL_LEAVE_DAYS = 'annual_leave_days'
|
||||
@@ -76,3 +84,22 @@ DISCORD_NOTIF_HEALTH = 'discord_notif_health'
|
||||
# 마이그레이션 sentinel
|
||||
ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated'
|
||||
BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2'
|
||||
|
||||
# === v2.8.0 도전과제 시스템 ===
|
||||
# 사용자 메타
|
||||
BIRTHDAY = 'birthday' # MM-DD 형식, 빈 문자열이면 비활성
|
||||
HIRE_DATE = 'hire_date' # YYYY-MM-DD, 첫 work_records 자동 기록
|
||||
|
||||
# 뷰 진입 카운터 (도전과제 + 사용 통계용)
|
||||
STAT_WEEKLY_VIEW_COUNT = 'stat_weekly_view_count'
|
||||
STAT_MONTHLY_VIEW_COUNT = 'stat_monthly_view_count'
|
||||
STAT_PATTERN_VIEW_COUNT = 'stat_pattern_view_count'
|
||||
CALENDAR_VIEW_COUNT = 'calendar_view_count'
|
||||
LEAVE_CALENDAR_VIEW_COUNT = 'leave_calendar_view_count'
|
||||
DAILY_REPORT_COUNT = 'daily_report_count'
|
||||
ACHIEVEMENTS_VIEW_COUNT = 'achievements_view_count'
|
||||
CHART_HOVER_DISCOVERED = 'chart_hover_discovered'
|
||||
|
||||
# 도전과제 알림
|
||||
NOTIF_ACHIEVEMENT = 'notification_achievement'
|
||||
DISCORD_NOTIF_ACHIEVEMENT = 'discord_notif_achievement'
|
||||
|
||||
+51
-1
@@ -21,7 +21,8 @@ class TimeCalculator:
|
||||
if work_minutes is not None:
|
||||
self.work_minutes = int(work_minutes)
|
||||
elif work_hours is not None:
|
||||
self.work_minutes = int(round(float(work_hours) * 60))
|
||||
# 은행 반올림(banker's rounding) 회피: 6.5시간 → 390분이 되도록 명시적 반올림
|
||||
self.work_minutes = int(float(work_hours) * 60 + 0.5)
|
||||
else:
|
||||
self.work_minutes = 480
|
||||
|
||||
@@ -236,6 +237,55 @@ class TimeCalculator:
|
||||
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
||||
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
||||
|
||||
def effective_work_minutes(self, date_obj: datetime, db) -> int:
|
||||
"""해당 날짜의 실효 근무 시간(분).
|
||||
|
||||
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
|
||||
종일 연차(>= work_minutes)면 0 반환.
|
||||
|
||||
Args:
|
||||
date_obj: 기준 날짜 (datetime; 시각은 무시)
|
||||
db: Database 인스턴스 (None이면 차감 없음)
|
||||
|
||||
Returns:
|
||||
실효 work_minutes (>= 0)
|
||||
"""
|
||||
if db is None:
|
||||
return self.work_minutes
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
leave_min = db.get_leave_minutes_for(date_str)
|
||||
return max(0, self.work_minutes - leave_min)
|
||||
|
||||
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
break_minutes: int = 0,
|
||||
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||
"""
|
||||
휴일/주말 근무: 모든 시간을 연장근무로 계산.
|
||||
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
current_time: 현재(또는 퇴근) 시간
|
||||
include_lunch/dinner: 식사 시간 차감 여부
|
||||
break_minutes: 외출 시간 (분) — 연장근무에서 제외
|
||||
unit_minutes: 적립 단위 (15/30/60)
|
||||
|
||||
Returns:
|
||||
(실제 연장 분, 적립 분) — 둘 다 0 이상.
|
||||
"""
|
||||
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
|
||||
if include_lunch:
|
||||
elapsed_minutes -= self.lunch_duration_minutes
|
||||
if include_dinner:
|
||||
elapsed_minutes -= self.dinner_duration_minutes
|
||||
elapsed_minutes -= break_minutes
|
||||
elapsed_minutes = max(0, elapsed_minutes)
|
||||
|
||||
unit = unit_minutes if unit_minutes > 0 else 30
|
||||
earned = (elapsed_minutes // unit) * unit
|
||||
return elapsed_minutes, earned
|
||||
|
||||
def is_weekend(self, date_obj: datetime) -> bool:
|
||||
"""
|
||||
주말 여부 확인
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.7.0'
|
||||
__version__ = '2.12.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.
@@ -96,8 +96,9 @@ def main():
|
||||
)
|
||||
return 1
|
||||
|
||||
# 폰트 설정
|
||||
app.setFont(QFont("Segoe UI", 9))
|
||||
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
|
||||
from utils.font_loader import apply_app_font
|
||||
apply_app_font(app, 9)
|
||||
|
||||
# 필수 패키지 확인
|
||||
if not check_requirements():
|
||||
@@ -145,9 +146,9 @@ def main():
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"onboarding skipped: {e}")
|
||||
|
||||
# 메인 윈도우 생성 및 표시
|
||||
# 메인 윈도우 생성 및 표시 (위에서 만든 db 재사용 — 이중 부트스트랩 방지)
|
||||
try:
|
||||
window = MainWindow()
|
||||
window = MainWindow(db=db)
|
||||
|
||||
# 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌
|
||||
def on_new_connection():
|
||||
|
||||
@@ -14,20 +14,35 @@ 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,
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_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=[],
|
||||
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
|
||||
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
|
||||
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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 = Get-Content $verFile -Raw
|
||||
$verContent = [System.IO.File]::ReadAllText((Resolve-Path $verFile).Path, [System.Text.Encoding]::UTF8)
|
||||
$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'"
|
||||
if ($verContent -eq $newContent) {
|
||||
Info "Already at $VersionRaw (no change)"
|
||||
} else {
|
||||
if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline }
|
||||
if (-not $DryRun) { [System.IO.File]::WriteAllText((Resolve-Path $verFile).Path, $newContent, [System.Text.Encoding]::UTF8) }
|
||||
OkMsg "$verFile -> $VersionRaw"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
pytest 공통 설정.
|
||||
|
||||
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
@@ -4,13 +4,15 @@ utils.csv_importer 단위 테스트.
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time
|
||||
from utils.csv_importer import parse_csv, _normalize_row, _normalize_time, import_records
|
||||
from core.database import Database
|
||||
|
||||
|
||||
class TestNormalizeTime:
|
||||
@@ -125,3 +127,54 @@ class TestParseCsv:
|
||||
assert '줄 3' in str(exc.value)
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
class TestImportRecords:
|
||||
def _db(self):
|
||||
p = tempfile.NamedTemporaryFile(delete=False, suffix='.db')
|
||||
p.close()
|
||||
db = Database(p.name)
|
||||
db.save_settings({
|
||||
'work_minutes': '480',
|
||||
'lunch_duration_minutes': '60',
|
||||
'dinner_duration_minutes': '60',
|
||||
})
|
||||
return db, p.name
|
||||
|
||||
def test_overwrite_clears_overtime_usage(self):
|
||||
"""CSV 덮어쓰기 시 overtime_usage도 삭제되어 잔액이 일관성을 유지해야 함."""
|
||||
db, path = self._db()
|
||||
try:
|
||||
date_str = '2026-04-01'
|
||||
# 기존 기록 + 연장근무 사용 기록 생성
|
||||
wid = db.add_work_record(date_str, '09:00:00')
|
||||
db.update_clock_out(date_str, '20:00:00', 11.0, 120, 120)
|
||||
db.add_overtime_earned(wid, 120, date_str)
|
||||
db.add_overtime_usage(wid, 30, date_str, '테스트')
|
||||
|
||||
# 덮어쓰기 전 잔액
|
||||
balance_before = db.get_total_overtime_balance()
|
||||
assert balance_before == 90 # 120 적립 - 30 사용
|
||||
|
||||
rows = [{
|
||||
'date': date_str,
|
||||
'clock_in': '09:00:00',
|
||||
'clock_out': '18:00:00',
|
||||
'lunch_minutes': 60,
|
||||
'dinner_minutes': 0,
|
||||
'memo': '',
|
||||
}]
|
||||
import_records(db, rows, on_conflict='overwrite')
|
||||
|
||||
# 덮어쓰기 후 연장근무 사용 기록은 삭제되어야 함
|
||||
with db._conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM overtime_usage WHERE date = ?", (date_str,))
|
||||
assert cur.fetchone()[0] == 0
|
||||
balance_after = db.get_total_overtime_balance()
|
||||
assert balance_after == 0 # 새 기록은 연장근무 없음
|
||||
finally:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
|
||||
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
||||
overtime_minutes=120, overtime_earned=120)
|
||||
assert fresh_db.get_consecutive_overtime_days() == 3
|
||||
|
||||
|
||||
class TestLeaveQueriesByDate:
|
||||
def test_get_leave_minutes_for_no_records(self, fresh_db):
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
|
||||
|
||||
def test_full_day_leave_detected(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||
|
||||
def test_half_day_not_full(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
|
||||
assert not fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
|
||||
|
||||
def test_two_halves_become_full(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
|
||||
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||
|
||||
def test_records_by_date(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
|
||||
recs = fresh_db.get_leave_records_by_date('2026-05-15')
|
||||
assert len(recs) == 1
|
||||
assert recs[0]['leave_type'] == '연차'
|
||||
assert recs[0]['memo'] == '메모'
|
||||
|
||||
def test_records_by_range(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
|
||||
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
|
||||
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
|
||||
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
|
||||
assert len(recs) == 2
|
||||
# 날짜 정렬
|
||||
assert recs[0]['date'] == '2026-05-01'
|
||||
assert recs[1]['date'] == '2026-05-10'
|
||||
|
||||
|
||||
class TestRecurringLeavesDB:
|
||||
def test_add_and_list(self, fresh_db):
|
||||
rid = fresh_db.add_recurring_leave(
|
||||
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
|
||||
)
|
||||
assert rid > 0
|
||||
recs = fresh_db.get_recurring_leaves()
|
||||
assert len(recs) == 1
|
||||
assert recs[0]['pattern'] == 'weekly:friday'
|
||||
assert recs[0]['memo'] == '단축'
|
||||
|
||||
def test_active_on_filter(self, fresh_db):
|
||||
# 종료일이 지난 패턴
|
||||
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||
'2025-01-01', '2025-12-31')
|
||||
# 아직 시작 안 한 패턴
|
||||
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
|
||||
'2027-01-01', None)
|
||||
# 현재 활성 패턴
|
||||
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
|
||||
'2026-01-01', None)
|
||||
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
|
||||
assert len(active) == 1
|
||||
assert active[0]['pattern'] == 'monthly:15'
|
||||
|
||||
def test_delete(self, fresh_db):
|
||||
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||
'2026-01-01')
|
||||
assert len(fresh_db.get_recurring_leaves()) == 1
|
||||
fresh_db.delete_recurring_leave(rid)
|
||||
assert fresh_db.get_recurring_leaves() == []
|
||||
|
||||
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
|
||||
# 매주 금요일 반차
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
|
||||
'2026-01-01')
|
||||
# 2026-05-01 = Friday → 240분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
|
||||
# 2026-05-04 = Monday → 0분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
|
||||
|
||||
def test_concrete_plus_recurring_sum(self, fresh_db):
|
||||
# 매주 금요일 반차 + 그날 별도 반반차 추가
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
|
||||
# 0.5 + 0.25 = 0.75일 = 360분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
|
||||
assert not fresh_db.has_full_day_leave('2026-05-01')
|
||||
|
||||
def test_concrete_plus_recurring_full_day(self, fresh_db):
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
|
||||
# 0.5 + 0.5 = 1.0일
|
||||
assert fresh_db.has_full_day_leave('2026-05-01')
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestSendNetwork:
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
ok = send('https://discord.com/api/webhooks/123/abc', 'title', 'desc')
|
||||
ok = send('https://discord.com/api/webhooks/123456789012345678/' + 'a' * 60, 'title', 'desc')
|
||||
assert ok is True
|
||||
|
||||
# User-Agent 헤더가 브라우저로 위장되어 있는지 (Cloudflare 우회)
|
||||
@@ -55,18 +55,18 @@ class TestSendNetwork:
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_network_error_returns_false(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
@patch('utils.discord_webhook.urllib.request.urlopen')
|
||||
def test_timeout_returns_false(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = TimeoutError('slow')
|
||||
assert send('https://discord.com/api/webhooks/x', 't', 'd') is False
|
||||
assert send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 't', 'd') is False
|
||||
|
||||
|
||||
class TestPayloadShape:
|
||||
@@ -79,7 +79,7 @@ class TestPayloadShape:
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
send('https://discord.com/api/webhooks/x', 'TITLE', 'DESC',
|
||||
send('https://discord.com/api/webhooks/123456789012345678/' + 'b' * 60, 'TITLE', 'DESC',
|
||||
color=COLOR_GREEN, fields=[{"name": "f", "value": "v"}])
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
body = _json.loads(req.data.decode('utf-8'))
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""연장근무 자동 적립 가드 테스트.
|
||||
|
||||
auto_overtime(자동 적립)가 OFF면, 자동 퇴근 경로(근무일 경계 롤오버 등)에서도
|
||||
은행 적립을 하지 않아야 한다 — clock_out() 대화상자에서 '아니오'를 고른 것과 동일한 의미.
|
||||
|
||||
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
|
||||
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, time as dtime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.time_calculator import TimeCalculator
|
||||
from ui.main_window import MainWindow
|
||||
|
||||
|
||||
def _rollover_balance(db, monkeypatch):
|
||||
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
monkeypatch.setattr(QMessageBox, 'information',
|
||||
staticmethod(lambda *a, **k: QMessageBox.Ok))
|
||||
|
||||
today = datetime.now().date()
|
||||
y = today - timedelta(days=1)
|
||||
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
|
||||
|
||||
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
|
||||
w.db = db
|
||||
w.time_calc = TimeCalculator(work_minutes=480)
|
||||
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
|
||||
w.is_clocked_in = True
|
||||
w.midnight_rollover_handled = False
|
||||
w.is_on_break = False
|
||||
w.lunch_break_enabled = False
|
||||
w.dinner_break_enabled = False
|
||||
w.load_today_data = lambda: None # tail UI refresh stub
|
||||
w.update_overtime_balance = lambda: None # tail UI refresh stub
|
||||
|
||||
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
|
||||
return db.get_total_overtime_balance()
|
||||
|
||||
|
||||
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
|
||||
db = Database(str(tmp_path / 'off.db'))
|
||||
db.set_setting('auto_overtime', 'false')
|
||||
assert _rollover_balance(db, monkeypatch) == 0
|
||||
|
||||
|
||||
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
|
||||
db = Database(str(tmp_path / 'on.db'))
|
||||
db.set_setting('auto_overtime', 'true')
|
||||
assert _rollover_balance(db, monkeypatch) > 0
|
||||
|
||||
|
||||
def test_delete_overtime_earned_reduces_balance(tmp_path):
|
||||
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
|
||||
from datetime import date
|
||||
db = Database(str(tmp_path / 'del.db'))
|
||||
today = date.today().isoformat()
|
||||
db.add_overtime_earned(None, 90, today)
|
||||
assert db.get_total_overtime_balance() == 90
|
||||
|
||||
bank_id = db.get_connection().execute(
|
||||
'SELECT id FROM overtime_bank').fetchone()[0]
|
||||
assert db.delete_overtime_earned(bank_id) is True
|
||||
assert db.get_total_overtime_balance() == 0
|
||||
|
||||
# 없는 id 삭제는 False
|
||||
assert db.delete_overtime_earned(999999) is False
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
core.recurring_leaves 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.recurring_leaves import (
|
||||
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
|
||||
)
|
||||
|
||||
|
||||
class TestParsePattern:
|
||||
@pytest.mark.parametrize("pattern,expected_kind", [
|
||||
('weekly:friday', 'weekly'),
|
||||
('weekly:fri', 'weekly'),
|
||||
('weekly:mon,wed,fri', 'weekly'),
|
||||
('biweekly:friday', 'biweekly'),
|
||||
('monthly:15', 'monthly'),
|
||||
('monthly:1', 'monthly'),
|
||||
])
|
||||
def test_valid(self, pattern, expected_kind):
|
||||
result = _parse_pattern(pattern)
|
||||
assert result is not None
|
||||
assert result[0] == expected_kind
|
||||
|
||||
@pytest.mark.parametrize("pattern", [
|
||||
'', 'weekly', 'weekly:', 'weekly:xyz',
|
||||
'monthly:0', 'monthly:32', 'monthly:abc',
|
||||
'unknown:fri', None,
|
||||
])
|
||||
def test_invalid(self, pattern):
|
||||
assert _parse_pattern(pattern) is None
|
||||
|
||||
|
||||
class TestMatches:
|
||||
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
|
||||
return {
|
||||
'pattern': pattern,
|
||||
'start_date': start,
|
||||
'end_date': end,
|
||||
'days': days,
|
||||
'leave_type': leave_type,
|
||||
}
|
||||
|
||||
def test_weekly_single_day(self):
|
||||
rec = self._rec('weekly:friday')
|
||||
assert matches(rec, date(2026, 5, 1)) # Fri
|
||||
assert not matches(rec, date(2026, 5, 2)) # Sat
|
||||
assert not matches(rec, date(2026, 5, 4)) # Mon
|
||||
|
||||
def test_weekly_multiple_days(self):
|
||||
rec = self._rec('weekly:mon,wed,fri')
|
||||
assert matches(rec, date(2026, 5, 4)) # Mon
|
||||
assert matches(rec, date(2026, 5, 6)) # Wed
|
||||
assert matches(rec, date(2026, 5, 8)) # Fri
|
||||
assert not matches(rec, date(2026, 5, 5)) # Tue
|
||||
assert not matches(rec, date(2026, 5, 7)) # Thu
|
||||
|
||||
def test_biweekly_alignment(self):
|
||||
# start_date 2026-01-02 = Friday (week 0)
|
||||
rec = self._rec('biweekly:friday', start='2026-01-02')
|
||||
assert matches(rec, date(2026, 1, 2)) # week 0
|
||||
assert not matches(rec, date(2026, 1, 9)) # week 1
|
||||
assert matches(rec, date(2026, 1, 16)) # week 2
|
||||
|
||||
def test_monthly(self):
|
||||
rec = self._rec('monthly:15')
|
||||
assert matches(rec, date(2026, 1, 15))
|
||||
assert matches(rec, date(2026, 5, 15))
|
||||
assert not matches(rec, date(2026, 5, 14))
|
||||
assert not matches(rec, date(2026, 5, 16))
|
||||
|
||||
def test_monthly_skipped_in_short_month(self):
|
||||
# 31일은 30일 달에는 매치되지 않음
|
||||
rec = self._rec('monthly:31')
|
||||
assert matches(rec, date(2026, 1, 31))
|
||||
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
|
||||
|
||||
def test_before_start(self):
|
||||
rec = self._rec('weekly:friday', start='2026-05-01')
|
||||
assert matches(rec, date(2026, 5, 1))
|
||||
assert not matches(rec, date(2026, 4, 24)) # 시작 전
|
||||
|
||||
def test_after_end(self):
|
||||
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
|
||||
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
|
||||
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
|
||||
|
||||
def test_no_end_means_forever(self):
|
||||
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
|
||||
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
|
||||
|
||||
def test_invalid_pattern_returns_false(self):
|
||||
rec = self._rec('garbage:xyz')
|
||||
assert not matches(rec, date(2026, 5, 1))
|
||||
|
||||
|
||||
class TestExpandRange:
|
||||
def _rec(self, pattern, start='2026-01-01'):
|
||||
return {
|
||||
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
|
||||
'days': 0.5, 'leave_type': '반차', 'memo': '',
|
||||
}
|
||||
|
||||
def test_expand_weekly_one_month(self):
|
||||
rec = self._rec('weekly:friday')
|
||||
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
|
||||
assert len(occs) == 5
|
||||
assert all(o.date.weekday() == 4 for o in occs)
|
||||
|
||||
def test_expand_empty_when_outside(self):
|
||||
rec = self._rec('weekly:friday', start='2027-01-01')
|
||||
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||
assert occs == []
|
||||
|
||||
def test_expand_invalid_range(self):
|
||||
# start > end
|
||||
rec = self._rec('weekly:friday')
|
||||
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
|
||||
assert occs == []
|
||||
|
||||
def test_expand_multiple_recs(self):
|
||||
rec_fri = self._rec('weekly:friday')
|
||||
rec_mon = self._rec('weekly:monday')
|
||||
rec_mon['id'] = 2
|
||||
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
|
||||
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
|
||||
assert len(occs) == 2
|
||||
|
||||
def test_expand_for_date_single(self):
|
||||
rec = self._rec('monthly:15')
|
||||
occs = expand_for_date([rec], date(2026, 5, 15))
|
||||
assert len(occs) == 1
|
||||
assert occs[0].date == date(2026, 5, 15)
|
||||
|
||||
|
||||
class TestDescribePattern:
|
||||
def test_weekly_korean(self):
|
||||
assert '매주' in describe_pattern('weekly:friday')
|
||||
assert '금' in describe_pattern('weekly:friday')
|
||||
|
||||
def test_biweekly(self):
|
||||
assert '격주' in describe_pattern('biweekly:friday')
|
||||
|
||||
def test_monthly(self):
|
||||
assert '매월' in describe_pattern('monthly:15')
|
||||
assert '15' in describe_pattern('monthly:15')
|
||||
@@ -98,3 +98,77 @@ class TestDayType:
|
||||
mon = datetime(2026, 5, 4)
|
||||
assert not calc.is_weekend(mon)
|
||||
assert calc.get_day_type(mon) == 'normal'
|
||||
|
||||
|
||||
class TestHolidayOvertime:
|
||||
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
|
||||
|
||||
def test_zero_elapsed_returns_zero(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
def test_one_minute_elapsed_no_lunch(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=1)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 1
|
||||
assert earned == 0 # 30분 단위 절삭
|
||||
|
||||
def test_30min_elapsed_truncates_to_30(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=30)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 30 and earned == 30
|
||||
|
||||
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=29)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 29 and earned == 0
|
||||
|
||||
def test_lunch_subtracted(self, calc_8h):
|
||||
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=9)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, include_lunch=True
|
||||
)
|
||||
assert actual == 8 * 60
|
||||
assert earned == 8 * 60
|
||||
|
||||
def test_break_minutes_subtracted(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=2)
|
||||
# 외출 30분 → 90분 적립
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, break_minutes=30
|
||||
)
|
||||
assert actual == 90 and earned == 90
|
||||
|
||||
def test_unit_minutes_15(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=44)
|
||||
# 44분 → 30분 적립 (15분 단위)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, unit_minutes=15
|
||||
)
|
||||
assert actual == 44 and earned == 30
|
||||
|
||||
def test_unit_minutes_60(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=119)
|
||||
# 119분 → 60분 적립 (60분 단위)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, unit_minutes=60
|
||||
)
|
||||
assert actual == 119 and earned == 60
|
||||
|
||||
def test_negative_clamped_to_zero(self, calc_8h):
|
||||
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=1)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, include_lunch=True, include_dinner=True
|
||||
)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
도전과제 뷰 — 4탭 (전체 / 진행 중 / 완료 / 시크릿).
|
||||
|
||||
디자인 원칙:
|
||||
- 카드 = 등급별 그라디언트 배경 + 외곽선 빛 (획득 시 강한 색)
|
||||
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
|
||||
- 진행 게이지 = 두꺼운 색상 막대 (등급 색)
|
||||
- 카테고리 = 작은 인라인 태그
|
||||
- 시크릿 미발견 = ❓ 처리
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTabWidget, QWidget, QScrollArea,
|
||||
QProgressBar, QFrame, QGridLayout, QSizePolicy)
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
from core.achievements import get_all_with_status, get_stats
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||
|
||||
|
||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||
TIER_THEMES = {
|
||||
'bronze': {
|
||||
'border': '#cd7f32',
|
||||
'border_strong': '#e09947',
|
||||
'bg_top': '#3a2a18',
|
||||
'bg_bot': '#241810',
|
||||
'text': '#ffd9a8',
|
||||
'label': '🥉',
|
||||
},
|
||||
'silver': {
|
||||
'border': '#a8a8a8',
|
||||
'border_strong': '#d0d0d0',
|
||||
'bg_top': '#2e2e36',
|
||||
'bg_bot': '#1c1c22',
|
||||
'text': '#e8e8f0',
|
||||
'label': '🥈',
|
||||
},
|
||||
'gold': {
|
||||
'border': '#ffb700',
|
||||
'border_strong': '#ffd24a',
|
||||
'bg_top': '#3a2e10',
|
||||
'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0',
|
||||
'label': '🥇',
|
||||
},
|
||||
'platinum': {
|
||||
'border': '#7fdbff',
|
||||
'border_strong': '#a8e8ff',
|
||||
'bg_top': '#1a3340',
|
||||
'bg_bot': '#0e1f28',
|
||||
'text': '#c5ecff',
|
||||
'label': '💎',
|
||||
},
|
||||
'legend': {
|
||||
'border': '#ff6b9d',
|
||||
'border_strong': '#ff90b8',
|
||||
'bg_top': '#3a1a2a',
|
||||
'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4',
|
||||
'label': '🌟',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class AchievementsView(QDialog):
|
||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('achieve.title'))
|
||||
self.setMinimumSize(960, 720)
|
||||
self.resize(1100, 800)
|
||||
self._increment_view_count()
|
||||
self.setStyleSheet(f"QDialog {{ background: {tc('bg')}; }}")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
def _increment_view_count(self) -> None:
|
||||
try:
|
||||
cur = self.db.get_setting_int('achievements_view_count', 0)
|
||||
self.db.set_setting('achievements_view_count', str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 20, 20, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
stats = get_stats(self.db)
|
||||
|
||||
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
|
||||
layout.addWidget(self._build_header(stats))
|
||||
|
||||
# === 탭 ===
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setStyleSheet(self._tabs_qss())
|
||||
|
||||
all_items = get_all_with_status(self.db)
|
||||
earned_items = [a for a in all_items if a['earned_date'] is not None]
|
||||
in_progress = [a for a in all_items
|
||||
if a['earned_date'] is None and not a['is_secret']]
|
||||
secret_items = [a for a in all_items if a['is_secret']]
|
||||
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), tr('achieve.tab_all', count=len(all_items)))
|
||||
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||
tr('achieve.tab_in_progress', count=len(in_progress)))
|
||||
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||
tr('achieve.tab_completed', count=len(earned_items)))
|
||||
self.tabs.addTab(
|
||||
self._build_grid_tab(secret_items, secret_mode=True),
|
||||
tr('achieve.tab_secret', earned=stats['secret_earned'], total=stats['secret_total'])
|
||||
)
|
||||
layout.addWidget(self.tabs, 1)
|
||||
|
||||
# === 닫기 버튼 ===
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.setMinimumWidth(100)
|
||||
close_btn.setStyleSheet(button_qss('default'))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# ----- 헤더 -----
|
||||
def _build_header(self, stats: dict) -> QWidget:
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {tc('panel')};
|
||||
border: 1px solid {tc('border')};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
|
||||
|
||||
# 큰 숫자 행
|
||||
num_row = QHBoxLayout()
|
||||
num_row.setSpacing(24)
|
||||
|
||||
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
|
||||
if _is_dark():
|
||||
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
|
||||
else:
|
||||
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
|
||||
|
||||
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
|
||||
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
|
||||
big.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet(f"color: {tc('border')};")
|
||||
num_row.addWidget(spacer)
|
||||
|
||||
secret_lbl = QLabel(
|
||||
f"<div style='line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 {tr('achieve.cat_secret')}</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||
f"</div>"
|
||||
)
|
||||
secret_lbl.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(secret_lbl)
|
||||
|
||||
num_row.addStretch()
|
||||
|
||||
pct_lbl = QLabel(
|
||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>{tr('achieve.completion_rate')}</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||
f"{pct:.1f}%</span></div>"
|
||||
)
|
||||
pct_lbl.setTextFormat(Qt.RichText)
|
||||
pct_lbl.setAlignment(Qt.AlignRight)
|
||||
num_row.addWidget(pct_lbl)
|
||||
|
||||
layout.addLayout(num_row)
|
||||
|
||||
# 진행 바
|
||||
bar = QProgressBar()
|
||||
bar.setMaximum(max(stats['total'], 1))
|
||||
bar.setValue(stats['earned'])
|
||||
bar.setTextVisible(False)
|
||||
bar.setMinimumHeight(8)
|
||||
bar.setMaximumHeight(8)
|
||||
bar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {tc('panel2')};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
layout.addWidget(bar)
|
||||
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
# ----- 탭 그리드 -----
|
||||
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet(scroll_qss())
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(12)
|
||||
grid.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
if not items:
|
||||
empty = QLabel(tr('achieve.empty'))
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
)
|
||||
grid.addWidget(empty, 0, 0)
|
||||
else:
|
||||
cols = 3
|
||||
for i, item in enumerate(items):
|
||||
card = self._build_card(item, secret_mode=secret_mode)
|
||||
grid.addWidget(card, i // cols, i % cols)
|
||||
# 빈 컬럼 stretch 방지
|
||||
for c in range(cols):
|
||||
grid.setColumnStretch(c, 1)
|
||||
|
||||
container.setLayout(grid)
|
||||
scroll.setWidget(container)
|
||||
return scroll
|
||||
|
||||
# ----- 단일 카드 -----
|
||||
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
|
||||
is_earned = item['earned_date'] is not None
|
||||
is_locked_secret = item['is_secret'] and not is_earned
|
||||
tier = item['tier'] or 'bronze'
|
||||
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
||||
|
||||
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
|
||||
light = not _is_dark()
|
||||
if is_locked_secret:
|
||||
if light:
|
||||
bg_top = bg_bot = tc('panel'); border = tc('border')
|
||||
else:
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
|
||||
text_color = tc('text_faint')
|
||||
elif light:
|
||||
bg_top = bg_bot = tc('panel')
|
||||
border = theme['border_strong'] if is_earned else theme['border']
|
||||
text_color = tc('text') if is_earned else tc('text_dim')
|
||||
else:
|
||||
bg_top = theme['bg_top']
|
||||
bg_bot = theme['bg_bot']
|
||||
border = theme['border_strong'] if is_earned else theme['border']
|
||||
text_color = theme['text'] if is_earned else '#c0c0d0'
|
||||
|
||||
# 외곽선 강도: 획득 시 2px + 더 진한 색
|
||||
border_width = 2 if is_earned else 1
|
||||
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
|
||||
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
card.setMinimumHeight(150)
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {bg_top}, stop:1 {bg_bot});
|
||||
border: {border_width}px solid {border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: {text_color};
|
||||
}}
|
||||
""")
|
||||
|
||||
outer = QVBoxLayout()
|
||||
outer.setContentsMargins(14, 12, 14, 12)
|
||||
outer.setSpacing(8)
|
||||
|
||||
# 1행: 이모지 + 이름 + 등급 라벨
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setSpacing(10)
|
||||
|
||||
if is_locked_secret:
|
||||
icon_text = "❓"
|
||||
else:
|
||||
icon_text = item['badge_icon'] or '🏆'
|
||||
icon = QLabel(icon_text)
|
||||
icon.setStyleSheet(
|
||||
f"font-size: 32pt; background: transparent; border: none; "
|
||||
f"color: {text_color};"
|
||||
)
|
||||
icon.setMinimumWidth(48)
|
||||
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
|
||||
top_row.addWidget(icon)
|
||||
|
||||
# 이름 + 카테고리 (세로 스택)
|
||||
name_box = QVBoxLayout()
|
||||
name_box.setSpacing(2)
|
||||
name_box.setContentsMargins(0, 4, 0, 0)
|
||||
|
||||
name_text = "???" if is_locked_secret else (item['name'] or '')
|
||||
name = QLabel(name_text)
|
||||
name.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; "
|
||||
f"color: {tc('text') if is_earned else tc('text_dim')}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
name.setWordWrap(True)
|
||||
name_box.addWidget(name)
|
||||
|
||||
cat_text = tr(f"achieve.cat_{item['category']}")
|
||||
if not is_locked_secret:
|
||||
cat_label = QLabel(f" {theme['label']} {tr(f'achieve.tier_{tier}')} · {cat_text} ")
|
||||
cat_label.setStyleSheet(
|
||||
f"font-size: 8.5pt; "
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
||||
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 8px; "
|
||||
f"padding: 1px 4px;"
|
||||
)
|
||||
cat_label.setMaximumHeight(20)
|
||||
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
cat_wrap = QHBoxLayout()
|
||||
cat_wrap.setContentsMargins(0, 0, 0, 0)
|
||||
cat_wrap.addWidget(cat_label)
|
||||
cat_wrap.addStretch()
|
||||
name_box.addLayout(cat_wrap)
|
||||
|
||||
top_row.addLayout(name_box, 1)
|
||||
outer.addLayout(top_row)
|
||||
|
||||
# 2행: 설명
|
||||
if is_locked_secret:
|
||||
desc_text = tr('achieve.secret_locked')
|
||||
else:
|
||||
desc_text = item['description'] or ''
|
||||
desc = QLabel(desc_text)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet(
|
||||
f"color: {tc('text_dim')}; font-size: 9.5pt; "
|
||||
f"background: transparent; border: none; padding: 0;"
|
||||
)
|
||||
outer.addWidget(desc)
|
||||
|
||||
# 3행: 진행 게이지 또는 획득 일자
|
||||
if is_earned:
|
||||
earned = QLabel(tr('achieve.earned_date', date=item['earned_date']))
|
||||
earned.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 6px; padding: 4px 8px;"
|
||||
)
|
||||
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(earned)
|
||||
row.addStretch()
|
||||
outer.addLayout(row)
|
||||
elif not is_locked_secret:
|
||||
target = max(1, item.get('target') or 1)
|
||||
progress = item.get('progress') or 0
|
||||
pct = (progress / target * 100) if target else 0
|
||||
|
||||
# 게이지 + 숫자 라벨
|
||||
gauge_row = QHBoxLayout()
|
||||
gauge_row.setSpacing(8)
|
||||
|
||||
pb = QProgressBar()
|
||||
pb.setMaximum(target)
|
||||
pb.setValue(min(progress, target))
|
||||
pb.setTextVisible(False)
|
||||
pb.setMinimumHeight(10)
|
||||
pb.setMaximumHeight(10)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
|
||||
border-radius: 5px;
|
||||
}}
|
||||
""")
|
||||
gauge_row.addWidget(pb, 1)
|
||||
|
||||
num = QLabel(f"{progress} / {target}")
|
||||
num.setStyleSheet(
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
|
||||
f"font-weight: bold; background: transparent; border: none;"
|
||||
)
|
||||
num.setMinimumWidth(60)
|
||||
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
gauge_row.addWidget(num)
|
||||
|
||||
outer.addLayout(gauge_row)
|
||||
else:
|
||||
# 시크릿 잠금 — 회색 점선 placeholder
|
||||
placeholder = QLabel("· · · · · · · · · ·")
|
||||
placeholder.setStyleSheet(
|
||||
"color: #444; font-size: 12pt; letter-spacing: 4px; "
|
||||
"background: transparent; border: none;"
|
||||
)
|
||||
placeholder.setAlignment(Qt.AlignCenter)
|
||||
outer.addWidget(placeholder)
|
||||
|
||||
outer.addStretch(1)
|
||||
card.setLayout(outer)
|
||||
return card
|
||||
|
||||
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||
def _tabs_qss(self) -> str:
|
||||
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
|
||||
return tabs_qss(ACCENT_GOLD)
|
||||
+68
-69
@@ -12,6 +12,7 @@ import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.i18n import tr
|
||||
from ui.styles import ThemeColors, apply_dark_titlebar
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class CalendarView(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel("월간 근무 기록")
|
||||
title = QLabel(tr('cal.dialog_title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
@@ -55,15 +56,16 @@ class CalendarView(QDialog):
|
||||
# 범례
|
||||
legend_layout = QHBoxLayout()
|
||||
legend_layout.setSpacing(12)
|
||||
legend_layout.addWidget(QLabel("🟢 정상"))
|
||||
legend_layout.addWidget(QLabel("🔴 연장"))
|
||||
legend_layout.addWidget(QLabel("🟡 휴가"))
|
||||
legend_layout.addWidget(QLabel("⚪ 없음"))
|
||||
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.addStretch()
|
||||
layout.addLayout(legend_layout)
|
||||
|
||||
# 선택된 날짜 상세 정보
|
||||
detail_group = QGroupBox("선택된 날짜 정보")
|
||||
detail_group = QGroupBox(tr('cal.detail_group_title'))
|
||||
detail_layout = QVBoxLayout()
|
||||
detail_layout.setSpacing(6)
|
||||
detail_layout.setContentsMargins(10, 20, 10, 8)
|
||||
@@ -77,13 +79,13 @@ class CalendarView(QDialog):
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6)
|
||||
|
||||
self.edit_time_button = QPushButton("✏️ 시간 수정")
|
||||
self.edit_time_button = QPushButton(tr('cal.edit_time'))
|
||||
self.edit_time_button.setObjectName("btn_primary")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||
button_layout.addWidget(self.edit_time_button)
|
||||
|
||||
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
|
||||
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
||||
self.delete_record_button.setObjectName("btn_danger")
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||
@@ -94,17 +96,17 @@ class CalendarView(QDialog):
|
||||
layout.addWidget(detail_group)
|
||||
|
||||
# 메모 그룹
|
||||
memo_group = QGroupBox("메모")
|
||||
memo_group = QGroupBox(tr('cal.memo_group'))
|
||||
memo_layout = QVBoxLayout()
|
||||
memo_layout.setSpacing(6)
|
||||
memo_layout.setContentsMargins(10, 20, 10, 8)
|
||||
|
||||
self.memo_edit = QTextEdit()
|
||||
self.memo_edit.setMaximumHeight(70)
|
||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||
self.memo_edit.setPlaceholderText(tr('cal.memo_placeholder'))
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
|
||||
self.save_memo_button = QPushButton("💾 메모 저장")
|
||||
self.save_memo_button = QPushButton(tr('cal.save_memo'))
|
||||
self.save_memo_button.setObjectName("btn_primary")
|
||||
self.save_memo_button.setEnabled(False)
|
||||
self.save_memo_button.clicked.connect(self.save_memo)
|
||||
@@ -163,21 +165,23 @@ 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(f"✏️ {date_str} 편집")
|
||||
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
|
||||
edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
|
||||
delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
|
||||
else:
|
||||
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
|
||||
add_action = menu.addAction(tr('cal.context_add', date=date_str))
|
||||
|
||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||
if action is None:
|
||||
return
|
||||
|
||||
if existing and action.text().startswith("✏️"):
|
||||
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||
if action == edit_action:
|
||||
self._open_edit_dialog(date_str)
|
||||
elif existing and action.text().startswith("🗑️"):
|
||||
elif action == delete_action:
|
||||
self._delete_record(date_str)
|
||||
elif not existing and action.text().startswith("➕"):
|
||||
elif action == add_action:
|
||||
self._add_past_record(date_str)
|
||||
|
||||
def _add_past_record(self, date_str: str):
|
||||
@@ -214,9 +218,9 @@ class CalendarView(QDialog):
|
||||
if ot_earned > 0:
|
||||
self.db.add_overtime_earned(wid, ot_earned, date_str)
|
||||
self._refresh_calendar()
|
||||
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
|
||||
QMessageBox.information(self, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str))
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
|
||||
QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=e))
|
||||
|
||||
def _open_edit_dialog(self, date_str: str):
|
||||
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
|
||||
@@ -228,8 +232,8 @@ class CalendarView(QDialog):
|
||||
|
||||
def _delete_record(self, date_str: str):
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
|
||||
tr('cal.delete_confirm_title'),
|
||||
tr('cal.delete_confirm_body', date=date_str),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
@@ -242,10 +246,10 @@ class CalendarView(QDialog):
|
||||
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
|
||||
conn.commit()
|
||||
self._refresh_calendar()
|
||||
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
|
||||
QMessageBox.information(self, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str))
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
QMessageBox.critical(self, "오류", str(e))
|
||||
QMessageBox.critical(self, tr('cal.edit_error_title'), str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -267,33 +271,33 @@ class CalendarView(QDialog):
|
||||
|
||||
if record:
|
||||
# 상세 정보 표시
|
||||
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail += f"출근: {record['clock_in']}\n"
|
||||
detail = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
|
||||
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
||||
|
||||
if record.get('clock_out'):
|
||||
detail += f"퇴근: {record['clock_out']}\n"
|
||||
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
|
||||
detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n'
|
||||
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
||||
|
||||
if record.get('lunch_break'):
|
||||
detail += f"점심시간: 사용함\n"
|
||||
detail += tr('cal.detail_lunch_used') + '\n'
|
||||
else:
|
||||
detail += f"점심시간: 미사용\n"
|
||||
detail += tr('cal.detail_lunch_unused') + '\n'
|
||||
|
||||
if record.get('dinner_break'):
|
||||
detail += f"저녁시간: 사용함\n"
|
||||
detail += tr('cal.detail_dinner_used') + '\n'
|
||||
else:
|
||||
detail += f"저녁시간: 미사용\n"
|
||||
detail += tr('cal.detail_dinner_unused') + '\n'
|
||||
|
||||
if record.get('overtime_earned', 0) > 0:
|
||||
earned_min = record['overtime_earned']
|
||||
earned_hours = earned_min // 60
|
||||
earned_mins = earned_min % 60
|
||||
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n"
|
||||
detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n'
|
||||
else:
|
||||
detail += f"퇴근: 미기록\n"
|
||||
detail += tr('cal.detail_clock_out_none') + '\n'
|
||||
|
||||
if record.get('memo'):
|
||||
detail += f"\n메모: {record['memo']}\n"
|
||||
detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n'
|
||||
|
||||
self.detail_text.setText(detail)
|
||||
self.edit_time_button.setEnabled(True)
|
||||
@@ -303,7 +307,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||
self.save_memo_button.setEnabled(True)
|
||||
else:
|
||||
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.memo_edit.setPlainText('')
|
||||
@@ -316,10 +320,8 @@ class CalendarView(QDialog):
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"출근 기록 삭제",
|
||||
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
|
||||
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
|
||||
f"※ 이 작업은 되돌릴 수 없습니다.",
|
||||
tr('cal.delete_selected_title'),
|
||||
tr('cal.delete_selected_body', date=self.selected_date_str),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
@@ -329,8 +331,8 @@ class CalendarView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"삭제 완료",
|
||||
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
|
||||
tr('cal.delete_done_title'),
|
||||
tr('cal.delete_done_body', date=self.selected_date_str)
|
||||
)
|
||||
|
||||
# 캘린더 새로고침
|
||||
@@ -350,8 +352,8 @@ class CalendarView(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"메모 저장",
|
||||
f"{self.selected_date_str}의 메모가 저장되었습니다."
|
||||
tr('cal.save_memo_title'),
|
||||
tr('cal.save_memo_body', date=self.selected_date_str)
|
||||
)
|
||||
|
||||
# 상세 정보 새로고침
|
||||
@@ -397,7 +399,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
from PyQt5.QtWidgets import QTimeEdit
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
self.setWindowTitle("출퇴근 시간 수정")
|
||||
self.setWindowTitle(tr('cal.edit_dialog_title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(420)
|
||||
|
||||
@@ -406,19 +408,19 @@ class EditWorkTimeDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
||||
title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str))
|
||||
title.setObjectName("dialog_subtitle")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 출근 시간
|
||||
clock_in_layout = QHBoxLayout()
|
||||
clock_in_layout.setSpacing(4)
|
||||
clock_in_label = QLabel("출근:")
|
||||
clock_in_label = QLabel(tr('cal.label_clock_in'))
|
||||
clock_in_label.setObjectName("field_label")
|
||||
clock_in_label.setFixedWidth(40)
|
||||
clock_in_layout.addWidget(clock_in_label)
|
||||
|
||||
clock_in_minus_btn = QPushButton("-30분")
|
||||
clock_in_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
||||
clock_in_minus_btn.setFixedWidth(55)
|
||||
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
||||
clock_in_layout.addWidget(clock_in_minus_btn)
|
||||
@@ -429,7 +431,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
self.clock_in_edit.setTime(clock_in_time)
|
||||
clock_in_layout.addWidget(self.clock_in_edit)
|
||||
|
||||
clock_in_plus_btn = QPushButton("+30분")
|
||||
clock_in_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||
clock_in_plus_btn.setFixedWidth(55)
|
||||
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
||||
clock_in_layout.addWidget(clock_in_plus_btn)
|
||||
@@ -438,12 +440,12 @@ class EditWorkTimeDialog(QDialog):
|
||||
# 퇴근 시간
|
||||
clock_out_layout = QHBoxLayout()
|
||||
clock_out_layout.setSpacing(4)
|
||||
clock_out_label = QLabel("퇴근:")
|
||||
clock_out_label = QLabel(tr('cal.label_clock_out'))
|
||||
clock_out_label.setObjectName("field_label")
|
||||
clock_out_label.setFixedWidth(40)
|
||||
clock_out_layout.addWidget(clock_out_label)
|
||||
|
||||
clock_out_minus_btn = QPushButton("-30분")
|
||||
clock_out_minus_btn = QPushButton(tr('cal.btn_minus_30'))
|
||||
clock_out_minus_btn.setFixedWidth(55)
|
||||
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
||||
clock_out_layout.addWidget(clock_out_minus_btn)
|
||||
@@ -455,7 +457,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
self.clock_out_edit.setTime(clock_out_time)
|
||||
clock_out_layout.addWidget(self.clock_out_edit)
|
||||
|
||||
clock_out_plus_btn = QPushButton("+30분")
|
||||
clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30'))
|
||||
clock_out_plus_btn.setFixedWidth(55)
|
||||
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
||||
clock_out_layout.addWidget(clock_out_plus_btn)
|
||||
@@ -464,27 +466,27 @@ class EditWorkTimeDialog(QDialog):
|
||||
# 점심/저녁 체크박스 - 한 줄에
|
||||
from PyQt5.QtWidgets import QCheckBox
|
||||
check_layout = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox("점심 (1시간)")
|
||||
self.lunch_check = QCheckBox(tr('cal.check_lunch_1h'))
|
||||
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
||||
check_layout.addWidget(self.lunch_check)
|
||||
|
||||
self.dinner_check = QCheckBox("저녁 (1시간)")
|
||||
self.dinner_check = QCheckBox(tr('cal.check_dinner_1h'))
|
||||
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
||||
check_layout.addWidget(self.dinner_check)
|
||||
layout.addLayout(check_layout)
|
||||
|
||||
# 안내 메시지
|
||||
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
|
||||
note = QLabel(tr('cal.edit_note'))
|
||||
note.setObjectName("note_text")
|
||||
layout.addWidget(note)
|
||||
|
||||
# 버튼
|
||||
button_layout = QHBoxLayout()
|
||||
save_button = QPushButton("저장")
|
||||
save_button = QPushButton(tr('btn.save'))
|
||||
save_button.setObjectName("btn_success")
|
||||
save_button.clicked.connect(self.save_changes)
|
||||
|
||||
cancel_button = QPushButton("취소")
|
||||
cancel_button = QPushButton(tr('btn.cancel'))
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addWidget(save_button)
|
||||
@@ -510,8 +512,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
if clock_out <= clock_in:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"시간 오류",
|
||||
"퇴근 시간은 출근 시간보다 늦어야 합니다."
|
||||
tr('cal.time_error_title'),
|
||||
tr('cal.time_error_body')
|
||||
)
|
||||
return
|
||||
|
||||
@@ -594,15 +596,12 @@ class EditWorkTimeDialog(QDialog):
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"수정 완료",
|
||||
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
|
||||
f"출근: {clock_in}\n"
|
||||
f"퇴근: {clock_out}\n"
|
||||
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
|
||||
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
|
||||
f"외출시간: {break_minutes}분\n"
|
||||
f"총 근무시간: {total_hours:.1f}시간\n"
|
||||
f"연장근무: {overtime_earned}분 적립"
|
||||
tr('cal.edit_done_title'),
|
||||
tr('cal.edit_done_body',
|
||||
date=self.date_str, clock_in=clock_in, clock_out=clock_out,
|
||||
lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
|
||||
dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
|
||||
break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
|
||||
)
|
||||
|
||||
self.accept()
|
||||
@@ -610,8 +609,8 @@ class EditWorkTimeDialog(QDialog):
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"오류",
|
||||
f"수정 중 오류가 발생했습니다:\n{str(e)}"
|
||||
tr('cal.edit_error_title'),
|
||||
tr('cal.edit_error_body', error=str(e))
|
||||
)
|
||||
finally:
|
||||
if conn:
|
||||
|
||||
+115
-32
@@ -9,15 +9,70 @@ from typing import List, Tuple
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
try:
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
import matplotlib
|
||||
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
from matplotlib.figure import Figure
|
||||
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
|
||||
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
|
||||
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
|
||||
try:
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||
except Exception:
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
_MPL = True
|
||||
except ImportError:
|
||||
except Exception as _mpl_err:
|
||||
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
|
||||
_MPL = False
|
||||
try:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
|
||||
# 막대/선은 데이터 구분용 고정 색.
|
||||
_CHART_BG = '#25262B'
|
||||
_CHART_GRID = '#2C2E33'
|
||||
_CHART_TEXT = '#909296'
|
||||
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
|
||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
|
||||
_CHART_AVG_LINE = '#51CF66' # green
|
||||
|
||||
|
||||
def _refresh_chart_colors() -> None:
|
||||
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
|
||||
global _CHART_BG, _CHART_GRID, _CHART_TEXT
|
||||
try:
|
||||
from ui.styles import ThemeColors
|
||||
_CHART_BG = ThemeColors.get('bg_secondary')
|
||||
_CHART_GRID = ThemeColors.get('border_subtle')
|
||||
_CHART_TEXT = ThemeColors.get('text_secondary')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _apply_dark_axes(ax) -> None:
|
||||
"""차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경."""
|
||||
ax.set_facecolor(_CHART_BG)
|
||||
ax.tick_params(axis='both', colors=_CHART_TEXT)
|
||||
ax.xaxis.label.set_color(_CHART_TEXT)
|
||||
ax.yaxis.label.set_color(_CHART_TEXT)
|
||||
ax.title.set_color(_CHART_TEXT)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color(_CHART_GRID)
|
||||
ax.grid(axis='y', alpha=0.25, color=_CHART_GRID)
|
||||
|
||||
|
||||
def _apply_dark_figure(fig) -> None:
|
||||
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
|
||||
_refresh_chart_colors()
|
||||
fig.patch.set_facecolor(_CHART_BG)
|
||||
|
||||
|
||||
class _Fallback(QWidget):
|
||||
@@ -28,7 +83,7 @@ class _Fallback(QWidget):
|
||||
label = QLabel(message)
|
||||
label.setAlignment(Qt.AlignCenter)
|
||||
label.setWordWrap(True)
|
||||
label.setStyleSheet("color: #888; padding: 20px;")
|
||||
label.setStyleSheet("color: #909296; padding: 20px;")
|
||||
layout.addWidget(label)
|
||||
self.setLayout(layout)
|
||||
|
||||
@@ -36,12 +91,15 @@ class _Fallback(QWidget):
|
||||
def make_chart_widget(parent=None) -> QWidget:
|
||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||
if not _MPL:
|
||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||
return _Fallback(tr('chart.need_matplotlib'))
|
||||
_refresh_chart_colors()
|
||||
widget = QWidget(parent)
|
||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
|
||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG)
|
||||
canvas = FigureCanvas(fig)
|
||||
canvas.setStyleSheet(f"background: {_CHART_BG};")
|
||||
layout.addWidget(canvas)
|
||||
widget.setLayout(layout)
|
||||
widget._figure = fig
|
||||
@@ -55,9 +113,12 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
@@ -68,20 +129,23 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
||||
ax.set_ylabel('시간')
|
||||
ax.legend(loc='upper left', fontsize=8)
|
||||
bars_base = ax.bar(dates, base, label=tr('chart.label_normal'), color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label=tr('chart.label_overtime'),
|
||||
color=_CHART_BAR_OVERTIME)
|
||||
ax.set_ylabel(tr('chart.ylabel_hours'))
|
||||
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
_apply_dark_axes(ax)
|
||||
|
||||
# 호버 annotation 설정
|
||||
annot = ax.annotate(
|
||||
"", xy=(0, 0), xytext=(15, 15),
|
||||
textcoords="offset points",
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
|
||||
alpha=0.95),
|
||||
color="white", fontsize=9,
|
||||
arrowprops=dict(arrowstyle="->", color="#888"),
|
||||
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
|
||||
)
|
||||
annot.set_visible(False)
|
||||
|
||||
@@ -95,13 +159,22 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
for i, bar in enumerate(bars):
|
||||
if bar.contains(event)[0]:
|
||||
h = hours[i]; ot = overtimes[i]
|
||||
text = f"▼ {full_dates[i]}\n근무 {h:.1f}h"
|
||||
text = tr('chart.hover_text',
|
||||
date=full_dates[i], hours=f"{h:.1f}")
|
||||
if ot > 0:
|
||||
text += f"\n연장 +{ot:.1f}h"
|
||||
text += "\n" + tr('chart.hover_overtime', hours=f"{ot:.1f}")
|
||||
annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y())
|
||||
annot.set_text(text)
|
||||
annot.set_visible(True)
|
||||
widget._canvas.draw_idle()
|
||||
# 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록
|
||||
db = getattr(widget, '_achievement_db', None)
|
||||
if db is not None:
|
||||
try:
|
||||
if db.get_setting('chart_hover_discovered', 'false').lower() != 'true':
|
||||
db.set_setting('chart_hover_discovered', 'true')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if annot.get_visible():
|
||||
annot.set_visible(False)
|
||||
@@ -117,13 +190,15 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
# 출근 시각을 분 단위로 (00:00=0)
|
||||
minutes_list = []
|
||||
for r in records:
|
||||
ci = r.get('clock_in')
|
||||
@@ -137,26 +212,32 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
pass
|
||||
|
||||
if not minutes_list:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, tr('chart.no_records'), ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
# 30분 빈
|
||||
bin_size = 30
|
||||
min_m = (min(minutes_list) // bin_size) * bin_size
|
||||
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
|
||||
bins = list(range(min_m, max_m + bin_size, bin_size))
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
|
||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||
edgecolor=_CHART_BG, linewidth=1)
|
||||
avg = sum(minutes_list) / len(minutes_list)
|
||||
ax.axvline(avg, color='#ff6b6b', linestyle='--',
|
||||
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
||||
avg_time = f"{int(avg//60):02d}:{int(avg%60):02d}"
|
||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||
label=tr('chart.avg_line', time=avg_time))
|
||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||
rotation=45, fontsize=8)
|
||||
ax.set_ylabel('일수')
|
||||
ax.set_title('출근 시각 분포')
|
||||
ax.legend(loc='upper right', fontsize=8)
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
ax.set_ylabel(tr('chart.ylabel_days'))
|
||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
|
||||
@@ -166,6 +247,7 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
|
||||
from datetime import datetime as _dt
|
||||
weekday_totals = [0.0] * 7
|
||||
@@ -179,12 +261,13 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
weekday_counts[d.weekday()] += 1
|
||||
|
||||
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
|
||||
labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||
labels = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
||||
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'),
|
||||
tr('label.weekday_sun')]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
|
||||
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||
ax.bar(labels, avg, color=colors)
|
||||
ax.set_ylabel('평균 시간')
|
||||
ax.set_title('요일별 평균 근무시간')
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
ax.set_ylabel(tr('chart.ylabel_avg_hours'))
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
@@ -134,8 +134,8 @@ if __name__ == "__main__":
|
||||
dialog = ClockInDialog()
|
||||
if dialog.exec_() == QDialog.Accepted:
|
||||
selected_time = dialog.get_time()
|
||||
print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}")
|
||||
print(tr('clock_in_dialog.selected', time=selected_time.strftime('%H:%M:%S')))
|
||||
else:
|
||||
print("취소됨")
|
||||
print(tr('clock_in_dialog.cancelled'))
|
||||
|
||||
sys.exit()
|
||||
|
||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
|
||||
import sqlite3
|
||||
|
||||
|
||||
class LockMonitor:
|
||||
@@ -17,12 +18,17 @@ class LockMonitor:
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
self.last_locked: bool = False
|
||||
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
|
||||
|
||||
def tick(self) -> None:
|
||||
try:
|
||||
from utils.lock_detector import is_screen_locked
|
||||
locked = is_screen_locked()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if not self._detector_failed_once:
|
||||
self._detector_failed_once = True
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"lock detector failed (silenced after first): {e}")
|
||||
return
|
||||
|
||||
was_locked = self.last_locked
|
||||
@@ -56,14 +62,23 @@ class LockMonitor:
|
||||
clock_in_str = when.strftime("%H:%M:%S")
|
||||
existing = self.db.get_today_record()
|
||||
if existing:
|
||||
conn = self.db.get_connection()
|
||||
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.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()
|
||||
w.update_display()
|
||||
|
||||
@@ -2,11 +2,25 @@
|
||||
알림 오케스트레이션.
|
||||
|
||||
5분 가드로 건강/주간/누적 임계 알림을 throttle.
|
||||
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
|
||||
notifier.py의 알림 메서드를 적절한 시점에 호출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.settings_keys import (
|
||||
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||
)
|
||||
from core.i18n import tr
|
||||
from utils.debug_log import dlog
|
||||
|
||||
|
||||
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||
try:
|
||||
v = int(db.get_setting(key, str(default)) or default)
|
||||
except (ValueError, TypeError):
|
||||
v = default
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
class NotificationOrchestrator:
|
||||
"""update_display() 1Hz tick에서 호출."""
|
||||
@@ -41,12 +55,12 @@ class NotificationOrchestrator:
|
||||
longest = max(closed, key=lambda r: r.get('total_hours') or 0)
|
||||
longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)"
|
||||
|
||||
title = "📊 지난주 요약"
|
||||
body = (f"기간: {last_mon} ~ {last_sun}\n"
|
||||
f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n"
|
||||
f"일 평균: {avg_h:.1f}시간\n"
|
||||
f"연장근무: {ot_h}시간 {ot_m}분\n"
|
||||
f"가장 긴 날: {longest_str}")
|
||||
title = tr('notif.weekly_report.title')
|
||||
body = tr('notif.weekly_report.body',
|
||||
start=last_mon, end=last_sun,
|
||||
total_h=total_h, days=len(closed),
|
||||
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
||||
longest=longest_str)
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
self.db.log_notification('system', 'weekly_report')
|
||||
|
||||
@@ -57,28 +71,39 @@ class NotificationOrchestrator:
|
||||
try:
|
||||
from utils.discord_webhook import send, COLOR_BLUE
|
||||
fields = [
|
||||
{"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True},
|
||||
{"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True},
|
||||
{"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True},
|
||||
{"name": "가장 긴 날", "value": longest_str, "inline": False},
|
||||
{"name": tr('field.total_work'), "value": tr('field.total_work_value', hours=total_h, days=len(closed)), "inline": True},
|
||||
{"name": tr('field.avg_daily'), "value": tr('field.avg_daily_value', hours=avg_h), "inline": True},
|
||||
{"name": tr('field.overtime'), "value": tr('field.overtime_value', hours=ot_h, minutes=ot_m), "inline": True},
|
||||
{"name": tr('field.longest_day'), "value": longest_str, "inline": False},
|
||||
]
|
||||
ok = send(url, "📊 지난주 요약",
|
||||
f"기간: {last_mon} ~ {last_sun}",
|
||||
ok = send(url, tr('notif.weekly_report.title'),
|
||||
tr('notif.weekly_report.body',
|
||||
start=last_mon, end=last_sun,
|
||||
total_h=total_h, days=len(closed),
|
||||
avg_h=avg_h, ot_h=ot_h, ot_m=ot_m,
|
||||
longest=longest_str),
|
||||
color=COLOR_BLUE, fields=fields)
|
||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"discord weekly_report failed: {e}")
|
||||
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
|
||||
is_holiday: bool = False) -> None:
|
||||
n = self.notifier
|
||||
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
||||
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
|
||||
if not is_holiday:
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
|
||||
n.check_lunch_reminder(self.window.clock_in_time,
|
||||
self.window.lunch_break_enabled, now)
|
||||
n.check_dinner_reminder(self.window.clock_in_time,
|
||||
getattr(self.window, 'dinner_break_enabled', False), now)
|
||||
if remaining_seconds < 0:
|
||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
||||
|
||||
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
|
||||
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
|
||||
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
|
||||
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||
self._last_5min_bucket = now.minute
|
||||
|
||||
@@ -89,18 +114,73 @@ class NotificationOrchestrator:
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
||||
|
||||
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
|
||||
consecutive = self.db.get_consecutive_overtime_days()
|
||||
if consecutive >= 3:
|
||||
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||
if consecutive >= consecutive_th:
|
||||
n.notify_health_warning(consecutive)
|
||||
self._discord_health(consecutive, break_minutes, now)
|
||||
|
||||
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
||||
if weekly_hours > 52:
|
||||
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||
if weekly_hours > weekly_th:
|
||||
n.notify_weekly_hours(weekly_hours)
|
||||
|
||||
balance_minutes = self.db.get_total_overtime_balance()
|
||||
if balance_minutes >= 1200:
|
||||
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||
if balance_minutes / 60.0 >= ot_threshold_h:
|
||||
n.notify_overtime_threshold(balance_minutes / 60.0)
|
||||
|
||||
# 도전과제 평가 (5분 throttle)
|
||||
self._evaluate_achievements(now)
|
||||
|
||||
def _evaluate_achievements(self, now: datetime) -> None:
|
||||
"""도전과제 평가 + 신규 잠금 해제 알림.
|
||||
|
||||
실패는 silent — 도전과제 시스템이 메인 흐름을 막으면 안 됨.
|
||||
"""
|
||||
try:
|
||||
from core.achievements import evaluate_all
|
||||
unlocked = evaluate_all(self.db)
|
||||
except Exception as e:
|
||||
dlog(f"achievement eval failed: {e}")
|
||||
return
|
||||
if not unlocked:
|
||||
return
|
||||
# 시스템 알림 + Discord push (옵션)
|
||||
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
|
||||
for a in unlocked:
|
||||
self.db.log_notification('system', f'achievement:{a.code}')
|
||||
if notif_on:
|
||||
title = tr('notif.achievement.title', icon=a.badge_icon)
|
||||
body = tr('notif.achievement.body', name=a.name, description=a.description)
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
# Discord 통합 push (여러 개면 묶어서)
|
||||
self._discord_achievements(unlocked)
|
||||
|
||||
def _discord_achievements(self, unlocked: list) -> None:
|
||||
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
|
||||
return
|
||||
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
from utils import discord_webhook
|
||||
fields = [{"name": f"{a.badge_icon} {a.name}",
|
||||
"value": a.description, "inline": False}
|
||||
for a in unlocked[:10]]
|
||||
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||
ok = discord_webhook.send(
|
||||
url,
|
||||
tr('discord.achievement.title', count=len(unlocked)),
|
||||
tr('discord.achievement.body', extra=extra),
|
||||
color=discord_webhook.COLOR_YELLOW,
|
||||
fields=fields,
|
||||
)
|
||||
self.db.log_notification('discord', 'achievement', success=ok)
|
||||
except Exception as e:
|
||||
dlog(f"discord achievement push failed: {e}")
|
||||
|
||||
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
|
||||
"""건강 경고 Discord push (옵션)."""
|
||||
if self.db.has_notification_today('discord', 'health'):
|
||||
@@ -115,5 +195,5 @@ class NotificationOrchestrator:
|
||||
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
||||
ok = discord_webhook.send_health_warning(url, elapsed)
|
||||
self.db.log_notification('discord', 'health', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"discord health push failed: {e}")
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
||||
|
||||
핵심 원칙:
|
||||
- 다이얼로그 배경: #0e0e14 (깊은 다크)
|
||||
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
|
||||
- 헤더: 큰 숫자 + 그라디언트 progress bar
|
||||
- 탭: 골드 강조 선택
|
||||
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
|
||||
|
||||
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List, Tuple
|
||||
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
|
||||
QProgressBar, QPushButton, QWidget, QSizePolicy,
|
||||
QTabWidget)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
||||
DARK_BG = '#1A1B1E'
|
||||
DARK_PANEL = '#25262B'
|
||||
DARK_PANEL_2 = '#2C2E33'
|
||||
DARK_BORDER = '#2C2E33'
|
||||
DARK_BORDER_STRONG = '#373A40'
|
||||
DARK_TEXT = '#E9ECEF'
|
||||
DARK_TEXT_DIM = '#909296'
|
||||
DARK_TEXT_FAINT = '#6C6E73'
|
||||
|
||||
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||
ACCENT_GOLD = '#ffd24a'
|
||||
ACCENT_BLUE = '#4DABF7'
|
||||
ACCENT_CYAN = '#4adef0'
|
||||
ACCENT_PINK = '#ff90b8'
|
||||
ACCENT_GREEN = '#51CF66'
|
||||
ACCENT_ORANGE = '#fcd34d'
|
||||
ACCENT_RED = '#FA5252'
|
||||
|
||||
# 카드 테마 (등급/상태별)
|
||||
CARD_THEMES = {
|
||||
'gold': {
|
||||
'border': '#ffb700', 'border_strong': '#ffd24a',
|
||||
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
|
||||
},
|
||||
'blue': {
|
||||
'border': '#5a8eff', 'border_strong': '#6b9eff',
|
||||
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
|
||||
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
|
||||
},
|
||||
'cyan': {
|
||||
'border': '#3acce0', 'border_strong': '#4adef0',
|
||||
'bg_top': '#0e3340', 'bg_bot': '#08222b',
|
||||
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
|
||||
},
|
||||
'green': {
|
||||
'border': '#3ace70', 'border_strong': '#4ade80',
|
||||
'bg_top': '#0e3324', 'bg_bot': '#082218',
|
||||
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
|
||||
},
|
||||
'pink': {
|
||||
'border': '#ff5a8c', 'border_strong': '#ff90b8',
|
||||
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4', 'accent': ACCENT_PINK,
|
||||
},
|
||||
'red': {
|
||||
'border': '#ea5566', 'border_strong': '#fb7185',
|
||||
'bg_top': '#3a1620', 'bg_bot': '#260e16',
|
||||
'text': '#ffb8c0', 'accent': ACCENT_RED,
|
||||
},
|
||||
'gray': {
|
||||
'border': '#44446a', 'border_strong': '#666688',
|
||||
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
|
||||
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 테마 연동 ──────────────────────────────────────────────────
|
||||
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
||||
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
|
||||
|
||||
def _pal() -> dict:
|
||||
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
|
||||
from ui.styles import ThemeColors
|
||||
g = ThemeColors.get
|
||||
return {
|
||||
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
|
||||
'border': g('border_subtle'), 'border_strong': g('border_default'),
|
||||
'text': g('text_primary'), 'text_dim': g('text_secondary'),
|
||||
'text_faint': g('text_tertiary'),
|
||||
'blue': g('accent_primary'), 'green': g('accent_success'),
|
||||
'red': g('accent_danger'),
|
||||
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
|
||||
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
|
||||
}
|
||||
|
||||
|
||||
def _is_dark() -> bool:
|
||||
from ui.styles import ThemeColors, DARK_COLORS
|
||||
return ThemeColors.current is DARK_COLORS
|
||||
|
||||
|
||||
def tc(role: str) -> str:
|
||||
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
|
||||
return _pal().get(role, '#FF00FF')
|
||||
|
||||
|
||||
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
def dialog_qss() -> str:
|
||||
"""다이얼로그 전체 배경 (현재 테마)."""
|
||||
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = None) -> str:
|
||||
p = _pal()
|
||||
if accent is None:
|
||||
accent = p['blue']
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {p['panel2']};
|
||||
color: {p['text_dim']};
|
||||
padding: 9px 18px;
|
||||
border: 1px solid {p['border']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {p['panel']};
|
||||
color: {accent};
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid {accent};
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
background: {p['border_strong']};
|
||||
color: {p['text']};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scroll_qss() -> str:
|
||||
p = _pal()
|
||||
return f"""
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
QScrollBar:vertical {{
|
||||
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
def button_qss(variant: str = 'default') -> str:
|
||||
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
||||
p = _pal()
|
||||
if variant == 'primary':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['blue']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['blue_hover']}; }}
|
||||
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
|
||||
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['green']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['green_hover']}; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['red']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['red_hover']}; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {p['text_dim']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
||||
border-color: {p['blue']}; }}
|
||||
"""
|
||||
# default
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {p['panel2']}; color: {p['text']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
|
||||
|
||||
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
big_color: str = ACCENT_GOLD,
|
||||
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
|
||||
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
|
||||
|
||||
Args:
|
||||
title: 큰 숫자 위 작은 라벨 (예: "달성")
|
||||
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
|
||||
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
|
||||
big_color: 큰 숫자 색
|
||||
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||
"""
|
||||
p = _pal()
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
|
||||
""")
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(20, 14, 20, 14)
|
||||
layout.setSpacing(20)
|
||||
|
||||
# 좌측: 제목 + 큰 숫자
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(2)
|
||||
if title:
|
||||
t = QLabel(title)
|
||||
t.setStyleSheet(
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
left.addWidget(t)
|
||||
big = QLabel(
|
||||
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
||||
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {p['text_dim']};'>"
|
||||
f" {subtitle}</span>" if subtitle else '')
|
||||
)
|
||||
big.setTextFormat(Qt.RichText)
|
||||
big.setStyleSheet("background: transparent; border: none;")
|
||||
left.addWidget(big)
|
||||
layout.addLayout(left)
|
||||
|
||||
# 우측: extra widgets
|
||||
if extra_widgets:
|
||||
layout.addStretch()
|
||||
for w in extra_widgets:
|
||||
layout.addWidget(w)
|
||||
else:
|
||||
layout.addStretch()
|
||||
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
|
||||
def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
theme: str = 'blue', icon: str = '') -> QFrame:
|
||||
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||
p = _pal()
|
||||
dark = _is_dark()
|
||||
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
|
||||
if dark:
|
||||
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||
card_border = t['border']
|
||||
label_color = t['text']
|
||||
value_color = t['border_strong']
|
||||
else:
|
||||
card_bg = p['panel']
|
||||
card_border = p['border']
|
||||
label_color = p['text']
|
||||
value_color = p['text']
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(16, 12, 16, 12)
|
||||
outer.setSpacing(12)
|
||||
|
||||
if icon:
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setMinimumWidth(48)
|
||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
|
||||
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
|
||||
else:
|
||||
# 이모지/텍스트 폴백 (구버전 호환)
|
||||
icon_lbl.setText(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
outer.addWidget(icon_lbl)
|
||||
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(2)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 9.5pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
text_box.addWidget(title_lbl)
|
||||
|
||||
val_lbl = QLabel(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
val_lbl.setTextFormat(Qt.RichText)
|
||||
val_lbl.setStyleSheet("background: transparent; border: none;")
|
||||
text_box.addWidget(val_lbl)
|
||||
|
||||
if subtitle:
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setStyleSheet(
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
sub_lbl.setWordWrap(True)
|
||||
text_box.addWidget(sub_lbl)
|
||||
|
||||
outer.addLayout(text_box, 1)
|
||||
card.setLayout(outer)
|
||||
return card
|
||||
|
||||
|
||||
def build_section_card(title: str, content: QWidget,
|
||||
theme: str = 'gray', icon: str = '') -> QFrame:
|
||||
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
||||
p = _pal()
|
||||
if _is_dark():
|
||||
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||
card_border = t['border']
|
||||
label_color = t['text']
|
||||
else:
|
||||
card_bg = p['panel']
|
||||
card_border = p['border']
|
||||
label_color = p['text']
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 14)
|
||||
layout.setSpacing(8)
|
||||
|
||||
head = QHBoxLayout()
|
||||
if icon:
|
||||
i = QLabel()
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
|
||||
else:
|
||||
i.setText(icon)
|
||||
i.setStyleSheet(
|
||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(i)
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(title_lbl)
|
||||
head.addStretch()
|
||||
layout.addLayout(head)
|
||||
|
||||
layout.addWidget(content, 1)
|
||||
card.setLayout(layout)
|
||||
return card
|
||||
|
||||
|
||||
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
||||
height: int = 10) -> None:
|
||||
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||
pb.setMinimumHeight(height)
|
||||
pb.setMaximumHeight(height)
|
||||
pb.setTextVisible(False)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-radius: {height // 2}px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {t['border']}, stop:1 {t['border_strong']});
|
||||
border-radius: {height // 2}px;
|
||||
}}
|
||||
""")
|
||||
|
||||
|
||||
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||
color: str = None) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
||||
lbl = QLabel(text)
|
||||
if color is None:
|
||||
color = _pal()['text']
|
||||
weight_str = 'bold' if weight == 'bold' else 'normal'
|
||||
lbl.setStyleSheet(
|
||||
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
||||
f"background: transparent; border: none; padding: 0; margin: 0;"
|
||||
)
|
||||
return lbl
|
||||
+7
-5
@@ -9,6 +9,8 @@ from datetime import datetime, date
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class GoalWidget(QWidget):
|
||||
"""월간 목표 진행률 표시."""
|
||||
@@ -20,13 +22,13 @@ class GoalWidget(QWidget):
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title = QLabel("🎯 이번 달 목표")
|
||||
title = QLabel(tr('goal.title'))
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# 연장근무 상한
|
||||
ot_row = QHBoxLayout()
|
||||
self.ot_label = QLabel("연장근무:")
|
||||
self.ot_label = QLabel(tr('goal.overtime'))
|
||||
self.ot_label.setFixedWidth(100)
|
||||
self.ot_bar = QProgressBar()
|
||||
self.ot_bar.setTextVisible(True)
|
||||
@@ -37,7 +39,7 @@ class GoalWidget(QWidget):
|
||||
|
||||
# 일평균
|
||||
avg_row = QHBoxLayout()
|
||||
self.avg_label = QLabel("일평균:")
|
||||
self.avg_label = QLabel(tr('goal.avg_daily'))
|
||||
self.avg_label.setFixedWidth(100)
|
||||
self.avg_bar = QProgressBar()
|
||||
self.avg_bar.setTextVisible(True)
|
||||
@@ -78,7 +80,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 = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
|
||||
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
|
||||
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.ot_label.setVisible(False)
|
||||
@@ -93,7 +95,7 @@ class GoalWidget(QWidget):
|
||||
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
|
||||
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
||||
ratio = avg / avg_target if avg_target else 0
|
||||
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
|
||||
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
|
||||
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.avg_label.setVisible(False)
|
||||
|
||||
+115
-17
@@ -2,6 +2,7 @@
|
||||
사용 설명 가이드 창.
|
||||
|
||||
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
|
||||
도전과제/통계 다이얼로그와 동일한 다크 톤.
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QWidget, QTabWidget, QTextBrowser)
|
||||
@@ -9,6 +10,7 @@ from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr, tr_html
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
|
||||
|
||||
|
||||
class HelpView(QDialog):
|
||||
@@ -28,42 +30,47 @@ class HelpView(QDialog):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(tr('window.help'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(680, 720)
|
||||
|
||||
self.setMinimumSize(720, 720)
|
||||
self.resize(820, 760)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
main_layout.setContentsMargins(20, 16, 20, 14)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# 다크 타이틀
|
||||
title = QLabel(tr('window.help'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('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))
|
||||
main_layout.addWidget(tabs)
|
||||
main_layout.addWidget(tabs, 1)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setContentsMargins(20, 10, 20, 20)
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽)
|
||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(40)
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton(tr('help.onboarding_button'))
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
button_layout.addWidget(onboarding_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setObjectName("btn_primary")
|
||||
close_button.setMinimumHeight(40)
|
||||
close_button.setMinimumHeight(36)
|
||||
close_button.setMinimumWidth(120)
|
||||
close_button.setStyleSheet(button_qss('primary'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
@@ -78,15 +85,106 @@ class HelpView(QDialog):
|
||||
|
||||
def _make_tab(self, html: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background: {tc('panel')};")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
browser = QTextBrowser()
|
||||
browser.setOpenExternalLinks(False)
|
||||
browser.setHtml(html)
|
||||
# HTML 내부에 다크 톤 스타일 주입
|
||||
styled_html = self._inject_dark_styles(html)
|
||||
browser.setHtml(styled_html)
|
||||
browser.setStyleSheet(f"""
|
||||
QTextBrowser {{
|
||||
background: {tc('panel')};
|
||||
color: {tc('text')};
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 10.5pt;
|
||||
selection-background-color: {tc('blue')};
|
||||
selection-color: #ffffff;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: {tc('panel')}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
""")
|
||||
layout.addWidget(browser)
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
def _inject_dark_styles(self, html: str) -> str:
|
||||
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
|
||||
text = tc('text')
|
||||
dim = tc('text_dim')
|
||||
blue = tc('blue')
|
||||
green = tc('green')
|
||||
panel2 = tc('panel2')
|
||||
border = tc('border')
|
||||
css = f"""
|
||||
<style>
|
||||
body, p, li {{
|
||||
color: {text};
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
h1, h2, h3, h4 {{
|
||||
color: {blue};
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: {blue}; }}
|
||||
h4 {{ font-size: 11pt; color: {green}; }}
|
||||
b, strong {{ color: {text}; }}
|
||||
code {{
|
||||
background: {panel2};
|
||||
color: {blue};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
pre {{
|
||||
background: {panel2};
|
||||
border: 1px solid {border};
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: {text};
|
||||
}}
|
||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
a {{ color: {blue}; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||
th {{
|
||||
background: {panel2};
|
||||
color: {text};
|
||||
padding: 8px 12px;
|
||||
border: 1px solid {border};
|
||||
text-align: left;
|
||||
}}
|
||||
td {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid {border};
|
||||
color: {text};
|
||||
}}
|
||||
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
|
||||
blockquote {{
|
||||
border-left: 3px solid {blue};
|
||||
margin-left: 0;
|
||||
padding: 4px 16px;
|
||||
color: {dim};
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
return css + html
|
||||
|
||||
|
||||
# 단독 실행 테스트
|
||||
if __name__ == "__main__":
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
|
||||
|
||||
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
|
||||
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환 시
|
||||
재호출하면 자동으로 재틴팅된다.
|
||||
|
||||
사용:
|
||||
from ui.icons import get_icon
|
||||
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
|
||||
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QByteArray, QRectF, Qt
|
||||
from PyQt5.QtGui import QIcon, QPixmap, QPainter
|
||||
from PyQt5.QtSvg import QSvgRenderer
|
||||
|
||||
from ui.styles import ThemeColors
|
||||
|
||||
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
|
||||
_PATHS = {
|
||||
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
|
||||
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
|
||||
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
}
|
||||
|
||||
_SVG_TMPL = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||
'fill="none" stroke="{color}" stroke-width="2" '
|
||||
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
|
||||
)
|
||||
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
|
||||
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
|
||||
if color is None:
|
||||
color = ThemeColors.get('text_secondary')
|
||||
key = (name, color, size)
|
||||
cached = _cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
paths = _PATHS.get(name)
|
||||
if paths is None:
|
||||
return QIcon()
|
||||
|
||||
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
|
||||
renderer = QSvgRenderer(QByteArray(svg))
|
||||
|
||||
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
|
||||
pm = QPixmap(size * dpr, size * dpr)
|
||||
pm.fill(Qt.transparent)
|
||||
painter = QPainter(pm)
|
||||
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||
painter.end()
|
||||
pm.setDevicePixelRatio(dpr)
|
||||
|
||||
icon = QIcon(pm)
|
||||
_cache[key] = icon
|
||||
return icon
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
|
||||
_cache.clear()
|
||||
+23
-14
@@ -13,6 +13,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@@ -22,7 +23,7 @@ class LeaveCalendarView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("📅 연차 캘린더")
|
||||
self.setWindowTitle(tr('leave_cal.title'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
@@ -37,19 +38,19 @@ class LeaveCalendarView(QDialog):
|
||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||
used = total - balance
|
||||
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||
title = QLabel(tr('leave_cal.header', balance=balance, total=total, used=used))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
layout.addLayout(header)
|
||||
|
||||
# 범례
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
legend = QHBoxLayout()
|
||||
for label, color in [("🟩 종일(1.0)", "#4caf50"),
|
||||
("🟨 반차(0.5)", "#ffc107"),
|
||||
("🟪 반반차(0.25)", "#9c27b0")]:
|
||||
l = QLabel(label)
|
||||
l.setStyleSheet(f"padding: 2px 6px;")
|
||||
for _color, _txt in [('#51CF66', tr('leave_cal.legend_full')), ('#FAB005', tr('leave_cal.legend_half')),
|
||||
('#B197FC', tr('leave_cal.legend_quarter')), ('#4DABF7', tr('leave_cal.legend_planned')),
|
||||
('#748FFC', tr('leave_cal.legend_full_planned'))]:
|
||||
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
l.setStyleSheet("padding: 2px 6px;")
|
||||
legend.addWidget(l)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
@@ -62,13 +63,13 @@ class LeaveCalendarView(QDialog):
|
||||
|
||||
# 선택 일자 정보
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
|
||||
layout.addWidget(self.detail_label)
|
||||
|
||||
# 닫기 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
@@ -76,7 +77,9 @@ class LeaveCalendarView(QDialog):
|
||||
self.setLayout(layout)
|
||||
|
||||
def _mark_dates(self):
|
||||
"""연차 사용 일자에 색상 표시."""
|
||||
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
|
||||
from datetime import date as _date
|
||||
today = _date.today()
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
for r in records:
|
||||
try:
|
||||
@@ -85,6 +88,12 @@ class LeaveCalendarView(QDialog):
|
||||
continue
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
days = float(r.get('days') or 0)
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
|
||||
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
|
||||
else:
|
||||
# 과거/오늘 = 사용 완료 색상
|
||||
if days >= 1.0:
|
||||
color = QColor("#4caf50")
|
||||
elif days >= 0.5:
|
||||
@@ -101,12 +110,12 @@ class LeaveCalendarView(QDialog):
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
match = [r for r in records if r['date'] == date_str]
|
||||
if not match:
|
||||
self.detail_label.setText(f"{date_str} — 연차 사용 없음")
|
||||
self.detail_label.setText(tr('leave_cal.detail_no_record', date=date_str))
|
||||
return
|
||||
parts = []
|
||||
for r in match:
|
||||
t = r.get('leave_type', 'annual')
|
||||
d = float(r.get('days') or 0)
|
||||
memo = r.get('memo') or ''
|
||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
||||
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
|
||||
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))
|
||||
|
||||
+50
-5
@@ -90,6 +90,11 @@ 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.clicked.connect(self._show_schedule)
|
||||
button_layout.addWidget(schedule_button)
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
@@ -102,6 +107,13 @@ class LeaveView(QDialog):
|
||||
dlg = LeaveCalendarView(self, self.db)
|
||||
dlg.exec_()
|
||||
|
||||
def _show_schedule(self):
|
||||
from ui.schedule_view import ScheduleView
|
||||
dlg = ScheduleView(self, self.db)
|
||||
dlg.exec_()
|
||||
# 닫고 돌아오면 잔액/리스트 갱신
|
||||
self.load_data()
|
||||
|
||||
def load_data(self):
|
||||
"""데이터 로드"""
|
||||
# 잔액 업데이트
|
||||
@@ -125,16 +137,16 @@ class LeaveView(QDialog):
|
||||
days = record['days']
|
||||
hours = days * 8
|
||||
if days == 1.0:
|
||||
days_str = "1일"
|
||||
days_str = tr('view.leave.used_1day')
|
||||
elif days == 0.5:
|
||||
days_str = "0.5일 (4시간)"
|
||||
days_str = tr('view.leave.used_half_day')
|
||||
elif hours < 8:
|
||||
days_str = f"{days}일 ({hours}시간)"
|
||||
days_str = tr('view.leave.used_hours_fmt', days=days, hours=hours)
|
||||
else:
|
||||
days_str = f"{days}일"
|
||||
days_str = tr('view.leave.used_days_fmt', days=days)
|
||||
days_item = QTableWidgetItem(days_str)
|
||||
days_item.setTextAlignment(Qt.AlignCenter)
|
||||
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
|
||||
memo_item = QTableWidgetItem(record['memo'] or "")
|
||||
|
||||
@@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
|
||||
self.date_edit = QDateEdit()
|
||||
self.date_edit.setDate(QDate.currentDate())
|
||||
self.date_edit.setCalendarPopup(True)
|
||||
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
|
||||
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
|
||||
row1.addWidget(date_label)
|
||||
row1.addWidget(self.date_edit)
|
||||
row1.addSpacing(8)
|
||||
@@ -345,6 +359,37 @@ class AddLeaveDialog(QDialog):
|
||||
)
|
||||
return
|
||||
|
||||
# 휴일/주말 검증 — 차감 의미 없으므로 차단
|
||||
from datetime import datetime as _dt
|
||||
date_dt = _dt.strptime(date, "%Y-%m-%d")
|
||||
if date_dt.weekday() in (5, 6): # 토/일
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.weekend_register_forbidden_title'),
|
||||
tr('view.leave.weekend_register_forbidden_body')
|
||||
)
|
||||
return
|
||||
if self.db.is_holiday(date):
|
||||
holiday = self.db.get_holiday(date)
|
||||
name = (holiday or {}).get('name', tr('label.holiday_default'))
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.holiday_register_forbidden_title'),
|
||||
tr('view.leave.holiday_register_forbidden_body', date=date, name=name)
|
||||
)
|
||||
return
|
||||
|
||||
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
|
||||
existing_min = self.db.get_leave_minutes_for(date)
|
||||
existing_days = existing_min / max(1, self.db.get_work_minutes())
|
||||
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
tr('view.leave.duplicate_register_title'),
|
||||
tr('view.leave.duplicate_register_body', date=date, existing_days=existing_days, days=days)
|
||||
)
|
||||
return
|
||||
|
||||
# 확인 메시지
|
||||
hours = days * 8
|
||||
reply = QMessageBox.question(
|
||||
|
||||
+709
-390
File diff suppressed because it is too large
Load Diff
+101
-33
@@ -3,62 +3,87 @@
|
||||
|
||||
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
||||
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
||||
|
||||
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
|
||||
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class MealTimeDialog(QDialog):
|
||||
"""점심/저녁 실제 시작·종료 시간 입력."""
|
||||
"""점심/저녁 실제 시작·종료 시간 입력.
|
||||
|
||||
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
|
||||
Args:
|
||||
parent: 부모 위젯
|
||||
meal_type: 'lunch' 또는 'dinner'
|
||||
default_minutes: 안내 문구에 표시할 기본 차감 분
|
||||
clock_in_time: 출근 datetime (옵션). 주어지면 식사가 출근 이후인지 검증.
|
||||
clock_out_time: 퇴근 datetime (옵션, 보통 미퇴근 상태). 주어지면 퇴근 이전인지 검증.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60,
|
||||
clock_in_time: Optional[datetime] = None,
|
||||
clock_out_time: Optional[datetime] = None):
|
||||
super().__init__(parent)
|
||||
self.meal_type = meal_type
|
||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||
self._clock_in = clock_in_time
|
||||
self._clock_out = clock_out_time
|
||||
meal_label = tr('label.lunch_short') if meal_type == 'lunch' else tr('label.dinner_short')
|
||||
self.setWindowTitle(tr('meal.dialog_title', meal=meal_label))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(360, 220)
|
||||
self.setFixedSize(380, 260)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||
info_text = tr('meal.info_text', meal=meal_label, minutes=default_minutes)
|
||||
if clock_in_time is not None:
|
||||
info_text += tr('meal.info_clock_in_limit', time=clock_in_time.strftime('%H:%M'))
|
||||
info = QLabel(info_text)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
||||
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 합리적 기본값: 출근 이후로 보정
|
||||
default_start_h = 12 if meal_type == 'lunch' else 18
|
||||
default_end_h = 13 if meal_type == 'lunch' else 19
|
||||
if clock_in_time is not None and clock_in_time.hour >= default_start_h:
|
||||
# 출근이 이미 점심/저녁 기본 시각을 지났으면 출근 직후를 기본값으로
|
||||
default_start_h = (clock_in_time.hour + 1) % 24
|
||||
default_end_h = (default_start_h + 1) % 24
|
||||
|
||||
# 시작
|
||||
start_row = QHBoxLayout()
|
||||
start_row.addWidget(QLabel("시작:"))
|
||||
start_row.addWidget(QLabel(tr('meal.label_start')))
|
||||
self.start_edit = QTimeEdit()
|
||||
self.start_edit.setDisplayFormat("HH:mm")
|
||||
# 기본값: 점심=12:00, 저녁=18:00
|
||||
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
|
||||
self.start_edit.setTime(default_start)
|
||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||
start_row.addWidget(self.start_edit)
|
||||
start_row.addStretch()
|
||||
layout.addLayout(start_row)
|
||||
|
||||
# 종료
|
||||
end_row = QHBoxLayout()
|
||||
end_row.addWidget(QLabel("종료:"))
|
||||
end_row.addWidget(QLabel(tr('meal.label_end')))
|
||||
self.end_edit = QTimeEdit()
|
||||
self.end_edit.setDisplayFormat("HH:mm")
|
||||
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
|
||||
self.end_edit.setTime(default_end)
|
||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||
end_row.addWidget(self.end_edit)
|
||||
end_row.addStretch()
|
||||
layout.addLayout(end_row)
|
||||
|
||||
# 미리보기 라벨
|
||||
self.preview = QLabel("")
|
||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
|
||||
layout.addWidget(self.preview)
|
||||
self._update_preview()
|
||||
self.start_edit.timeChanged.connect(self._update_preview)
|
||||
@@ -67,10 +92,10 @@ class MealTimeDialog(QDialog):
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self.accept)
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
@@ -79,20 +104,67 @@ class MealTimeDialog(QDialog):
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _update_preview(self):
|
||||
s = self.start_edit.time()
|
||||
e = self.end_edit.time()
|
||||
start_dt = datetime.combine(datetime.today(), s.toPyTime())
|
||||
end_dt = datetime.combine(datetime.today(), e.toPyTime())
|
||||
# -------- 내부 시각 계산 --------
|
||||
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
|
||||
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
|
||||
|
||||
자정 경계 처리:
|
||||
1) 야간 출근자(clock_in 18시 이후)가 새벽 식사 시각을 입력하면
|
||||
시작이 출근 이전으로 보이는데, 이를 다음날 새벽으로 +1day shift.
|
||||
2) 종료가 시작보다 빠르면 종료에 +1day (점심 12:55→13:30 같은 정상은 영향 X).
|
||||
|
||||
주간 출근자(clock_in 09시)가 08시 입력 시 +1day는 적용하지 않아 검증에서 거절.
|
||||
"""
|
||||
s = self.start_edit.time().toPyTime()
|
||||
e = self.end_edit.time().toPyTime()
|
||||
base_date = (self._clock_in.date() if self._clock_in is not None
|
||||
else datetime.today().date())
|
||||
start_dt = datetime.combine(base_date, s)
|
||||
end_dt = datetime.combine(base_date, e)
|
||||
|
||||
# 야간 출근자 자동 보정
|
||||
if (self._clock_in is not None and start_dt < self._clock_in
|
||||
and self._clock_in.hour >= 18):
|
||||
start_dt += timedelta(days=1)
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
if end_dt < start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
||||
if minutes <= 0:
|
||||
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
|
||||
self.preview.setStyleSheet("color: #f44336;")
|
||||
return start_dt, end_dt, minutes
|
||||
|
||||
def _update_preview(self):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
self.preview.setText(reason)
|
||||
self.preview.setStyleSheet("color: #FA5252;")
|
||||
else:
|
||||
self.preview.setText(f"총 {minutes}분")
|
||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
self.preview.setText(tr('meal.preview_total', minutes=minutes))
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||
minutes: int) -> tuple[bool, str]:
|
||||
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||
if minutes <= 0:
|
||||
return False, tr('meal.error_start_after_end')
|
||||
if minutes > 8 * 60:
|
||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||
return False, tr('meal.error_too_long')
|
||||
if self._clock_in is not None and start_dt < self._clock_in:
|
||||
return False, tr('meal.error_before_clock_in', time=self._clock_in.strftime('%H:%M'))
|
||||
if self._clock_out is not None and end_dt > self._clock_out:
|
||||
return False, tr('meal.error_after_clock_out', time=self._clock_out.strftime('%H:%M'))
|
||||
return True, ""
|
||||
|
||||
def accept(self):
|
||||
"""저장 버튼: 검증 실패 시 다이얼로그 닫지 않고 메시지박스."""
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, tr('meal.input_error_title'), reason)
|
||||
return
|
||||
super().accept()
|
||||
|
||||
def get_times(self) -> tuple[str, str, int]:
|
||||
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
||||
@@ -100,9 +172,5 @@ class MealTimeDialog(QDialog):
|
||||
e = self.end_edit.time().toPyTime()
|
||||
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
||||
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
||||
s_dt = datetime.combine(datetime.today(), s)
|
||||
e_dt = datetime.combine(datetime.today(), e)
|
||||
if e_dt < s_dt:
|
||||
e_dt += timedelta(days=1)
|
||||
minutes = int((e_dt - s_dt).total_seconds() / 60)
|
||||
_, _, minutes = self._resolve_meal_window()
|
||||
return start_str, end_str, minutes
|
||||
|
||||
+20
-8
@@ -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: #888; font-size: 11px;")
|
||||
self.title_label.setStyleSheet("color: #909296; 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(30, 30, 30, 230); border-radius: 8px; }
|
||||
QLabel { color: #fff; }
|
||||
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
|
||||
QLabel { color: #E9ECEF; background: transparent; }
|
||||
""")
|
||||
|
||||
apply_dark_titlebar(self)
|
||||
@@ -63,11 +63,12 @@ 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: #ff6b6b;")
|
||||
self.time_label.setStyleSheet("color: #51CF66;")
|
||||
else:
|
||||
self.title_label.setText(tr('label.remaining'))
|
||||
self.time_label.setStyleSheet("color: #fff;")
|
||||
self.time_label.setStyleSheet("color: #E9ECEF;")
|
||||
|
||||
# 드래그 이동
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
@@ -90,8 +91,19 @@ class MiniWidget(QWidget):
|
||||
def contextMenuEvent(self, event):
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
menu = QMenu(self)
|
||||
open_main = menu.addAction("메인 창 열기")
|
||||
close_mini = menu.addAction("미니 위젯 닫기")
|
||||
# 미니 위젯 자체 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'))
|
||||
action = menu.exec_(event.globalPos())
|
||||
if action == open_main and self.parent_window:
|
||||
self.parent_window.show()
|
||||
|
||||
+101
-86
@@ -12,33 +12,30 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
|
||||
QMessageBox, QGroupBox)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# (label, work_minutes, lunch_minutes)
|
||||
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
|
||||
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
|
||||
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
|
||||
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
|
||||
WORK_PRESETS = [
|
||||
("표준 8시간 (점심 60분)", 480, 60),
|
||||
("단축근무 7시간 30분 (점심 30분)", 450, 30),
|
||||
("단축근무 7시간 (점심 60분)", 420, 60),
|
||||
("단축근무 6시간 (점심 30분)", 360, 30),
|
||||
("반일 4시간 (점심 0분)", 240, 0),
|
||||
('onboarding.preset.standard_8h', 480, 60, 0),
|
||||
('onboarding.preset.short_7h30m', 450, 30, 0),
|
||||
('onboarding.preset.short_7h', 420, 60, 0),
|
||||
('onboarding.preset.short_6h', 360, 30, 0),
|
||||
('onboarding.preset.half_4h', 240, 0, 0),
|
||||
]
|
||||
|
||||
|
||||
class WelcomePage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("👋 환영합니다!")
|
||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||
self.setTitle(tr('onboarding.welcome_title'))
|
||||
self.setSubTitle(tr('onboarding.welcome_subtitle'))
|
||||
layout = QVBoxLayout()
|
||||
intro = QLabel(
|
||||
"이 앱은:\n"
|
||||
"• 컴퓨터 부팅/잠금 해제로 출근 시간 자동 감지\n"
|
||||
"• 30분 단위 연장근무 적립\n"
|
||||
"• 연차·반차·외출 시간 추적\n"
|
||||
"• 매일 퇴근 시간을 1초마다 카운트다운\n\n"
|
||||
"[다음] 버튼을 눌러 시작하세요."
|
||||
)
|
||||
intro = QLabel(tr('onboarding.welcome_intro'))
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
self.setLayout(layout)
|
||||
@@ -47,74 +44,96 @@ class WelcomePage(QWizardPage):
|
||||
class WorkPatternPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🕘 근무 패턴")
|
||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||
self.setTitle(tr('onboarding.work_pattern_title'))
|
||||
self.setSubTitle(tr('onboarding.work_pattern_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.button_group = QButtonGroup(self)
|
||||
for i, (label, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(label)
|
||||
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(tr(key))
|
||||
self.button_group.addButton(rb, i)
|
||||
layout.addWidget(rb)
|
||||
if i == 0:
|
||||
rb.setChecked(True)
|
||||
|
||||
# 사용자 정의
|
||||
custom_box = QGroupBox("사용자 정의")
|
||||
custom_layout = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton("직접 입력:")
|
||||
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
|
||||
custom_layout = QVBoxLayout()
|
||||
custom_layout.setSpacing(4)
|
||||
|
||||
suffix_h = tr('onboarding.preset.suffix_hours')
|
||||
suffix_m = tr('onboarding.preset.suffix_minutes')
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio'))
|
||||
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
|
||||
self.hours_spin = QSpinBox()
|
||||
self.hours_spin.setRange(0, 12)
|
||||
self.hours_spin.setValue(8)
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.hours_spin.setSuffix(suffix_h)
|
||||
self.minutes_spin = QSpinBox()
|
||||
self.minutes_spin.setRange(0, 59)
|
||||
self.minutes_spin.setSingleStep(15)
|
||||
self.minutes_spin.setSuffix(" 분")
|
||||
self.minutes_spin.setSuffix(suffix_m)
|
||||
row1.addWidget(self.custom_radio)
|
||||
row1.addWidget(self.hours_spin)
|
||||
row1.addWidget(self.minutes_spin)
|
||||
row1.addStretch()
|
||||
custom_layout.addLayout(row1)
|
||||
|
||||
row2 = QHBoxLayout()
|
||||
self.lunch_spin = QSpinBox()
|
||||
self.lunch_spin.setRange(0, 120)
|
||||
self.lunch_spin.setSingleStep(5)
|
||||
self.lunch_spin.setValue(60)
|
||||
self.lunch_spin.setPrefix("점심 ")
|
||||
self.lunch_spin.setSuffix(" 분")
|
||||
custom_layout.addWidget(self.custom_radio)
|
||||
custom_layout.addWidget(self.hours_spin)
|
||||
custom_layout.addWidget(self.minutes_spin)
|
||||
custom_layout.addWidget(self.lunch_spin)
|
||||
custom_layout.addStretch()
|
||||
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
|
||||
self.lunch_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin = QSpinBox()
|
||||
self.dinner_spin.setRange(0, 120)
|
||||
self.dinner_spin.setSingleStep(5)
|
||||
self.dinner_spin.setValue(0)
|
||||
self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix'))
|
||||
self.dinner_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip'))
|
||||
row2.addSpacing(20)
|
||||
row2.addWidget(self.lunch_spin)
|
||||
row2.addWidget(self.dinner_spin)
|
||||
row2.addStretch()
|
||||
custom_layout.addLayout(row2)
|
||||
|
||||
custom_box.setLayout(custom_layout)
|
||||
layout.addWidget(custom_box)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_minutes(self):
|
||||
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
|
||||
idx = self.button_group.checkedId()
|
||||
if 0 <= idx < len(WORK_PRESETS):
|
||||
_, wm, lm = WORK_PRESETS[idx]
|
||||
return wm, lm
|
||||
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
|
||||
_, wm, lm, dm = WORK_PRESETS[idx]
|
||||
return wm, lm, dm
|
||||
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
|
||||
self.lunch_spin.value(),
|
||||
self.dinner_spin.value())
|
||||
|
||||
|
||||
class ClockInDetectionPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("⏰ 출근 시간 감지 방식")
|
||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||
self.setTitle(tr('onboarding.detection_title'))
|
||||
self.setSubTitle(tr('onboarding.detection_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.option_boot = QRadioButton("PC 부팅 시간 (기본 — 매일 PC를 끄는 경우)")
|
||||
self.option_unlock = QRadioButton("화면 잠금 해제 시간 (PC를 안 끄고 다니는 경우)")
|
||||
self.option_manual = QRadioButton("수동 입력만 (자동 감지 안 함)")
|
||||
self.option_boot = QRadioButton(tr('onboarding.detection_boot'))
|
||||
self.option_unlock = QRadioButton(tr('onboarding.detection_unlock'))
|
||||
self.option_manual = QRadioButton(tr('onboarding.detection_manual'))
|
||||
self.option_boot.setChecked(True)
|
||||
for opt in (self.option_boot, self.option_unlock, self.option_manual):
|
||||
layout.addWidget(opt)
|
||||
|
||||
info = QLabel(
|
||||
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
)
|
||||
info = QLabel(tr('onboarding.detection_info'))
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #888; padding: 8px;")
|
||||
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
layout.addStretch()
|
||||
@@ -131,35 +150,35 @@ class ClockInDetectionPage(QWizardPage):
|
||||
class LeaveSalaryPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
|
||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||
self.setTitle(tr('onboarding.leave_salary_title'))
|
||||
self.setSubTitle(tr('onboarding.leave_salary_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
# 연차
|
||||
leave_box = QGroupBox("연간 연차")
|
||||
leave_box = QGroupBox(tr('onboarding.leave_group'))
|
||||
leave_layout = QHBoxLayout()
|
||||
self.leave_spin = QSpinBox()
|
||||
self.leave_spin.setRange(0, 30)
|
||||
self.leave_spin.setValue(15)
|
||||
self.leave_spin.setSuffix(" 일")
|
||||
leave_layout.addWidget(QLabel("내 연차:"))
|
||||
self.leave_spin.setSuffix(tr('label.unit_day'))
|
||||
leave_layout.addWidget(QLabel(tr('onboarding.my_leave')))
|
||||
leave_layout.addWidget(self.leave_spin)
|
||||
leave_layout.addStretch()
|
||||
leave_box.setLayout(leave_layout)
|
||||
layout.addWidget(leave_box)
|
||||
|
||||
# 급여 (옵션)
|
||||
salary_box = QGroupBox("급여 추정 (옵션 — 포괄임금이면 비활성)")
|
||||
salary_box = QGroupBox(tr('onboarding.salary_group'))
|
||||
salary_layout = QVBoxLayout()
|
||||
self.salary_enabled = QCheckBox("급여 추정 활성화")
|
||||
self.salary_enabled = QCheckBox(tr('onboarding.salary_enabled'))
|
||||
salary_layout.addWidget(self.salary_enabled)
|
||||
|
||||
wage_row = QHBoxLayout()
|
||||
wage_row.addWidget(QLabel("시급:"))
|
||||
wage_row.addWidget(QLabel(tr('onboarding.hourly_wage')))
|
||||
self.wage_spin = QSpinBox()
|
||||
self.wage_spin.setRange(0, 1000000)
|
||||
self.wage_spin.setSingleStep(1000)
|
||||
self.wage_spin.setSuffix(" 원/시간")
|
||||
self.wage_spin.setSuffix(tr('onboarding.wage_suffix'))
|
||||
self.wage_spin.setValue(0)
|
||||
self.wage_spin.setEnabled(False)
|
||||
wage_row.addWidget(self.wage_spin)
|
||||
@@ -167,11 +186,11 @@ class LeaveSalaryPage(QWizardPage):
|
||||
salary_layout.addLayout(wage_row)
|
||||
|
||||
rate_row = QHBoxLayout()
|
||||
rate_row.addWidget(QLabel("연장수당 가산률:"))
|
||||
rate_row.addWidget(QLabel(tr('onboarding.overtime_rate')))
|
||||
self.rate_combo = QComboBox()
|
||||
self.rate_combo.addItem("1.0배 (가산 없음)", 1.0)
|
||||
self.rate_combo.addItem("1.5배 (한국 노동법 기본)", 1.5)
|
||||
self.rate_combo.addItem("2.0배 (야근/휴일 가산)", 2.0)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_1x'), 1.0)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_1_5x'), 1.5)
|
||||
self.rate_combo.addItem(tr('onboarding.rate_2x'), 2.0)
|
||||
self.rate_combo.setCurrentIndex(1)
|
||||
self.rate_combo.setEnabled(False)
|
||||
rate_row.addWidget(self.rate_combo)
|
||||
@@ -190,30 +209,25 @@ class LeaveSalaryPage(QWizardPage):
|
||||
class DiscordPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("💬 Discord 알림 (선택)")
|
||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||
self.setTitle(tr('onboarding.discord_title'))
|
||||
self.setSubTitle(tr('onboarding.discord_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.enable_check = QCheckBox("Discord 웹훅 알림 사용")
|
||||
self.enable_check = QCheckBox(tr('onboarding.discord_enable'))
|
||||
layout.addWidget(self.enable_check)
|
||||
|
||||
self.url_edit = QLineEdit()
|
||||
self.url_edit.setPlaceholderText("https://discord.com/api/webhooks/...")
|
||||
self.url_edit.setPlaceholderText(tr('onboarding.discord_url_placeholder'))
|
||||
self.url_edit.setEnabled(False)
|
||||
layout.addWidget(self.url_edit)
|
||||
|
||||
guide = QLabel(
|
||||
"셋업 방법:\n"
|
||||
"1. Discord 서버에서 채널 우클릭 → 편집 → 연동 → 웹훅\n"
|
||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||
"3. 위 입력란에 붙여넣기"
|
||||
)
|
||||
guide.setStyleSheet("color: #888; padding: 6px;")
|
||||
guide = QLabel(tr('onboarding.discord_guide'))
|
||||
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||
guide.setWordWrap(True)
|
||||
layout.addWidget(guide)
|
||||
|
||||
test_row = QHBoxLayout()
|
||||
self.test_btn = QPushButton("테스트 메시지 보내기")
|
||||
self.test_btn = QPushButton(tr('onboarding.discord_test'))
|
||||
self.test_btn.setEnabled(False)
|
||||
self.test_btn.clicked.connect(self._test_webhook)
|
||||
test_row.addWidget(self.test_btn)
|
||||
@@ -229,32 +243,30 @@ class DiscordPage(QWizardPage):
|
||||
def _test_webhook(self):
|
||||
url = self.url_edit.text().strip()
|
||||
if not url:
|
||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||
QMessageBox.warning(self, tr('onboarding.discord_url_required_title'), tr('onboarding.discord_url_required_body'))
|
||||
return
|
||||
from utils import discord_webhook
|
||||
if not discord_webhook.is_valid_webhook_url(url):
|
||||
QMessageBox.warning(
|
||||
self, tr('onboarding.discord_url_invalid_title'),
|
||||
tr('onboarding.discord_url_invalid_body')
|
||||
)
|
||||
return
|
||||
ok = discord_webhook.send_test(url)
|
||||
if ok:
|
||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||
QMessageBox.information(self, tr('onboarding.discord_success'), tr('onboarding.discord_success_body'))
|
||||
else:
|
||||
QMessageBox.warning(self, "실패", "전송 실패. URL을 다시 확인해주세요.")
|
||||
QMessageBox.warning(self, tr('onboarding.discord_failed'), tr('onboarding.discord_failed_body'))
|
||||
|
||||
|
||||
class FinishPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🎉 준비 완료!")
|
||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||
self.setTitle(tr('onboarding.finish_title'))
|
||||
self.setSubTitle(tr('onboarding.finish_subtitle'))
|
||||
|
||||
layout = QVBoxLayout()
|
||||
msg = QLabel(
|
||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||
"🕐 단축키:\n"
|
||||
" • Ctrl+O — 출퇴근 토글\n"
|
||||
" • F1 — 도움말\n"
|
||||
" • F5 — 업데이트 확인\n"
|
||||
" • Ctrl+, — 설정"
|
||||
)
|
||||
msg = QLabel(tr('onboarding.finish_msg'))
|
||||
msg.setWordWrap(True)
|
||||
layout.addWidget(msg)
|
||||
self.setLayout(layout)
|
||||
@@ -266,7 +278,7 @@ class OnboardingWizard(QWizard):
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("Clock-out Calculator — 시작 설정")
|
||||
self.setWindowTitle(tr('onboarding.window_title'))
|
||||
self.setMinimumSize(600, 500)
|
||||
self.setWizardStyle(QWizard.ModernStyle)
|
||||
self.setOption(QWizard.NoBackButtonOnStartPage, True)
|
||||
@@ -286,14 +298,15 @@ class OnboardingWizard(QWizard):
|
||||
|
||||
def accept(self):
|
||||
# 1. 근무 패턴
|
||||
wm, lm = self.work_page.selected_minutes()
|
||||
wm, lm, dm = self.work_page.selected_minutes()
|
||||
if wm < 30:
|
||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||
QMessageBox.warning(self, tr('onboarding.input_error_title'), tr('onboarding.work_min_too_small'))
|
||||
return
|
||||
|
||||
settings = {
|
||||
'work_minutes': wm,
|
||||
'lunch_duration_minutes': lm,
|
||||
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
|
||||
'annual_leave_days': self.leave_page.leave_spin.value(),
|
||||
'annual_leave_total': self.leave_page.leave_spin.value(),
|
||||
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
|
||||
@@ -301,6 +314,8 @@ class OnboardingWizard(QWizard):
|
||||
'overtime_rate': self.leave_page.rate_combo.currentData(),
|
||||
'onboarding_completed': True,
|
||||
}
|
||||
if dm > 0:
|
||||
settings['dinner_duration_minutes'] = dm
|
||||
|
||||
# 2. 출근 감지 방식
|
||||
mode = self.detect_page.detection_mode()
|
||||
|
||||
+46
-3
@@ -66,6 +66,8 @@ 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'))
|
||||
@@ -126,7 +128,7 @@ class OvertimeView(QDialog):
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
|
||||
FROM overtime_bank ob
|
||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||
ORDER BY ob.date DESC
|
||||
@@ -138,6 +140,7 @@ 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
|
||||
@@ -148,7 +151,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(39, 174, 96)) # 초록색
|
||||
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
|
||||
|
||||
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
||||
@@ -183,7 +186,7 @@ class OvertimeView(QDialog):
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
|
||||
reason_item = QTableWidgetItem(record[3] or "")
|
||||
|
||||
@@ -249,6 +252,46 @@ 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)
|
||||
|
||||
+14
-13
@@ -9,6 +9,7 @@ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
@@ -18,7 +19,7 @@ class PastRecordDialog(QDialog):
|
||||
def __init__(self, parent=None, date_str: str = ''):
|
||||
super().__init__(parent)
|
||||
self.date_str = date_str
|
||||
self.setWindowTitle(f"기록 추가 — {date_str}")
|
||||
self.setWindowTitle(tr('past_record.dialog_title', date=date_str))
|
||||
self.setModal(True)
|
||||
self.setFixedSize(380, 320)
|
||||
|
||||
@@ -26,13 +27,13 @@ class PastRecordDialog(QDialog):
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
|
||||
info = QLabel(tr('past_record.info', date=date_str))
|
||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 출근
|
||||
ci_row = QHBoxLayout()
|
||||
ci_row.addWidget(QLabel("출근:"))
|
||||
ci_row.addWidget(QLabel(tr('past_record.label_clock_in')))
|
||||
self.clock_in_edit = QTimeEdit()
|
||||
self.clock_in_edit.setDisplayFormat("HH:mm")
|
||||
self.clock_in_edit.setTime(QTime(9, 0))
|
||||
@@ -42,8 +43,8 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 퇴근
|
||||
co_row = QHBoxLayout()
|
||||
co_row.addWidget(QLabel("퇴근:"))
|
||||
self.clock_out_check = QCheckBox("입력")
|
||||
co_row.addWidget(QLabel(tr('past_record.label_clock_out')))
|
||||
self.clock_out_check = QCheckBox(tr('past_record.check_clock_out'))
|
||||
self.clock_out_check.setChecked(True)
|
||||
self.clock_out_edit = QTimeEdit()
|
||||
self.clock_out_edit.setDisplayFormat("HH:mm")
|
||||
@@ -56,18 +57,18 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox("🍱 점심시간 포함")
|
||||
self.lunch_check = QCheckBox(tr('past_record.check_lunch'))
|
||||
self.lunch_check.setChecked(True)
|
||||
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
|
||||
self.dinner_check = QCheckBox(tr('past_record.check_dinner'))
|
||||
meal_row.addWidget(self.lunch_check)
|
||||
meal_row.addWidget(self.dinner_check)
|
||||
meal_row.addStretch()
|
||||
layout.addLayout(meal_row)
|
||||
|
||||
# 메모
|
||||
layout.addWidget(QLabel("메모 (선택):"))
|
||||
layout.addWidget(QLabel(tr('past_record.label_memo')))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("예: 재택근무 / 외근 / 휴가")
|
||||
self.memo_edit.setPlaceholderText(tr('past_record.memo_placeholder'))
|
||||
layout.addWidget(self.memo_edit)
|
||||
|
||||
layout.addStretch()
|
||||
@@ -75,10 +76,10 @@ class PastRecordDialog(QDialog):
|
||||
# 버튼
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
ok_btn = QPushButton("저장")
|
||||
ok_btn = QPushButton(tr('btn.save'))
|
||||
ok_btn.setObjectName("btn_primary")
|
||||
ok_btn.clicked.connect(self._validate_and_accept)
|
||||
cancel_btn = QPushButton("취소")
|
||||
cancel_btn = QPushButton(tr('btn.cancel'))
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_row.addWidget(ok_btn)
|
||||
btn_row.addWidget(cancel_btn)
|
||||
@@ -92,8 +93,8 @@ class PastRecordDialog(QDialog):
|
||||
ci = self.clock_in_edit.time()
|
||||
co = self.clock_out_edit.time()
|
||||
if co <= ci:
|
||||
QMessageBox.warning(self, "입력 오류",
|
||||
"퇴근 시간이 출근 시간보다 빠르거나 같습니다.")
|
||||
QMessageBox.warning(self, tr('past_record.input_error_title'),
|
||||
tr('past_record.input_error_body'))
|
||||
return
|
||||
self.accept()
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
반복 연차 등록/관리 다이얼로그.
|
||||
|
||||
지원: 매주/격주 요일, 매월 N일.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import date
|
||||
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QComboBox, QDateEdit, QSpinBox,
|
||||
QDoubleSpinBox, QLineEdit, QGroupBox,
|
||||
QListWidget, QListWidgetItem, QMessageBox,
|
||||
QCheckBox, QButtonGroup, QRadioButton)
|
||||
from PyQt5.QtCore import QDate, Qt
|
||||
|
||||
from core.recurring_leaves import describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
_KO_WEEKDAYS = [('월', 'mon'), ('화', 'tue'), ('수', 'wed'),
|
||||
('목', 'thu'), ('금', 'fri'), ('토', 'sat'), ('일', 'sun')]
|
||||
|
||||
|
||||
class RecurringLeaveDialog(QDialog):
|
||||
"""반복 연차 패턴 추가/삭제."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('recurring.title'))
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._reload_list()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 기존 패턴 목록
|
||||
list_group = QGroupBox(tr('recurring.list_group'))
|
||||
lg = QVBoxLayout()
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setMinimumHeight(160)
|
||||
lg.addWidget(self.list_widget)
|
||||
del_btn = QPushButton(tr('recurring.btn_delete_selected'))
|
||||
del_btn.clicked.connect(self._delete_selected)
|
||||
lg.addWidget(del_btn)
|
||||
list_group.setLayout(lg)
|
||||
layout.addWidget(list_group)
|
||||
|
||||
# 신규 등록
|
||||
add_group = QGroupBox(tr('recurring.add_group'))
|
||||
ag = QVBoxLayout()
|
||||
|
||||
# 패턴 종류
|
||||
kind_row = QHBoxLayout()
|
||||
kind_row.addWidget(QLabel(tr('recurring.label_cycle')))
|
||||
self.kind_group = QButtonGroup(self)
|
||||
self.rb_weekly = QRadioButton(tr('recurring.weekly'))
|
||||
self.rb_weekly.setChecked(True)
|
||||
self.rb_biweekly = QRadioButton(tr('recurring.biweekly'))
|
||||
self.rb_monthly = QRadioButton(tr('recurring.monthly'))
|
||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||
self.kind_group.addButton(rb)
|
||||
kind_row.addWidget(rb)
|
||||
kind_row.addStretch()
|
||||
ag.addLayout(kind_row)
|
||||
|
||||
# 요일 체크박스 (weekly/biweekly)
|
||||
wd_row = QHBoxLayout()
|
||||
wd_row.addWidget(QLabel(tr('recurring.label_weekday')))
|
||||
self.weekday_checks = []
|
||||
for ko, en in _KO_WEEKDAYS:
|
||||
cb = QCheckBox(tr(f'label.weekday_{en}'))
|
||||
self.weekday_checks.append((cb, en))
|
||||
wd_row.addWidget(cb)
|
||||
wd_row.addStretch()
|
||||
ag.addLayout(wd_row)
|
||||
|
||||
# 매월 N일
|
||||
month_row = QHBoxLayout()
|
||||
month_row.addWidget(QLabel(tr('recurring.label_monthly_day')))
|
||||
self.day_of_month = QSpinBox()
|
||||
self.day_of_month.setRange(1, 31)
|
||||
self.day_of_month.setValue(15)
|
||||
self.day_of_month.setSuffix(tr('recurring.day_suffix'))
|
||||
month_row.addWidget(self.day_of_month)
|
||||
month_row.addStretch()
|
||||
ag.addLayout(month_row)
|
||||
|
||||
# 차감 일수
|
||||
days_row = QHBoxLayout()
|
||||
days_row.addWidget(QLabel(tr('recurring.label_deduction')))
|
||||
self.days_combo = QComboBox()
|
||||
self.days_combo.addItem(tr('recurring.deduction_full'), 1.0)
|
||||
self.days_combo.addItem(tr('recurring.deduction_half'), 0.5)
|
||||
self.days_combo.addItem(tr('recurring.deduction_quarter'), 0.25)
|
||||
days_row.addWidget(self.days_combo)
|
||||
days_row.addStretch()
|
||||
ag.addLayout(days_row)
|
||||
|
||||
# 시작/종료 날짜
|
||||
date_row = QHBoxLayout()
|
||||
date_row.addWidget(QLabel(tr('recurring.label_start')))
|
||||
self.start_edit = QDateEdit()
|
||||
self.start_edit.setDate(QDate.currentDate())
|
||||
self.start_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.start_edit)
|
||||
|
||||
date_row.addWidget(QLabel(tr('recurring.label_end')))
|
||||
self.end_edit = QDateEdit()
|
||||
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||
self.end_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.end_edit)
|
||||
self.no_end_check = QCheckBox(tr('recurring.no_end'))
|
||||
self.no_end_check.toggled.connect(
|
||||
lambda v: self.end_edit.setEnabled(not v)
|
||||
)
|
||||
date_row.addWidget(self.no_end_check)
|
||||
date_row.addStretch()
|
||||
ag.addLayout(date_row)
|
||||
|
||||
# 메모
|
||||
memo_row = QHBoxLayout()
|
||||
memo_row.addWidget(QLabel(tr('recurring.label_memo')))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText(tr('recurring.memo_placeholder'))
|
||||
memo_row.addWidget(self.memo_edit)
|
||||
ag.addLayout(memo_row)
|
||||
|
||||
# 추가 버튼
|
||||
add_btn = QPushButton(tr('recurring.btn_add'))
|
||||
add_btn.setObjectName("btn_primary")
|
||||
add_btn.clicked.connect(self._save)
|
||||
ag.addWidget(add_btn)
|
||||
|
||||
add_group.setLayout(ag)
|
||||
layout.addWidget(add_group)
|
||||
|
||||
# 닫기
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _reload_list(self):
|
||||
self.list_widget.clear()
|
||||
for r in self.db.get_recurring_leaves():
|
||||
desc = describe_pattern(r['pattern'])
|
||||
end = r.get('end_date') or tr('recurring.no_end')
|
||||
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||
f"· {r['start_date']} ~ {end}")
|
||||
if r.get('memo'):
|
||||
text += f" — {r['memo']}"
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, r['id'])
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _delete_selected(self):
|
||||
item = self.list_widget.currentItem()
|
||||
if not item:
|
||||
return
|
||||
rec_id = item.data(Qt.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, tr('recurring.delete_confirm_title'),
|
||||
tr('recurring.delete_confirm_body', item=item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_recurring_leave(rec_id)
|
||||
self._reload_list()
|
||||
|
||||
def _build_pattern(self) -> str | None:
|
||||
if self.rb_monthly.isChecked():
|
||||
return f"monthly:{self.day_of_month.value()}"
|
||||
# weekly/biweekly
|
||||
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
|
||||
if not chosen:
|
||||
return None
|
||||
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
|
||||
return f"{prefix}:" + ",".join(chosen)
|
||||
|
||||
def _save(self):
|
||||
pattern = self._build_pattern()
|
||||
if not pattern:
|
||||
QMessageBox.warning(self, tr('recurring.input_error_title'), tr('recurring.input_error_weekday'))
|
||||
return
|
||||
days = self.days_combo.currentData()
|
||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||
start = self.start_edit.date().toString('yyyy-MM-dd')
|
||||
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
|
||||
memo = self.memo_edit.text().strip()
|
||||
|
||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||
QMessageBox.information(self, tr('recurring.add_done_title'),
|
||||
tr('recurring.add_done_body', pattern=describe_pattern(pattern)))
|
||||
self.memo_edit.clear()
|
||||
self._reload_list()
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
|
||||
|
||||
기능:
|
||||
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
|
||||
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
|
||||
- 반복 패턴 관리 → RecurringLeaveDialog
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import List
|
||||
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QCalendarWidget, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QMenu,
|
||||
QGroupBox, QSplitter, QWidget)
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# 색상 팔레트
|
||||
_C_HOLIDAY = QColor("#e53935") # 빨강
|
||||
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
|
||||
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
|
||||
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
|
||||
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
|
||||
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
|
||||
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
|
||||
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
|
||||
|
||||
|
||||
class ScheduleView(QDialog):
|
||||
"""월간 통합 스케줄 다이얼로그."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle(tr('schedule.title'))
|
||||
self.setMinimumSize(820, 560)
|
||||
self._build_ui()
|
||||
self._reload()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 상단 툴바
|
||||
bar = QHBoxLayout()
|
||||
title = QLabel(tr('schedule.header'))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
bar.addWidget(title)
|
||||
bar.addStretch()
|
||||
|
||||
rec_btn = QPushButton(tr('schedule.btn_recurring'))
|
||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||
bar.addWidget(rec_btn)
|
||||
|
||||
add_btn = QPushButton(tr('schedule.btn_add_leave'))
|
||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||
bar.addWidget(add_btn)
|
||||
|
||||
layout.addLayout(bar)
|
||||
|
||||
# 범례
|
||||
legend = QHBoxLayout()
|
||||
for label, color in [(tr('schedule.legend_holiday'), _C_HOLIDAY),
|
||||
(tr('schedule.legend_leave_used'), _C_LEAVE_FULL_PAST),
|
||||
(tr('schedule.legend_leave_planned'), _C_LEAVE_FULL_PLAN),
|
||||
(tr('schedule.legend_half'), _C_LEAVE_HALF_PAST),
|
||||
(tr('schedule.legend_recurring'), _C_RECURRING)]:
|
||||
sw = QLabel(f" {label} ")
|
||||
sw.setStyleSheet(
|
||||
f"background-color: {color.name()}; color: white; "
|
||||
f"padding: 2px 6px; border-radius: 3px;"
|
||||
)
|
||||
legend.addWidget(sw)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
|
||||
# 캘린더 + 상세 splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 좌측: 캘린더
|
||||
self.calendar = QCalendarWidget()
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self._on_date_click)
|
||||
self.calendar.currentPageChanged.connect(self._on_page_change)
|
||||
splitter.addWidget(self.calendar)
|
||||
|
||||
# 우측: 상세 패널
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
|
||||
self.detail_title = QLabel(tr('schedule.detail_placeholder'))
|
||||
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
right_layout.addWidget(self.detail_title)
|
||||
|
||||
self.detail_list = QListWidget()
|
||||
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
|
||||
right_layout.addWidget(self.detail_list, 1)
|
||||
|
||||
right.setLayout(right_layout)
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([520, 280])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
close_btn = QPushButton(tr('btn.close'))
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# ------------------------------------------------------------- reload
|
||||
|
||||
def _reload(self):
|
||||
"""현재 화면 월에 대해 색상/리스트 갱신."""
|
||||
# 모든 날짜 포맷 초기화
|
||||
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
|
||||
|
||||
y = self.calendar.yearShown()
|
||||
m = self.calendar.monthShown()
|
||||
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
|
||||
first = date(y, m, 1)
|
||||
if m == 12:
|
||||
last = date(y + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
last = date(y, m + 1, 1) - timedelta(days=1)
|
||||
view_start = first - timedelta(days=7)
|
||||
view_end = last + timedelta(days=7)
|
||||
|
||||
# 휴일
|
||||
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
|
||||
view_end.isoformat()) \
|
||||
if hasattr(self.db, 'get_holidays_in_range') else []
|
||||
if not holidays:
|
||||
holidays = self._fallback_holidays(view_start, view_end)
|
||||
|
||||
for h in holidays:
|
||||
d = self._parse_date(h.get('date'))
|
||||
if d is None:
|
||||
continue
|
||||
self._paint(d, _C_HOLIDAY, fg='white')
|
||||
|
||||
# 연차 (구체)
|
||||
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
|
||||
view_end.isoformat())
|
||||
today = date.today()
|
||||
for r in leaves:
|
||||
d = self._parse_date(r.get('date'))
|
||||
if d is None:
|
||||
continue
|
||||
days = float(r.get('days') or 0)
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
|
||||
else:
|
||||
if days >= 1.0:
|
||||
color = _C_LEAVE_FULL_PAST
|
||||
elif days >= 0.5:
|
||||
color = _C_LEAVE_HALF_PAST
|
||||
else:
|
||||
color = _C_LEAVE_QUART_PAST
|
||||
self._paint(d, color, fg='white')
|
||||
|
||||
# 반복 패턴 인스턴스
|
||||
recurring = self.db.get_recurring_leaves()
|
||||
for occ in expand_for_range(recurring, view_start, view_end):
|
||||
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
|
||||
existing = self.calendar.dateTextFormat(
|
||||
QDate(occ.date.year, occ.date.month, occ.date.day))
|
||||
if existing.background() != QBrush():
|
||||
continue
|
||||
self._paint(occ.date, _C_RECURRING, fg='white')
|
||||
|
||||
def _paint(self, d: date, color: QColor, fg: str = 'white'):
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setBackground(QBrush(color))
|
||||
fmt.setForeground(QBrush(QColor(fg)))
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
|
||||
# ------------------------------------------------------------- events
|
||||
|
||||
def _on_date_click(self, qd: QDate):
|
||||
d = date(qd.year(), qd.month(), qd.day())
|
||||
date_str = d.isoformat()
|
||||
weekday_kr = [tr('label.weekday_mon'), tr('label.weekday_tue'), tr('label.weekday_wed'),
|
||||
tr('label.weekday_thu'), tr('label.weekday_fri'), tr('label.weekday_sat'), tr('label.weekday_sun')]
|
||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}{tr('schedule.weekday_suffix')})")
|
||||
self.detail_list.clear()
|
||||
|
||||
# 휴일
|
||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||
if holiday:
|
||||
item = QListWidgetItem(tr('schedule.holiday', name=holiday.get('name', tr('label.holiday_default'))))
|
||||
item.setForeground(QBrush(QColor("#e53935")))
|
||||
self.detail_list.addItem(item)
|
||||
elif d.weekday() in (5, 6):
|
||||
item = QListWidgetItem(tr('schedule.weekend', weekday=weekday_kr[d.weekday()]))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 연차 (구체)
|
||||
for r in self.db.get_leave_records_by_date(date_str):
|
||||
days = float(r.get('days') or 0)
|
||||
t = r.get('leave_type', '연차')
|
||||
memo = r.get('memo') or ''
|
||||
label = tr('schedule.leave_label', type=t, days=days)
|
||||
if memo:
|
||||
label += f" — {memo}"
|
||||
label += f" [id={r['id']}]"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, ('concrete', r['id']))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 반복 패턴 매치
|
||||
recurring = self.db.get_recurring_leaves(active_on=date_str)
|
||||
from core.recurring_leaves import expand_for_date
|
||||
for occ in expand_for_date(recurring, d):
|
||||
item = QListWidgetItem(
|
||||
tr('schedule.recurring_item', pattern=describe_pattern(occ.pattern),
|
||||
days=occ.days, type=occ.leave_type)
|
||||
)
|
||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
if self.detail_list.count() == 0:
|
||||
self.detail_list.addItem(tr('schedule.no_events'))
|
||||
|
||||
def _on_page_change(self, year: int, month: int):
|
||||
self._reload()
|
||||
|
||||
def _on_list_menu(self, pos):
|
||||
item = self.detail_list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
data = item.data(Qt.UserRole)
|
||||
if not data:
|
||||
return
|
||||
kind, _id = data
|
||||
menu = QMenu(self)
|
||||
del_act = menu.addAction(tr('schedule.delete'))
|
||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._delete_record(kind, _id)
|
||||
|
||||
def _delete_record(self, kind: str, _id: int):
|
||||
if kind == 'concrete':
|
||||
reply = QMessageBox.question(
|
||||
self, tr('schedule.delete_leave_confirm_title'),
|
||||
tr('schedule.delete_leave_confirm_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_leave_record(_id)
|
||||
self._reload()
|
||||
# 상세 갱신
|
||||
d = self.calendar.selectedDate()
|
||||
self._on_date_click(d)
|
||||
elif kind == 'recurring':
|
||||
reply = QMessageBox.question(
|
||||
self, tr('schedule.delete_recurring_confirm_title'),
|
||||
tr('schedule.delete_recurring_confirm_body'),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_recurring_leave(_id)
|
||||
self._reload()
|
||||
d = self.calendar.selectedDate()
|
||||
self._on_date_click(d)
|
||||
|
||||
def _open_recurring_dialog(self):
|
||||
from ui.recurring_leave_dialog import RecurringLeaveDialog
|
||||
dlg = RecurringLeaveDialog(self, self.db)
|
||||
dlg.exec_()
|
||||
self._reload()
|
||||
|
||||
def _open_add_leave_dialog(self):
|
||||
from ui.leave_view import AddLeaveDialog
|
||||
dlg = AddLeaveDialog(self, self.db)
|
||||
# 선택된 날짜로 기본값 설정
|
||||
d = self.calendar.selectedDate()
|
||||
if d.isValid():
|
||||
dlg.date_edit.setDate(d)
|
||||
if dlg.exec_() == dlg.Accepted:
|
||||
self._reload()
|
||||
self._on_date_click(d)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
|
||||
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
|
||||
if not hasattr(self.db, 'get_holiday'):
|
||||
return []
|
||||
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
|
||||
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
|
||||
out = []
|
||||
cur = view_start
|
||||
while cur <= view_end:
|
||||
h = self.db.get_holiday(cur.isoformat())
|
||||
if h:
|
||||
out.append(h)
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
+294
-184
File diff suppressed because it is too large
Load Diff
+152
-100
@@ -2,7 +2,8 @@
|
||||
통계 대시보드 - 주간/월간 통계
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
|
||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
|
||||
QFrame)
|
||||
from PyQt5.QtCore import Qt
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
@@ -12,6 +13,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.database import Database
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||
transparent_label, tc,
|
||||
)
|
||||
|
||||
|
||||
class StatsView(QDialog):
|
||||
@@ -22,72 +27,91 @@ class StatsView(QDialog):
|
||||
self.db = db if db else Database()
|
||||
self.init_ui()
|
||||
self.load_stats()
|
||||
apply_dark_titlebar(self)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('window.stats'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(420, 350)
|
||||
self.setMinimumSize(720, 600)
|
||||
self.resize(900, 720)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 14)
|
||||
|
||||
title = QLabel(tr('stats.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
# 다크 톤 타이틀
|
||||
title = QLabel(f"{tr('stats.title')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
|
||||
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
|
||||
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
|
||||
layout.addWidget(tabs)
|
||||
# 도전과제용 탭 진입 카운터
|
||||
tabs.currentChanged.connect(self._on_tab_changed)
|
||||
self._on_tab_changed(0)
|
||||
layout.addWidget(tabs, 1)
|
||||
|
||||
# 닫기 버튼 — 우측 정렬
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setMinimumWidth(100)
|
||||
close_button.setStyleSheet(button_qss('ghost'))
|
||||
close_button.clicked.connect(self.close)
|
||||
layout.addWidget(close_button)
|
||||
btn_row.addWidget(close_button)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_tab_changed(self, idx: int) -> None:
|
||||
"""탭별 진입 카운터 (도전과제 시스템용). 실패는 silent."""
|
||||
keys = ['stat_weekly_view_count', 'stat_monthly_view_count',
|
||||
'stat_pattern_view_count']
|
||||
if 0 <= idx < len(keys):
|
||||
try:
|
||||
cur = self.db.get_setting_int(keys[idx], 0)
|
||||
self.db.set_setting(keys[idx], str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_weekly_tab(self) -> QWidget:
|
||||
"""주간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
summary_group = QGroupBox(tr('stats.weekly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.weekly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
||||
theme='blue', icon='clock')
|
||||
self.weekly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_week'),
|
||||
theme='cyan', icon='calendar')
|
||||
self.weekly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_week'),
|
||||
theme='green', icon='chart')
|
||||
self.weekly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_week'),
|
||||
theme='gold', icon='flame')
|
||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||
self.weekly_avg_card, self.weekly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
layout.addLayout(cards_row)
|
||||
|
||||
self.weekly_total_hours = QLabel("0")
|
||||
self.weekly_total_hours.setObjectName("stat_value")
|
||||
self.weekly_work_days = QLabel("0")
|
||||
self.weekly_work_days.setObjectName("stat_value")
|
||||
self.weekly_avg_hours = QLabel("0")
|
||||
self.weekly_avg_hours.setObjectName("stat_value")
|
||||
self.weekly_overtime = QLabel("0")
|
||||
self.weekly_overtime.setObjectName("stat_value")
|
||||
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
||||
summary_layout.addWidget(self.weekly_total_hours, 0, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
||||
summary_layout.addWidget(self.weekly_work_days, 1, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
||||
summary_layout.addWidget(self.weekly_avg_hours, 2, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
||||
summary_layout.addWidget(self.weekly_overtime, 3, 1)
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
|
||||
# 주간 차트 (일별 근무시간)
|
||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.weekly_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.weekly_chart, 1)
|
||||
chart_card = build_section_card(tr('stats.daily_work_hours'), self.weekly_chart,
|
||||
theme='gray', icon='trending-up')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@@ -95,40 +119,36 @@ class StatsView(QDialog):
|
||||
def create_monthly_tab(self) -> QWidget:
|
||||
"""월간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
# 추정 급여 카드 (옵션 활성 시)
|
||||
# 카드 4개
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.monthly_total_card = build_stat_card(tr('stats.total_work_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
||||
theme='blue', icon='clock')
|
||||
self.monthly_days_card = build_stat_card(tr('stats.card_work_days'), tr('stats.value_days', days=0), tr('stats.this_month'),
|
||||
theme='cyan', icon='calendar')
|
||||
self.monthly_avg_card = build_stat_card(tr('stats.card_avg_hours'), tr('stats.value_hours', hours=0), tr('stats.this_month'),
|
||||
theme='green', icon='chart')
|
||||
self.monthly_ot_card = build_stat_card(tr('stats.card_overtime'), tr('stats.value_hours_minutes', hours=0, minutes=0), tr('stats.this_month'),
|
||||
theme='gold', icon='flame')
|
||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||
self.monthly_avg_card, self.monthly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
layout.addLayout(cards_row)
|
||||
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
|
||||
self.salary_label.setStyleSheet(
|
||||
f"background: rgba(81, 207, 102, 0.12); "
|
||||
f"border: 1px solid {tc('green')}; border-radius: 8px; "
|
||||
f"color: {tc('green')}; font-weight: bold; "
|
||||
f"padding: 10px 14px; font-size: 11pt;"
|
||||
)
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
summary_group = QGroupBox(tr('stats.monthly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.monthly_total_hours = QLabel("0")
|
||||
self.monthly_total_hours.setObjectName("stat_value")
|
||||
self.monthly_work_days = QLabel("0")
|
||||
self.monthly_work_days.setObjectName("stat_value")
|
||||
self.monthly_avg_hours = QLabel("0")
|
||||
self.monthly_avg_hours.setObjectName("stat_value")
|
||||
self.monthly_overtime = QLabel("0")
|
||||
self.monthly_overtime.setObjectName("stat_value")
|
||||
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
||||
summary_layout.addWidget(self.monthly_total_hours, 0, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
||||
summary_layout.addWidget(self.monthly_work_days, 1, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
||||
summary_layout.addWidget(self.monthly_avg_hours, 2, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
||||
summary_layout.addWidget(self.monthly_overtime, 3, 1)
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
layout.addWidget(self.salary_label)
|
||||
|
||||
# 목표 진행률
|
||||
@@ -139,7 +159,9 @@ class StatsView(QDialog):
|
||||
# 월간 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.monthly_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.monthly_chart, 1)
|
||||
chart_card = build_section_card(tr('stats.weekday_avg'), self.monthly_chart,
|
||||
theme='gray', icon='chart')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@@ -147,46 +169,71 @@ class StatsView(QDialog):
|
||||
def create_pattern_tab(self) -> QWidget:
|
||||
"""패턴 분석 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
pattern_group = QGroupBox(tr('stats.pattern_insights'))
|
||||
pattern_layout = QVBoxLayout()
|
||||
pattern_layout.setSpacing(4)
|
||||
pattern_layout.setContentsMargins(8, 20, 8, 6)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
# 패턴 텍스트 카드
|
||||
self.pattern_text = QLabel(tr('stats.analyzing'))
|
||||
self.pattern_text.setWordWrap(True)
|
||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
|
||||
pattern_layout.addWidget(self.pattern_text)
|
||||
|
||||
pattern_group.setLayout(pattern_layout)
|
||||
layout.addWidget(pattern_group)
|
||||
self.pattern_text.setStyleSheet(
|
||||
f"font-size: 11pt; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(build_section_card(tr('stats.pattern_insights'), self.pattern_text,
|
||||
theme='cyan', icon='search'))
|
||||
|
||||
# 출근 시각 분포 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.clock_in_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.clock_in_chart, 1)
|
||||
layout.addWidget(build_section_card(tr('stats.clock_in_distribution'), self.clock_in_chart,
|
||||
theme='gray', icon='clock'), 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _set_card_value(self, card, value: str) -> None:
|
||||
"""build_stat_card로 만든 카드의 큰 숫자 라벨 업데이트.
|
||||
|
||||
카드 구조: QFrame > QHBoxLayout > [icon QLabel] [text QVBoxLayout > title, value, subtitle]
|
||||
value는 두 번째 QLabel.
|
||||
"""
|
||||
# text_box는 outer hbox의 마지막 layout
|
||||
outer = card.layout()
|
||||
if outer is None or outer.count() == 0:
|
||||
return
|
||||
# text_box 찾기 (마지막 item, layout)
|
||||
text_item = outer.itemAt(outer.count() - 1)
|
||||
text_box = text_item.layout() if text_item else None
|
||||
if text_box is None or text_box.count() < 2:
|
||||
return
|
||||
val_lbl = text_box.itemAt(1).widget() # 두 번째가 큰 숫자
|
||||
if val_lbl is None:
|
||||
return
|
||||
# 큰 숫자 RichText 형식 유지
|
||||
from ui.dark_components import CARD_THEMES
|
||||
# tier color는 카드 자체에 알 방법이 없으니 기본 골드 톤
|
||||
val_lbl.setText(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ffd24a;'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
|
||||
def load_stats(self):
|
||||
"""통계 로드"""
|
||||
# 주간 통계
|
||||
weekly_stats = self.db.get_weekly_stats()
|
||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 0)}일")
|
||||
self._set_card_value(self.weekly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||
self._set_card_value(self.weekly_days_card, tr('stats.value_days', days=weekly_stats.get('work_days', 0)))
|
||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||
self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간")
|
||||
self._set_card_value(self.weekly_avg_card, tr('stats.value_hours', hours=f"{avg_hours:.1f}"))
|
||||
|
||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
self.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
||||
self._set_card_value(self.weekly_ot_card, tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
||||
|
||||
# 주간 차트
|
||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||
@@ -196,26 +243,29 @@ class StatsView(QDialog):
|
||||
(today - _td(days=6)).isoformat(), today.isoformat()
|
||||
)
|
||||
if hasattr(self, 'weekly_chart'):
|
||||
# 도전과제 chart_hover 감지를 위해 db 참조 attach
|
||||
self.weekly_chart._achievement_db = self.db
|
||||
draw_daily_hours(self.weekly_chart, week_records)
|
||||
|
||||
# 월간 통계
|
||||
now = datetime.now()
|
||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||
self.monthly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
self._set_card_value(self.monthly_total_card, tr('stats.value_hours', hours=f"{total_hours:.1f}"))
|
||||
work_days = monthly_stats.get('work_days', 0) or 0
|
||||
self.monthly_work_days.setText(f"{work_days}일")
|
||||
self._set_card_value(self.monthly_days_card, tr('stats.value_days', days=work_days))
|
||||
|
||||
if work_days > 0:
|
||||
avg = total_hours / work_days
|
||||
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
|
||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=f"{avg:.1f}"))
|
||||
else:
|
||||
self.monthly_avg_hours.setText("0시간")
|
||||
self._set_card_value(self.monthly_avg_card, tr('stats.value_hours', hours=0))
|
||||
|
||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
||||
self._set_card_value(self.monthly_ot_card,
|
||||
tr('stats.value_hours_minutes', hours=overtime_hours, minutes=overtime_mins))
|
||||
|
||||
# 월간 차트 (요일별 평균)
|
||||
if hasattr(self, 'monthly_chart'):
|
||||
@@ -250,8 +300,10 @@ class StatsView(QDialog):
|
||||
from core.salary import estimate_pay, format_won
|
||||
result = estimate_pay(records, wage, rate)
|
||||
self.salary_label.setText(
|
||||
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
||||
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
||||
tr('stats.salary_estimate',
|
||||
total=format_won(result['total']),
|
||||
base=format_won(result['base']),
|
||||
overtime=format_won(result['overtime']))
|
||||
)
|
||||
self.salary_label.setVisible(True)
|
||||
|
||||
@@ -284,7 +336,7 @@ class StatsView(QDialog):
|
||||
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
||||
avg_hour = int(avg_minutes // 60)
|
||||
avg_min = int(avg_minutes % 60)
|
||||
insights.append(f"📌 평균 출근시간: {avg_hour:02d}:{avg_min:02d}")
|
||||
insights.append(tr('stats.avg_clock_in', time=f"{avg_hour:02d}:{avg_min:02d}"))
|
||||
|
||||
# 연장근무 빈도
|
||||
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
||||
@@ -292,14 +344,14 @@ class StatsView(QDialog):
|
||||
|
||||
if total_days > 0:
|
||||
overtime_rate = (overtime_days / total_days) * 100
|
||||
insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)")
|
||||
insights.append(tr('stats.overtime_frequency', rate=f"{overtime_rate:.0f}", days=overtime_days, total=total_days))
|
||||
|
||||
# 가장 긴 근무일
|
||||
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
||||
if records_with_hours:
|
||||
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
||||
if longest_work.get('total_hours', 0) > 0:
|
||||
insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)")
|
||||
insights.append(tr('stats.longest_work', date=longest_work['date'], hours=f"{longest_work['total_hours']:.1f}"))
|
||||
|
||||
# 건강 경고
|
||||
recent_records = records[-7:] # 최근 7일
|
||||
@@ -314,15 +366,15 @@ class StatsView(QDialog):
|
||||
consecutive_overtime = 0
|
||||
|
||||
if max_consecutive >= 3:
|
||||
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!")
|
||||
insights.append(tr('stats.consecutive_ot_warning', days=max_consecutive))
|
||||
|
||||
# 주 52시간 체크
|
||||
if len(recent_records) >= 7:
|
||||
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
||||
if week_total > 52:
|
||||
insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간")
|
||||
insights.append(tr('stats.weekly_52_exceeded', hours=f"{week_total:.1f}"))
|
||||
|
||||
self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.")
|
||||
self.pattern_text.setText("\n\n".join(insights) if insights else tr('stats.no_pattern_data'))
|
||||
|
||||
|
||||
# 테스트 코드
|
||||
|
||||
+181
-101
@@ -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', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||
]:
|
||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||
if not os.path.exists(path):
|
||||
@@ -78,6 +78,9 @@ LIGHT_COLORS = {
|
||||
'bg_primary': '#F5F5F7',
|
||||
'bg_secondary': '#FFFFFF',
|
||||
'bg_tertiary': '#EDEDF0',
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#E2E3E7',
|
||||
'surface_pressed': '#D5D6DB',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#1A1A2E',
|
||||
'text_secondary': '#4A4A68',
|
||||
@@ -85,9 +88,15 @@ 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',
|
||||
@@ -120,40 +129,58 @@ LIGHT_COLORS = {
|
||||
}
|
||||
|
||||
DARK_COLORS = {
|
||||
'bg_primary': '#111118',
|
||||
'bg_secondary': '#1C1C2E',
|
||||
'bg_tertiary': '#282842',
|
||||
'text_primary': '#ECECF4',
|
||||
'text_secondary': '#B0B0C8',
|
||||
'text_tertiary': '#808098',
|
||||
# 배경 계층 — 모던 다크 (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',
|
||||
'text_inverse': '#FFFFFF',
|
||||
'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',
|
||||
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||
'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',
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +219,7 @@ QMainWindow, QDialog {{
|
||||
}}
|
||||
|
||||
QWidget {{
|
||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
||||
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@@ -206,14 +233,14 @@ QWidget#central_widget {{
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QLabel#app_title {{
|
||||
font-size: 12pt;
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#date_label {{
|
||||
font-size: 9pt;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_secondary']};
|
||||
padding-bottom: 4px;
|
||||
}}
|
||||
@@ -221,7 +248,7 @@ QLabel#date_label {{
|
||||
QLabel#section_title {{
|
||||
font-size: 9.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QLabel#field_label {{
|
||||
@@ -229,29 +256,30 @@ QLabel#field_label {{
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||
QLabel#time_value {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 11pt;
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||
QLabel#time_display {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 22pt;
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
}}
|
||||
|
||||
QLabel#expected_time {{
|
||||
font-size: 10pt;
|
||||
font-size: 11.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 4px;
|
||||
color: {c['text_secondary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#dialog_title {{
|
||||
@@ -295,7 +323,7 @@ QLabel#badge_overtime {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_overtime_bg']};
|
||||
color: {c['badge_overtime_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_leave {{
|
||||
@@ -306,7 +334,7 @@ QLabel#badge_leave {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_leave_bg']};
|
||||
color: {c['badge_leave_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_total {{
|
||||
@@ -317,7 +345,7 @@ QLabel#badge_total {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_balance {{
|
||||
@@ -326,7 +354,7 @@ QLabel#badge_balance {{
|
||||
padding: 10px;
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_success {{
|
||||
@@ -335,7 +363,7 @@ QLabel#badge_success {{
|
||||
padding: 8px;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@@ -355,9 +383,9 @@ QLabel#separator {{
|
||||
QGroupBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
padding-top: 28px;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
@@ -378,52 +406,55 @@ QGroupBox::title {{
|
||||
버튼
|
||||
════════════════════════════════════════ */
|
||||
|
||||
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||
QPushButton {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
background: {c['border_default']};
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background: {c['border_subtle']};
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background: {c['bg_tertiary']};
|
||||
background: {c['bg_secondary']};
|
||||
color: {c['text_tertiary']};
|
||||
border-color: {c['border_subtle']};
|
||||
}}
|
||||
|
||||
QPushButton:checked {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
border-color: {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 (primary action) */
|
||||
QPushButton:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||
QPushButton#clock_out_button {{
|
||||
background: {c['accent_success']};
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
padding: 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:hover {{
|
||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:pressed {{
|
||||
background: {'#0C8F63' if not is_dark else '#28A87A'};
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 주요 액션 버튼 */
|
||||
@@ -435,11 +466,11 @@ QPushButton#btn_primary {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:hover {{
|
||||
background: {c['accent_primary']}DD;
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:pressed {{
|
||||
background: {c['accent_primary']}BB;
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 위험 버튼 */
|
||||
@@ -450,11 +481,11 @@ QPushButton#btn_danger {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:hover {{
|
||||
background: {c['accent_danger']}DD;
|
||||
background: {c['accent_danger_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:pressed {{
|
||||
background: {c['accent_danger']}BB;
|
||||
background: {c['accent_danger_pressed']};
|
||||
}}
|
||||
|
||||
/* 성공 버튼 */
|
||||
@@ -465,25 +496,44 @@ QPushButton#btn_success {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:hover {{
|
||||
background: {c['accent_success']}DD;
|
||||
background: {c['accent_success_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:pressed {{
|
||||
background: {c['accent_success']}BB;
|
||||
background: {c['accent_success_pressed']};
|
||||
}}
|
||||
|
||||
/* 작은 버튼 */
|
||||
/* 작은 버튼 — 미묘한 표면 */
|
||||
QPushButton#btn_small {{
|
||||
font-size: 8.5pt;
|
||||
padding: 5px 10px;
|
||||
padding: 6px 10px;
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:hover {{
|
||||
background: {c['accent_primary']}20;
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:pressed {{
|
||||
background: {c['accent_primary']}35;
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */
|
||||
QPushButton#nav_btn {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 4px;
|
||||
font-size: 8.5pt;
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:hover {{
|
||||
background: {c['surface_hover']};
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:pressed {{
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
|
||||
QLineEdit, QTextEdit, QComboBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
@@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
|
||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
min-height: 20px;
|
||||
}}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 7px;
|
||||
}}
|
||||
|
||||
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 27px 5px 7px;
|
||||
border: 1px solid {c['border_focus']};
|
||||
}}
|
||||
|
||||
/* 비활성 입력 필드 */
|
||||
@@ -563,13 +609,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: 4px;
|
||||
border-top-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||
subcontrol-position: bottom right;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||
@@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
|
||||
QProgressBar {{
|
||||
border: none;
|
||||
background: {c['progress_bg']};
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
max-height: 6px;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
font-size: 0px;
|
||||
}}
|
||||
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
||||
border-radius: 4px;
|
||||
background: {c['progress_start']};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@@ -648,7 +694,7 @@ QProgressBar::chunk {{
|
||||
QTableWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
gridline-color: {c['border_subtle']};
|
||||
color: {c['text_primary']};
|
||||
font-size: 9pt;
|
||||
@@ -667,23 +713,47 @@ 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: 6px;
|
||||
border-radius: 8px;
|
||||
background: {c['bg_secondary']};
|
||||
top: -1px;
|
||||
}}
|
||||
@@ -694,8 +764,8 @@ QTabBar::tab {{
|
||||
padding: 8px 20px;
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 2px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
@@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
||||
QCalendarWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
@@ -902,7 +972,7 @@ QToolTip {{
|
||||
QMenu {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@@ -916,6 +986,16 @@ 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;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+19
-13
@@ -7,6 +7,8 @@ from __future__ import annotations
|
||||
from PyQt5.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
|
||||
|
||||
class TodaySummaryCard(QFrame):
|
||||
"""퇴근 처리 직후 표시되는 요약 카드."""
|
||||
@@ -16,12 +18,12 @@ class TodaySummaryCard(QFrame):
|
||||
self.setObjectName("today_summary_card")
|
||||
self.setStyleSheet("""
|
||||
QFrame#today_summary_card {
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||
background-color: rgba(81, 207, 102, 0.08);
|
||||
border: 1px solid rgba(81, 207, 102, 0.40);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
QLabel { padding: 1px; }
|
||||
QLabel { padding: 1px; background: transparent; border: none; }
|
||||
""")
|
||||
self.setVisible(False)
|
||||
|
||||
@@ -30,7 +32,7 @@ class TodaySummaryCard(QFrame):
|
||||
layout.setSpacing(2)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("📋 오늘의 요약")
|
||||
title = QLabel(tr('today.title'))
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@@ -43,9 +45,9 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
self.total_label = QLabel("")
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
layout.addWidget(self.total_label)
|
||||
layout.addWidget(self.detail_label)
|
||||
@@ -55,33 +57,37 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
def show_summary(self, total_hours: float, lunch_minutes: int,
|
||||
break_minutes: int, overtime_actual: int,
|
||||
overtime_earned: int, salary_text: str = "") -> None:
|
||||
overtime_earned: int, salary_text: str = "",
|
||||
dinner_minutes: int = 0) -> None:
|
||||
"""카드 내용 채우고 표시.
|
||||
|
||||
Args:
|
||||
total_hours: 총 근무시간(시간)
|
||||
lunch_minutes: 점심 시간(분)
|
||||
break_minutes: 외출 시간(분)
|
||||
break_minutes: 외출 시간(분, 식사 제외)
|
||||
overtime_actual: 실제 연장근무(분)
|
||||
overtime_earned: 적립 연장근무(분)
|
||||
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
||||
dinner_minutes: 저녁 시간(분), 0이면 표시 안 함
|
||||
"""
|
||||
h = int(total_hours)
|
||||
m = int((total_hours - h) * 60)
|
||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
||||
self.total_label.setText(tr('today.total_work', hours=h, minutes=m))
|
||||
|
||||
details = []
|
||||
if lunch_minutes > 0:
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
details.append(tr('today.detail_lunch', minutes=lunch_minutes))
|
||||
if dinner_minutes > 0:
|
||||
details.append(tr('today.detail_dinner', minutes=dinner_minutes))
|
||||
if break_minutes > 0:
|
||||
details.append(f"외출 {break_minutes}분")
|
||||
details.append(tr('today.detail_break', minutes=break_minutes))
|
||||
if overtime_actual > 0:
|
||||
details.append(f"연장 {overtime_actual}분 → 적립 {overtime_earned}분")
|
||||
details.append(tr('today.detail_overtime', actual=overtime_actual, earned=overtime_earned))
|
||||
self.detail_label.setText(" · ".join(details) if details else "")
|
||||
self.detail_label.setVisible(bool(details))
|
||||
|
||||
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)
|
||||
|
||||
+88
-15
@@ -13,6 +13,9 @@ 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
|
||||
@@ -21,9 +24,30 @@ 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':
|
||||
@@ -62,28 +86,65 @@ def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def replace_file(new_path: Path, target_path: Path) -> Path | None:
|
||||
def replace_file(new_path: Path, target_path: Path,
|
||||
max_retries: int = 5) -> Path | None:
|
||||
"""target을 .bak으로 백업하고 new를 target 위치로 이동.
|
||||
|
||||
Windows에서 메인 앱 종료 직후에도 OS가 EXE 핸들을 잠시 유지하는 경우가 있어
|
||||
(특히 안티바이러스 스캔/Defender Real-Time Protection) 즉시 move가 실패할 수
|
||||
있음. 지수 backoff로 재시도 — 0.3, 0.6, 1.2, 2.4, 4.8초 (총 ~9초).
|
||||
|
||||
Returns:
|
||||
백업 파일 경로 (롤백용). 실패 시 None.
|
||||
백업 파일 경로 (롤백용). 모든 재시도 실패 시 None.
|
||||
"""
|
||||
backup = target_path.with_suffix(target_path.suffix + '.bak')
|
||||
try:
|
||||
last_err: Exception | None = None
|
||||
|
||||
# 1단계: 기존 .bak 정리 (실패해도 진행 — 새 .bak 이름이 다르면 무관)
|
||||
if backup.exists():
|
||||
try:
|
||||
backup.unlink()
|
||||
if target_path.exists():
|
||||
except OSError as e:
|
||||
_log(f"[updater] old backup unlink failed (continuing): {e}")
|
||||
|
||||
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
||||
for attempt in range(max_retries):
|
||||
if not target_path.exists():
|
||||
break # 첫 설치 등 — target 없으면 그냥 다음 단계로
|
||||
try:
|
||||
shutil.move(str(target_path), str(backup))
|
||||
break
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
# 모든 재시도 실패
|
||||
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||
return None
|
||||
|
||||
# 3단계: new → target 이동
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
shutil.move(str(new_path), str(target_path))
|
||||
return backup
|
||||
except OSError as e:
|
||||
print(f"[updater] replace failed: {e}", file=sys.stderr)
|
||||
# 롤백 시도
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
|
||||
# new 이동 실패 → backup으로 롤백 시도
|
||||
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
|
||||
if backup.exists() and not target_path.exists():
|
||||
try:
|
||||
shutil.move(str(backup), str(target_path))
|
||||
except OSError:
|
||||
pass
|
||||
_log("[updater] rolled back from backup")
|
||||
except OSError as e:
|
||||
_log(f"[updater] rollback also failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -91,13 +152,20 @@ 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
|
||||
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
|
||||
close_fds=True,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen([str(exe_path)], close_fds=True)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"[updater] launch failed: {e}", file=sys.stderr)
|
||||
_log(f"[updater] launch failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -109,29 +177,34 @@ 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():
|
||||
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
|
||||
_log(f"[updater] new exe not found: {new_exe}")
|
||||
return 2
|
||||
|
||||
if not wait_for_exit(args.pid, timeout_sec=30):
|
||||
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
|
||||
_log(f"[updater] timeout waiting for PID {args.pid}")
|
||||
return 3
|
||||
|
||||
# Windows 파일 핸들 해제 시간 여유
|
||||
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
||||
# 짧은 grace period — 이후 replace_file 자체가 재시도 backoff 내장.
|
||||
time.sleep(0.5)
|
||||
|
||||
backup = replace_file(new_exe, target_exe)
|
||||
if backup is None:
|
||||
_log("[updater] replace_file failed — aborting")
|
||||
return 4
|
||||
|
||||
if args.no_launch:
|
||||
_log("[updater] --no-launch set, exiting after replace")
|
||||
return 0
|
||||
|
||||
if not launch(target_exe):
|
||||
# 시작 실패 시 롤백
|
||||
_log("[updater] launch failed — rolling back")
|
||||
try:
|
||||
target_exe.unlink()
|
||||
shutil.move(str(backup), str(target_exe))
|
||||
@@ -140,7 +213,7 @@ def main() -> int:
|
||||
pass
|
||||
return 5
|
||||
|
||||
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
|
||||
_log("[updater] update complete, new app launched")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ exe = EXE(
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
|
||||
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
||||
+4
-2
@@ -71,5 +71,7 @@ def _rotate(directory: Path, keep: int) -> None:
|
||||
for old in files[keep:]:
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
# 회전 실패 시 로그만 — 다음 실행에 재시도. 누적 시 디스크 압박 가능.
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"backup rotation failed for {old}: {e}")
|
||||
|
||||
+59
-4
@@ -20,24 +20,79 @@ USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.4'
|
||||
|
||||
|
||||
def install_global_handler(db, app_version: str = 'unknown') -> None:
|
||||
"""sys.excepthook 등록. db는 crash_log 저장용."""
|
||||
"""sys.excepthook 등록. db는 crash_log 저장용.
|
||||
|
||||
각 단계(log → dialog → 파일 fallback → original hook)는 모두 독립 try로 감싸
|
||||
하나가 실패해도 다음 단계가 동작. 가장 최후 fallback은 stderr + 로컬 파일.
|
||||
"""
|
||||
original = sys.excepthook
|
||||
|
||||
def hook(exc_type, exc_value, exc_tb):
|
||||
if exc_type is KeyboardInterrupt:
|
||||
original(exc_type, exc_value, exc_tb)
|
||||
return
|
||||
|
||||
# 1단계: traceback 직렬화 (실패하면 minimal fallback)
|
||||
try:
|
||||
tb_str = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
|
||||
_log_crash(db, exc_type.__name__, str(exc_value), tb_str, app_version)
|
||||
_show_dialog(db, exc_type.__name__, str(exc_value), tb_str, app_version)
|
||||
except Exception:
|
||||
pass # 후킹 자체 실패는 silent
|
||||
tb_str = f"{exc_type}: {exc_value} (traceback formatting failed)"
|
||||
|
||||
type_name = getattr(exc_type, '__name__', str(exc_type))
|
||||
msg = str(exc_value)
|
||||
|
||||
# 2단계: DB 로깅 (DB 잠금/디스크 풀 등으로 실패 가능 — 단계 분리)
|
||||
log_ok = False
|
||||
try:
|
||||
_log_crash(db, type_name, msg, tb_str, app_version)
|
||||
log_ok = True
|
||||
except Exception as log_err:
|
||||
_fallback_to_file(type_name, msg, tb_str, app_version,
|
||||
f"DB log failed: {log_err}")
|
||||
|
||||
# 3단계: 다이얼로그 (PyQt 미초기화/이미 종료 등으로 실패 가능)
|
||||
try:
|
||||
_show_dialog(db, type_name, msg, tb_str, app_version)
|
||||
except Exception as dlg_err:
|
||||
# 다이얼로그 실패 시 stderr + 파일에 기록 (DB 로깅도 실패했으면 이게 유일한 흔적)
|
||||
if not log_ok:
|
||||
_fallback_to_file(type_name, msg, tb_str, app_version,
|
||||
f"DB+dialog both failed; dialog err: {dlg_err}")
|
||||
try:
|
||||
print(f"\n[CRASH] {type_name}: {msg}\n{tb_str}", file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4단계: 원래 hook도 호출 (콘솔 출력 + 종료)
|
||||
try:
|
||||
original(exc_type, exc_value, exc_tb)
|
||||
except Exception:
|
||||
pass # 마지막 단계라 더 할 게 없음
|
||||
|
||||
sys.excepthook = hook
|
||||
|
||||
|
||||
def _fallback_to_file(exc_type: str, message: str, tb: str,
|
||||
version: str, reason: str) -> None:
|
||||
"""DB 로깅이 실패한 경우 ~/.clockout_logs/crashes.log에 append.
|
||||
|
||||
Best-effort — 파일 쓰기 실패도 silent (이 시점엔 뭐 할 게 없음).
|
||||
"""
|
||||
try:
|
||||
from pathlib import Path
|
||||
log_dir = Path.home() / '.clockout_logs'
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / 'crashes.log'
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(
|
||||
f"\n=== {datetime.now().isoformat(timespec='seconds')} ===\n"
|
||||
f"version={version} reason={reason}\n"
|
||||
f"{exc_type}: {message}\n{tb}\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_crash(db, exc_type: str, message: str, tb: str, version: str) -> None:
|
||||
"""crash_log 테이블에 기록."""
|
||||
try:
|
||||
|
||||
+50
-8
@@ -11,12 +11,15 @@ class CSVExporter:
|
||||
"""CSV 내보내기 클래스"""
|
||||
|
||||
@staticmethod
|
||||
def export_work_records(records: List[Dict], filename: str = None) -> str:
|
||||
def export_work_records(records: List[Dict], filename: str = None,
|
||||
db=None) -> str:
|
||||
"""
|
||||
근무 기록을 CSV로 내보내기
|
||||
근무 기록을 CSV로 내보내기 (사람이 읽는 한글 헤더 + 사용/미사용 표기).
|
||||
|
||||
Args:
|
||||
records: 근무 기록 리스트
|
||||
filename: 저장할 파일명 (None이면 자동 생성)
|
||||
db: 점심/저녁 기본 분(minutes)을 표시용으로 읽을 Database 인스턴스 (옵션)
|
||||
Returns:
|
||||
str: 저장된 파일 경로
|
||||
"""
|
||||
@@ -24,31 +27,70 @@ class CSVExporter:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"work_records_{timestamp}.csv"
|
||||
|
||||
# CSV 파일 작성
|
||||
# 표시용 기본 분 (옵션)
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
|
||||
|
||||
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||
fieldnames = [
|
||||
'날짜', '출근시간', '퇴근시간', '점심시간',
|
||||
'총근무시간', '연장근무(분)', '적립(분)', '메모'
|
||||
'날짜', '출근시간', '퇴근시간',
|
||||
'점심시간', '점심(분)', '저녁시간', '저녁(분)',
|
||||
'총근무시간', '연장근무(분)', '적립(분)', '메모',
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
for record in records:
|
||||
lunch_on = bool(record.get('lunch_break'))
|
||||
dinner_on = bool(record.get('dinner_break'))
|
||||
row = {
|
||||
'날짜': record.get('date', ''),
|
||||
'출근시간': record.get('clock_in', ''),
|
||||
'퇴근시간': record.get('clock_out', '미기록'),
|
||||
'점심시간': '사용' if record.get('lunch_break') else '미사용',
|
||||
'점심시간': '사용' if lunch_on else '미사용',
|
||||
'점심(분)': lunch_default if lunch_on else 0,
|
||||
'저녁시간': '사용' if dinner_on else '미사용',
|
||||
'저녁(분)': dinner_default if dinner_on else 0,
|
||||
'총근무시간': f"{record.get('total_hours', 0):.1f}시간",
|
||||
'연장근무(분)': record.get('overtime_minutes', 0),
|
||||
'적립(분)': record.get('overtime_earned', 0),
|
||||
'메모': record.get('memo', '')
|
||||
'메모': record.get('memo', ''),
|
||||
}
|
||||
writer.writerow(row)
|
||||
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def export_work_records_for_reimport(records: List[Dict], filename: str = None,
|
||||
db=None) -> str:
|
||||
"""csv_importer가 직접 다시 읽을 수 있는 round-trip 포맷으로 내보내기.
|
||||
|
||||
헤더: date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"work_records_reimport_{timestamp}.csv"
|
||||
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60) if db else 60
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60) if db else 60
|
||||
|
||||
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
|
||||
fieldnames = ['date', 'clock_in', 'clock_out',
|
||||
'lunch_minutes', 'dinner_minutes', 'memo']
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for record in records:
|
||||
writer.writerow({
|
||||
'date': record.get('date', ''),
|
||||
'clock_in': record.get('clock_in', ''),
|
||||
'clock_out': record.get('clock_out', '') or '',
|
||||
'lunch_minutes': lunch_default if record.get('lunch_break') else 0,
|
||||
'dinner_minutes': dinner_default if record.get('dinner_break') else 0,
|
||||
'memo': record.get('memo', '') or '',
|
||||
})
|
||||
|
||||
return filename
|
||||
|
||||
@staticmethod
|
||||
def export_overtime_summary(db, filename: str = None) -> str:
|
||||
"""
|
||||
|
||||
+43
-17
@@ -1,14 +1,15 @@
|
||||
"""
|
||||
CSV 가져오기 — 우리 표준 포맷.
|
||||
|
||||
표준 포맷:
|
||||
date,clock_in,clock_out,lunch_minutes,memo
|
||||
2026-04-01,09:00:00,18:30:00,60,"메모"
|
||||
표준 포맷 (v2.7.1+):
|
||||
date,clock_in,clock_out,lunch_minutes,dinner_minutes,memo
|
||||
2026-04-01,09:00:00,18:30:00,60,0,"메모"
|
||||
|
||||
- 헤더 첫 줄 필수
|
||||
- date: YYYY-MM-DD
|
||||
- clock_in/out: HH:MM:SS 또는 HH:MM
|
||||
- clock_in/out: HH:MM:SS 또는 HH:MM (clock_out이 clock_in보다 빠르면 익일로 간주)
|
||||
- lunch_minutes: 정수 (0이면 점심 미포함)
|
||||
- dinner_minutes: 정수 (옵션, 0/누락이면 저녁 미포함)
|
||||
- memo: 선택 (따옴표 가능)
|
||||
|
||||
기존 일자와 충돌 시 import 호출자가 'overwrite'/'skip' 정책 결정.
|
||||
@@ -45,6 +46,20 @@ def parse_csv(path: str) -> List[Dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def _parse_minutes(raw: str, field_name: str) -> int:
|
||||
"""0 이상 정수 파싱. 빈 값이면 0."""
|
||||
s = (raw or '').strip()
|
||||
if not s:
|
||||
return 0
|
||||
try:
|
||||
v = int(s)
|
||||
if v < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"{field_name}는 0 이상 정수여야 함: '{raw}'")
|
||||
return v
|
||||
|
||||
|
||||
def _normalize_row(row: Dict) -> Dict:
|
||||
"""단일 행 검증 + 정규화."""
|
||||
date_str = (row.get('date') or '').strip()
|
||||
@@ -59,15 +74,8 @@ def _normalize_row(row: Dict) -> Dict:
|
||||
co_raw = (row.get('clock_out') or '').strip()
|
||||
co = _normalize_time(co_raw, 'clock_out') if co_raw else None
|
||||
|
||||
lunch = 0
|
||||
lm = (row.get('lunch_minutes') or '').strip()
|
||||
if lm:
|
||||
try:
|
||||
lunch = int(lm)
|
||||
if lunch < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"lunch_minutes는 0 이상 정수여야 함: '{lm}'")
|
||||
lunch = _parse_minutes(row.get('lunch_minutes', ''), 'lunch_minutes')
|
||||
dinner = _parse_minutes(row.get('dinner_minutes', ''), 'dinner_minutes')
|
||||
|
||||
memo = (row.get('memo') or '').strip()
|
||||
return {
|
||||
@@ -75,6 +83,7 @@ def _normalize_row(row: Dict) -> Dict:
|
||||
'clock_in': ci,
|
||||
'clock_out': co,
|
||||
'lunch_minutes': lunch,
|
||||
'dinner_minutes': dinner,
|
||||
'memo': memo,
|
||||
}
|
||||
|
||||
@@ -111,9 +120,11 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
||||
|
||||
added = updated = skipped = 0
|
||||
|
||||
from datetime import timedelta
|
||||
from core.time_calculator import TimeCalculator
|
||||
work_minutes = db.get_work_minutes()
|
||||
lunch_default = db.get_setting_int('lunch_duration_minutes', 60)
|
||||
dinner_default = db.get_setting_int('dinner_duration_minutes', 60)
|
||||
|
||||
for row in rows:
|
||||
existing = db.get_work_record(row['date'])
|
||||
@@ -127,10 +138,14 @@ 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'],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
updated += 1
|
||||
@@ -141,16 +156,27 @@ def import_records(db, rows: List[Dict], on_conflict: str = 'skip') -> Tuple[int
|
||||
if row.get('clock_out'):
|
||||
ci_dt = datetime.strptime(f"{row['date']} {row['clock_in']}", '%Y-%m-%d %H:%M:%S')
|
||||
co_dt = datetime.strptime(f"{row['date']} {row['clock_out']}", '%Y-%m-%d %H:%M:%S')
|
||||
calc = TimeCalculator(work_minutes=work_minutes,
|
||||
lunch_duration_minutes=row.get('lunch_minutes') or lunch_default)
|
||||
include_lunch = (row.get('lunch_minutes') or 0) > 0
|
||||
# 자정 경계: 퇴근이 출근보다 빠르면 익일로 간주
|
||||
if co_dt <= ci_dt:
|
||||
co_dt += timedelta(days=1)
|
||||
lunch_min = row.get('lunch_minutes') or 0
|
||||
dinner_min = row.get('dinner_minutes') or 0
|
||||
calc = TimeCalculator(
|
||||
work_minutes=work_minutes,
|
||||
lunch_duration_minutes=lunch_min or lunch_default,
|
||||
dinner_duration_minutes=dinner_min or dinner_default,
|
||||
)
|
||||
include_lunch = lunch_min > 0
|
||||
include_dinner = dinner_min > 0
|
||||
total = (co_dt - ci_dt).total_seconds() / 3600
|
||||
ot_actual, ot_earned = calc.calculate_overtime(
|
||||
ci_dt, co_dt, include_lunch=include_lunch
|
||||
ci_dt, co_dt, include_lunch=include_lunch, include_dinner=include_dinner,
|
||||
)
|
||||
db.update_clock_out(row['date'], row['clock_out'], total, ot_actual, ot_earned)
|
||||
if include_lunch:
|
||||
db.update_lunch_break(row['date'], True)
|
||||
if include_dinner:
|
||||
db.update_dinner_break(row['date'], True)
|
||||
if ot_earned > 0:
|
||||
db.add_overtime_earned(wid, ot_earned, row['date'])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ URL 1개로 끝. 봇 등록·서버 운영 0. 실패 시 silent (앱 동작 안
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime
|
||||
@@ -12,7 +13,9 @@ from typing import Optional, List
|
||||
|
||||
# Discord/Cloudflare는 Python 기본 UA(Python-urllib/3.x)를 봇으로 차단(error 1010).
|
||||
# 일반 브라우저 UA로 위장해야 통과.
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/2.3'
|
||||
# 버전은 core/version.py에서 동기화.
|
||||
from core.version import __version__
|
||||
USER_AGENT = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ClockOutCalculator/{__version__}'
|
||||
|
||||
# Discord embed 색상 (decimal)
|
||||
COLOR_GREEN = 0x57F287
|
||||
@@ -21,6 +24,23 @@ COLOR_YELLOW = 0xFEE75C
|
||||
COLOR_PINK = 0xEB459E
|
||||
COLOR_ORANGE = 0xED4245
|
||||
|
||||
# Discord 웹훅 URL 패턴: https://(discord.com|discordapp.com|...)/api/webhooks/{ID:digits}/{TOKEN}
|
||||
# canary./ptb. 서브도메인도 허용. PTB 모바일 앱이 종종 그 URL 발급.
|
||||
_WEBHOOK_RE = re.compile(
|
||||
r'^https://(?:(?:canary|ptb)\.)?discord(?:app)?\.com/api/webhooks/\d{17,20}/[\w-]{50,}$'
|
||||
)
|
||||
|
||||
|
||||
def is_valid_webhook_url(url: str) -> bool:
|
||||
"""입력된 URL이 Discord webhook 형식인지 검증.
|
||||
|
||||
형식만 확인 — 실제 도달성·토큰 유효성은 send_test()로 검증해야 함.
|
||||
빈 문자열이나 공백은 False (비활성 상태로 간주).
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
return bool(_WEBHOOK_RE.match(url.strip()))
|
||||
|
||||
|
||||
def send(webhook_url: str, title: str, description: str,
|
||||
color: int = COLOR_BLUE, fields: Optional[List[dict]] = None,
|
||||
@@ -37,7 +57,7 @@ def send(webhook_url: str, title: str, description: str,
|
||||
Returns:
|
||||
성공 시 True. URL 비었거나 네트워크/4xx/5xx 시 False.
|
||||
"""
|
||||
if not webhook_url or not webhook_url.startswith('https://'):
|
||||
if not is_valid_webhook_url(webhook_url):
|
||||
return False
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""번들 폰트(NanumSquare) 로딩.
|
||||
|
||||
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
|
||||
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
|
||||
등록 실패 시 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5.QtGui import QFontDatabase, QFont
|
||||
|
||||
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
|
||||
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
|
||||
_FONT_FILES = [
|
||||
'NanumSquareL.ttf',
|
||||
'NanumSquareR.ttf',
|
||||
'NanumSquareB.ttf',
|
||||
'NanumSquareEB.ttf',
|
||||
'NanumSquare_acR.ttf',
|
||||
'NanumSquare_acB.ttf',
|
||||
]
|
||||
|
||||
|
||||
def _font_dir() -> str:
|
||||
"""번들 font/ 디렉토리 절대 경로."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
|
||||
else:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(base, 'font')
|
||||
|
||||
|
||||
def load_bundled_fonts() -> list:
|
||||
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
|
||||
families: list = []
|
||||
fdir = _font_dir()
|
||||
if not os.path.isdir(fdir):
|
||||
return families
|
||||
for name in _FONT_FILES:
|
||||
path = os.path.join(fdir, name)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
fid = QFontDatabase.addApplicationFont(path)
|
||||
if fid == -1:
|
||||
continue
|
||||
for fam in QFontDatabase.applicationFontFamilies(fid):
|
||||
if fam not in families:
|
||||
families.append(fam)
|
||||
return families
|
||||
|
||||
|
||||
def _pick_primary(families: list) -> str:
|
||||
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
|
||||
if 'NanumSquare' in families:
|
||||
return 'NanumSquare'
|
||||
for fam in families:
|
||||
low = fam.lower()
|
||||
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
|
||||
return fam
|
||||
return 'Malgun Gothic'
|
||||
|
||||
|
||||
def apply_app_font(app, point_size: int = 9) -> str:
|
||||
"""앱 전역 기본 폰트를 NanumSquare로 설정.
|
||||
|
||||
Returns:
|
||||
실제 적용된 primary family 이름 (폴백 시 'Malgun Gothic').
|
||||
"""
|
||||
families = load_bundled_fonts()
|
||||
primary = _pick_primary(families)
|
||||
font = QFont(primary, point_size)
|
||||
font.setStyleStrategy(QFont.PreferAntialias)
|
||||
app.setFont(font)
|
||||
return primary
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
_app = QApplication(sys.argv)
|
||||
fams = load_bundled_fonts()
|
||||
print('font dir:', _font_dir())
|
||||
print('registered families:', fams)
|
||||
print('picked primary:', _pick_primary(fams))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
공공데이터포털 — 한국천문연구원 특일정보 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
|
||||
+52
-35
@@ -57,58 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
|
||||
return QIcon(pixmap)
|
||||
|
||||
def setup_menu(self):
|
||||
"""트레이 메뉴 설정"""
|
||||
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
|
||||
menu = QMenu()
|
||||
|
||||
show_action = QAction(tr('tray.open'), self)
|
||||
show_action.triggered.connect(self.show_window)
|
||||
menu.addAction(show_action)
|
||||
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
|
||||
self._icon_actions = []
|
||||
|
||||
mini_action = QAction(tr('tray.mini_widget'), self)
|
||||
mini_action.triggered.connect(self._open_mini_widget)
|
||||
menu.addAction(mini_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')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
|
||||
clock_out_action.triggered.connect(self.quick_clock_out)
|
||||
menu.addAction(clock_out_action)
|
||||
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
stats_action = QAction("📊 " + tr('menu.stats'), self)
|
||||
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
|
||||
menu.addAction(stats_action)
|
||||
|
||||
cal_action = QAction("📅 " + tr('menu.calendar'), self)
|
||||
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
||||
menu.addAction(cal_action)
|
||||
|
||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
||||
menu.addAction(help_action)
|
||||
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')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
quit_action = QAction(tr('tray.quit'), self)
|
||||
quit_action.triggered.connect(self.quit_app)
|
||||
menu.addAction(quit_action)
|
||||
add(tr('tray.quit'), self.quit_app)
|
||||
|
||||
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,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
|
||||
|
||||
pid = os.getpid()
|
||||
try:
|
||||
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
|
||||
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
|
||||
# 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
|
||||
subprocess.Popen(
|
||||
[str(updater_exe),
|
||||
'--pid', str(pid),
|
||||
|
||||
Reference in New Issue
Block a user