commit bedbb1e9ec4e3f1a997e55b8f4a6baa048e6514a Author: KINDNICK Date: Thu Apr 30 12:54:40 2026 +0900 Initial release v2.2.0 핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의) - Windows 이벤트 뷰어 자동 출퇴근 감지 - 30분 단위 연장근무 적립/사용 시스템 - 1.0/0.5/0.25일 연차·반차·반반차 - 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출 - 한국 공휴일 자동 등록 (음력 포함, holidays 패키지) - matplotlib 차트 기반 주간/월간/패턴 통계 - 미니 위젯 + 시스템 트레이 통합 - 한국어/English i18n - 자가 업데이트 (updater.exe + Gitea Releases) 아키텍처: - core/ (db, time_calculator, notifier, i18n, version, settings_keys) - ui/ (main_window + 9 dialogs + 3 controllers) - utils/ (backup, lock_detector, debug_log, updater_client, time_format) - tests/ (66 pytest 단위) + 통합/i18n GUI 검증 CI/CD: - .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트 - .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..10298df --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: pytest unit tests + env: + QT_QPA_PLATFORM: offscreen + run: pytest tests -v + + - name: integration tests + run: python _integration_test.py + + - name: i18n GUI tests + env: + QT_QPA_PLATFORM: offscreen + run: python _i18n_gui_test.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..98dd81d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +on: + push: + tags: + - 'v*' # v1.0.0, v2.1.0 등 태그 push 시 트리거 + +jobs: + build-and-release: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + PyInstaller + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Run unit tests + env: + QT_QPA_PLATFORM: offscreen + run: | + pip install pytest + pytest tests -q + + - name: Run integration tests + run: python _integration_test.py + + - name: Build main.exe + run: python -m PyInstaller --clean main.spec + + - name: Build updater.exe + run: python -m PyInstaller --clean updater.spec + + - name: Verify both exe exist + run: | + if (-not (Test-Path dist/main.exe)) { throw "main.exe missing" } + if (-not (Test-Path dist/updater.exe)) { throw "updater.exe missing" } + + - name: Create release archive + run: | + New-Item -ItemType Directory -Path dist/release -Force + Copy-Item dist/main.exe dist/release/ + Copy-Item dist/updater.exe dist/release/ + Compress-Archive -Path dist/release/* -DestinationPath dist/ClockOutCalculator.zip -Force + + - name: Publish Release (Gitea) + uses: actions/release-action@main + with: + api_key: ${{ secrets.RELEASE_TOKEN }} + files: | + dist/main.exe + dist/updater.exe + dist/ClockOutCalculator.zip + + # GitHub Actions 호환 fallback (Gitea Actions에서 동작 안 할 경우) + # 위 release-action이 실패하면 아래를 활용: + # + # - name: Publish Release (manual via API) + # shell: pwsh + # env: + # GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + # GITEA_HOST: https://kindnick-git.duckdns.org + # OWNER: kindnick + # REPO: Clock_out_Time_Calculator + # run: | + # $tag = $env:GITHUB_REF -replace 'refs/tags/', '' + # $body = @{ tag_name = $tag; name = $tag; draft = $false } | ConvertTo-Json + # $headers = @{ Authorization = "token $env:GITEA_TOKEN" } + # $release = Invoke-RestMethod -Uri "$env:GITEA_HOST/api/v1/repos/$env:OWNER/$env:REPO/releases" ` + # -Method Post -Headers $headers -ContentType 'application/json' -Body $body + # $uploadUrl = "$env:GITEA_HOST/api/v1/repos/$env:OWNER/$env:REPO/releases/$($release.id)/assets" + # foreach ($f in @('dist/main.exe', 'dist/updater.exe')) { + # $name = [System.IO.Path]::GetFileName($f) + # Invoke-RestMethod -Uri "$uploadUrl?name=$name" -Method Post ` + # -Headers $headers -InFile $f -ContentType 'application/octet-stream' + # } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d8a543 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~\AppData\Local\pip\Cache + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run integration tests + run: python _integration_test.py + + - name: Run i18n GUI tests (offscreen) + env: + QT_QPA_PLATFORM: offscreen + run: python _i18n_gui_test.py + + - name: Run pytest (if tests/ exists) + env: + QT_QPA_PLATFORM: offscreen + run: | + if (Test-Path tests) { pytest tests --cov=core --cov=utils } + + build: + needs: test + runs-on: windows-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + PyInstaller + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build exe + run: python -m PyInstaller --clean main.spec + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ClockOutCalculator-exe + path: dist/main.exe + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2427b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# PyInstaller 빌드 산출물 (CI에서 자동 빌드되므로 저장소엔 포함 X) +build/ +dist/ +*.spec.bak + +# Python 캐시 +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# 가상환경 +venv/ +env/ +ENV/ +.venv/ + +# 사용자 데이터베이스 (개인 정보 — 절대 커밋 금지) +*.db +!database.example.db +work_records.db +database.db +test_settings*.db + +# 로그 +*.log +~/.clockout_logs/ +shutdown_debug.log +debug.log + +# Backup +.clockout_backups/ +*.bak + +# IDE / Agent metadata +.vscode/ +.idea/ +.claude/ +.opencode/ +*.swp +*.swo +.DS_Store + +# pytest / coverage +.pytest_cache/ +.coverage +htmlcov/ +*.cover +.cache + +# 환경변수 +.env +.env.local +settings.local.json + +# 임시 파일 +*.tmp +nul +analysis/ +download_resources.py +test_settings.db +test_settings2.db + +# Old PyInstaller spec files (legacy) +ClockOutCalculator.spec +퇴근시간계산기.spec diff --git a/3d-alarm.ico b/3d-alarm.ico new file mode 100644 index 0000000..c72083d Binary files /dev/null and b/3d-alarm.ico differ diff --git a/3d-alarm.png b/3d-alarm.png new file mode 100644 index 0000000..6ef2f82 Binary files /dev/null and b/3d-alarm.png differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6fedf4f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# Project Conventions and Operational Gotchas + +## 🛠️ Setup & Execution +- **Dependencies:** `pip install -r requirements.txt`. Optional: `pip install anthropic` for AI insight feature. +- **Run:** `python main.py` +- **Module-level tests:** + - Event monitoring: `python core/event_monitor.py` + - Time calculation: `python core/time_calculator.py` +- **Integration tests:** `python _integration_test.py` (35 scenarios), `python _i18n_gui_test.py` (5 ko/en GUI), `python _gui_smoke_test.py` (8 widget). All should be green before release. + +## 🗄️ Architecture Notes (Core Business Logic) +- **8 SQLite tables** in `database.db`: `work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`. Migrations in `init_database()` ALTER existing DBs. +- **`work_records.date` UNIQUE** — one row per workday. +- **Overtime tracking:** earned (bank) and used (usage) tables separate. Both have NULLable `work_record_id` for manual entries — never filter them out. +- **Time representation:** `TimeCalculator.work_minutes` is the canonical attribute (int). `work_hours` is a property for compatibility. UI/DB sync `WORK_MINUTES ↔ WORK_HOURS` automatically (floor on minutes→hours). +- **Settings:** all keys defined in `core/settings_keys.py`. Import constants — never use raw strings. `get_setting()` returns string; use `get_setting_int/float/bool()` helpers or read from `get_settings()` dict (already typed). +- **i18n:** `tr('key', **kwargs)` and `tr_html('help.html.X')`. Sentences use format placeholders. Language switch requires restart for full effect. + +## ⚠️ 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. Hot-path caching +`update_display()` runs at 1Hz. Any DB call inside this method must be cached (see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format`). Periodic checks (health/weekly notifications) are gated by `now.minute % 5 == 0`. + +### 3. Time format separation +24-hour `datetime` for ALL internal calculation. 12-hour conversion happens only in `MainWindow.format_time()` (adds Korean "오전"/"오후" markers when applicable). + +### 4. Workday boundary +`workday_boundary_hour` (default 6). Overnight work stays on the previous day's record until that hour. Don't naively use `date.today()` in time logic without considering this rollover. + +### 5. Migration idempotency +All `migrate_*` methods must early-return if already applied. Use sentinel keys (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) — without them, every startup runs the migration query. + +## ⚙️ Build Process + +```bash +python -m PyInstaller --clean main.spec +``` + +- Output: `dist/main.exe` (console disabled, UPX compressed) +- `main.spec` includes icon (`3d-alarm.ico`), data file (`3d-alarm.png`) +- **Stale `dist/main.exe` running** → `PermissionError`. Kill it first. +- **Optional packages** (`holidays`, `anthropic`) only baked in if installed in build env. + +## 🚦 External Integrations + +- **Anthropic Claude API** (optional): `core/ai_analysis.py`. Without `anthropic` package or API key, falls back to `static_summary()`. Don't crash the stats view. +- **HTTP API** (`utils/http_api.py`): bound to `127.0.0.1:17389` only — never expose externally. Read-only by design. +- **Cloud sync via `db_path_override`**: settings stores DB path; main.py + main_window.py both bootstrap with default DB to read this key, then reopen with override path. Don't break the bootstrap order. +- **`holidays` package**: `add_korean_holidays_auto()` returns `-1` if package missing → UI falls back to `add_korean_holidays()` (8 fixed dates). + +## 🐞 Past Incidents + +- **+29h remaining time bug** (Phase 1): caused by `break_minutes -= overtime_used`. Fixed by subtracting `total_time_off` AFTER `calculate_remaining_time` call. Never re-introduce. +- **Manual overtime invisible**: `overtime_view` previously filtered `work_record_id IS NOT NULL`. Manual additions (no work_record) were hidden. Show all rows; label NULL rows as "수동 추가" / "Manual". +- **`annual_leave_total` vs `annual_leave_days`**: two keys for the same value. UI used `_days`, internal methods used `_total`. Now auto-synced in `save_settings()`. If you add a method reading either, also handle the sibling. +- **Banker's rounding**: `round(450/60)` = 8 in Python (round-half-even). Use `int(value) // 60` (floor) for hours derivation when consistency matters. + +## 🌐 i18n Coverage Status + +- **Fully translated**: window titles, menus, buttons, group boxes, mini widget, tray menu, all 6 notifications, HelpView 6 tabs, settings_view core labels, stats_view labels. +- **Partially translated**: settings_view sub-labels (입력란 placeholder 등), calendar_view detail labels. +- **Not yet translated**: dialog inner labels in break_view/overtime_view/leave_view. Window titles for these dialogs ARE translated. + +Adding new translations: add key to `_DICT['ko']` AND `_DICT['en']`, replace literal with `tr('key')`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eb230ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +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.2.0] — 2026-04-30 + +### Added +- **자동 업데이트** — Gitea Releases 기반 + - 시작 5초 후 백그라운드 버전 체크 (silent) + - F5 또는 설정 → 데이터 관리 → "업데이트 확인" 버튼으로 수동 트리거 + - 새 버전 발견 시 다운로드 → `updater.exe`가 메인 종료 대기 → 파일 교체 → 재시작 + - 실패 시 .bak 자동 롤백 +- **`core/version.py`** — `__version__` 상수 +- **`updater.py` + `updater.spec`** — 독립 자가 업데이터 (Python 표준 라이브러리만, 6MB) +- **`utils/updater_client.py`** — Gitea/GitHub 호환 Releases API 클라이언트 +- **Gitea Actions 워크플로** — `.gitea/workflows/{ci,release}.yml` + - `v*` 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 + ZIP 패키징 + +### Changed +- `Settings → 데이터 관리`에 "버전 표시 + 업데이트 확인" 추가 +- `main.spec` datas에 안내 주석 (updater.exe는 별도 배포) + +## [2.1.0] — 2026-04-29 + +### Removed +- **Claude AI 분석 기능** 제거 — 외부 API 의존성 정리 +- **로컬 HTTP API** 제거 — 외부 위젯 연동 기능 미사용으로 정리 +- **pandas 의존성** 제거 — 코드에서 미사용 (PyInstaller 빌드 사이즈 감소) + +### Added +- **첫 잠금 해제 = 출근 옵션** (`clock_in_on_unlock`) — PC를 안 끄는 사용자 케이스 해결 +- **WAL 모드 + busy timeout** — 클라우드 동기화 환경에서 다중 PC 동시 접근 안전성↑ +- **`MainWindow` 컨트롤러 분리** — `LockMonitor` / `AutoLunchManager` / `NotificationOrchestrator` +- **`update_display` 변화 감지** — 직전 값과 동일 시 `setText` 스킵 (1Hz hot-path 부하↓) +- **언어 변경 시 자동 재시작 제안** — 사용자 동의 후 `os.execv`로 재시작 +- **`CHANGELOG.md`** + **GitHub Actions CI** 추가 + +### Changed +- 5분 throttle 알림 (건강/주간/누적) `_last_5min_bucket` 명시 가드 +- `PyInstaller` `excludes`에 `pandas`, `numpy.testing`, `PyQt5.QtWebEngineWidgets` 추가 + +## [2.0.0] — 2026-04-29 + +### Added +- **단축근무 지원**: `work_minutes`(분 단위) + 5종 프리셋 (8h / 7h30m / 7h / 6h / 4h) +- **자동 점심시간** 적용 (출근 4h 경과) +- **DB 자동 백업** (`~/.clockout_backups/`, 7일 회전, SQLite backup API) +- **화면 잠금 자동 외출/복귀** +- **공휴일 자동 등록** (`holidays` 패키지, 음력 명절 포함) +- **시스템 트레이 완전 통합** (점심/외출/통계/캘린더/도움말) +- **미니 위젯** (Always-on-top, 드래그 가능) +- **matplotlib 차트** (일별/요일별 근무시간) +- **건강·주간·누적 알림** (3종 추가) +- **i18n 인프라** (한국어/English, 28 카테고리, HelpView ko/en HTML) +- **설정 키 상수 모듈** (`core/settings_keys.py`) +- **DB 경로 override** (클라우드 동기화 폴더 지정 가능) +- **앱 단축키 7종** (Ctrl+O/L/D/B, F1, Ctrl+R, Ctrl+,) +- **사용 설명 가이드** (HelpView, 6 탭) +- **48 통합 시나리오** + 5 i18n GUI + 8 widget smoke 테스트 + +### Fixed +- **+29h remaining time bug** — `break_minutes`에서 overtime 차감하던 로직 수정 +- **annual_leave_total / annual_leave_days 키 불일치** — 양방향 자동 동기화 + +## [1.0.0] — 2026-01-09 + +- 최초 릴리스: 자동 출퇴근 / 점심 관리 / 30분 단위 연장근무 적립 / 캘린더 / 통계 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b0e6827 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Clock-out Time Calculator** (퇴근시간 계산기) is a Windows desktop app that auto-detects clock-in via Windows Event Log, calculates clock-out time, banks overtime in 30-min units, and tracks leave/breaks. Korean UI by default with English (i18n switchable). + +**Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`, optional `anthropic`. + +Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md). + +## Build and Run + +```bash +pip install -r requirements.txt +python main.py + +# Standalone module tests +python core/event_monitor.py +python core/time_calculator.py + +# Production build → dist/main.exe +python -m PyInstaller --clean main.spec + +# Integration tests (35 + 5 + view scenarios) +python _integration_test.py +python _i18n_gui_test.py +python _gui_smoke_test.py +``` + +PyInstaller fails with `PermissionError` if `dist/main.exe` is running — kill it first. `_integration_test.py` and `_gui_smoke_test.py` are intentionally hidden behind a leading underscore so they don't ship in the build. + +## Architecture + +### Core (`core/`) +- **[database.py](core/database.py)** — SQLite. 8 tables (`work_records`, `overtime_bank`, `overtime_usage`, `leave_records`, `break_records`, `settings`, `achievements`, `holidays`). Runtime migrations (`migrate_*` methods called from `init_database()`) ALTER existing DBs on startup. Helpers: `get_setting_int/float/bool()`, `get_work_minutes()`, `get_consecutive_overtime_days()`, `add_korean_holidays_auto()`. +- **[time_calculator.py](core/time_calculator.py)** — Internal representation is `work_minutes: int`. `work_hours` is a read-only property (compatibility shim for legacy callers / float input). 30-min truncation in `calculate_overtime()`. +- **[event_monitor.py](core/event_monitor.py)** — Reads Win Event IDs 6005 (boot), 4624 (login), 6006 (shutdown). Admin may be required. +- **[notifier.py](core/notifier.py)** — 6 notifications, each gated by setting key (`NOTIF_CLOCK_OUT/LUNCH/OVERTIME/HEALTH`). Texts come from `tr()` for ko/en. +- **[ai_analysis.py](core/ai_analysis.py)** — Optional Claude API integration. `get_insights(records, api_key)`: with key → Claude, without → `static_summary()` fallback. +- **[i18n.py](core/i18n.py)** — `_DICT` (28 categories × 2 languages) + `_HELP_HTML` (6 large HTML blocks for HelpView). API: `tr('key', **kwargs)`, `tr_html('key')`, `set_language('ko'|'en')`. +- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. + +### UI (`ui/`) +- **[main_window.py](ui/main_window.py)** — `update_display()` ticks 1Hz. State: `clock_in_time`, `is_clocked_in`, `lunch_break_enabled`, `dinner_break_enabled`, `is_on_break`, `auto_lunch_applied_today`. Hot-path caches: `_auto_lunch_enabled_cache`, `_today_non_working_cache`. Single-instance via `QLocalServer` named `"ClockOutCalculatorInstance"`. 7 keyboard shortcuts (Ctrl+O/L/D/B/, F1, Ctrl+R). +- **[settings_view.py](ui/settings_view.py)** — Work pattern presets, hour+minute split spinboxes, language combo, DB path override, Claude API key, HTTP API toggle, auto-break toggle. `save_settings()` sends only `WORK_MINUTES` — DB auto-syncs `WORK_HOURS`. +- **[stats_view.py](ui/stats_view.py)** — 3 tabs (weekly/monthly/patterns) with matplotlib charts (`make_chart_widget`, `draw_daily_hours`, `draw_weekday_avg`) and AI insight button. +- **[mini_widget.py](ui/mini_widget.py)** — Always-on-top frameless widget; updated from `update_display()` when visible. +- **[help_view.py](ui/help_view.py)** — 6 tabs sourced from `_HELP_HTML` dict (ko/en). `_TABS` class constant defines (html_key, label_key) pairs. +- Other dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels still mostly Korean (point of incremental i18n extension). +- **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers. Returns `_Fallback` widget if matplotlib missing. + +### Utils (`utils/`) +- **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Once per day, `~/.clockout_backups/database-YYYY-MM-DD.db`, 7-file rotation. Uses `sqlite3.Connection.backup` API for lock-safe copy. +- **[lock_detector.py](utils/lock_detector.py)** — Windows screen-lock detection via `OpenInputDesktop` + `GetUserObjectInformation` (active desktop name != "default" → locked). +- **[http_api.py](utils/http_api.py)** — stdlib `http.server` on `127.0.0.1`, daemon thread. Endpoints: `/status`, `/today`, `/balance`, `/weekly`. Started from `MainWindow.__init__` if `HTTP_API_ENABLED=true`. +- **[debug_log.py](utils/debug_log.py)** — `dlog(...)` env-gated by `CLOCKOUT_DEBUG`. No-op in production. +- **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper. +- **[system_tray.py](utils/system_tray.py)** — Tray icon menu (lunch/break/clock-out/stats/calendar/help/mini-widget/quit), tooltips i18n. + +### Time-off accounting in `update_display()` + +Critical invariant — preserve in any change: +```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) # subtract AFTER, never via break_minutes mutation +``` + +Pass actual `break_minutes` to `calculate_remaining_time`. Overtime/leave usage subtracted as a `timedelta` on the result. Progress bar uses `overtime_used_minutes=total_time_off` keyword arg of `calculate_work_progress`. + +### Workday rollover + +`workday_boundary_hour` setting (default 6). `start_new_workday()` triggers when `is_clocked_in=False` and `clock_in_time.date() != now.date() and now.hour >= boundary`. Overnight work past midnight stays attributed to previous workday until that hour. `auto_clock_out_previous_days()` retroactively closes records using shutdown events (6006). + +## Database invariants + +- `work_records.date` UNIQUE (one row/day). +- `lunch_break`, `dinner_break` are BOOLEAN flags; durations live in `lunch_duration_minutes`/`dinner_duration_minutes` settings. +- `overtime_bank.work_record_id` and `overtime_usage.work_record_id` are NULLable (manual additions / direct usage). DO NOT filter `WHERE work_record_id IS NOT NULL` — those rows render with "수동 추가" / "Manual" label. +- `leave_records.days` is FLOAT (1.0 / 0.5 / 0.25). +- Balance: `SUM(overtime_bank.earned_minutes) - SUM(overtime_usage.used_minutes)` via `get_total_overtime_balance()`. +- Settings dict from `get_settings()` already auto-converts numeric strings to int/float — additional `int(x)` casts in callers are dead code. + +## Settings system + +Stored as string key-value pairs in `settings` table. Always import keys from [core/settings_keys.py](core/settings_keys.py) — typos become ImportError. Defaults set in `init_default_settings()`. Auto-sync in `save_settings()`: +- `WORK_MINUTES ↔ WORK_HOURS` (floor: 450 min → 7 h, not 8 — settings_view sends `WORK_MINUTES` only) +- `ANNUAL_LEAVE_DAYS ↔ ANNUAL_LEAVE_TOTAL` + +Migration sentinels (`balance_adjustment_migrated_v2`, `annual_leave_keys_migrated`) prevent re-running migrations every startup. + +## i18n + +`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then to literal key. `tr_html('help.html.X')` reads `_HELP_HTML` dict. Window titles, menus, buttons, group boxes, tray menu, mini widget, all 6 notification messages, and HelpView tabs are translated. Many deeper dialog labels remain Korean — extending is just adding keys + replacing the literal. + +Language is read from `LANGUAGE` setting at `MainWindow.__init__`. Changing language requires restart for full propagation (existing widget instances keep their original-language text). + +## Conventions and gotchas + +- **`Database.get_setting()` always returns a string (or default).** Use `get_setting_int/float/bool()` helpers or import a key constant. Already-loaded `settings = db.get_settings()` dict returns proper types. +- **Time format:** Internal calc uses 24h `datetime`. UI conversion only in `format_time()` with Korean "오전"/"오후" markers when `time_format=12`. +- **QSS hover colors:** Hex with alpha suffix (`#colorDD`) renders translucent and can hide text. Use solid colors for hover. +- **Hot-path 1 Hz:** `update_display()` runs every second. Don't add un-cached DB calls — see `_auto_lunch_enabled_cache`, `_today_non_working_cache`, `cached_time_format` patterns. Health/weekly checks are gated by `now.minute % 5 == 0` to throttle to 5 min. +- **Bash with spaces:** Repo path contains a space. PowerShell more reliable for stderr capture. +- **Single-instance during dev:** `QLocalServer` blocks a second `python main.py`. Use import-level test or set `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. +- **PyInstaller frozen?** `getattr(sys, 'frozen', False)` and `sys._MEIPASS` for resource path resolution (icon). + +## Tests + +- [_integration_test.py](_integration_test.py) — 35 business-logic scenarios (no Qt). +- [_gui_smoke_test.py](_gui_smoke_test.py) — 8 widget instantiation checks via `QT_QPA_PLATFORM=offscreen`. +- [_i18n_gui_test.py](_i18n_gui_test.py) — 5 ko/en switch verifications on real widgets. + +All three should be green before any release. diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..f9e3502 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,160 @@ +# 설치 가이드 + +## 1. Python 설치 + +Python 3.9 이상이 필요합니다. + +### Windows +1. https://www.python.org/downloads/ 방문 +2. "Download Python 3.x.x" 클릭 +3. 설치 시 **"Add Python to PATH"** 체크 필수! + +확인: +```bash +python --version +``` + +## 2. 프로젝트 다운로드 + +프로젝트를 다운로드하거나 압축을 해제합니다. + +## 3. 패키지 설치 + +프로젝트 폴더에서 명령 프롬프트(cmd) 또는 PowerShell을 엽니다. + +```bash +pip install -r requirements.txt +``` + +### 설치되는 패키지: +1. **PyQt5** - GUI 프레임워크 +2. **pywin32** - Windows API 접근 +3. **python-dateutil** - 날짜 계산 +4. **matplotlib** - 그래프 +5. **plyer** - 알림 시스템 +6. **holidays** - 한국 공휴일 자동 등록 (음력 명절 포함) + +## 4. 리소스 다운로드 (선택) + +리소스가 없어도 프로그램은 작동하지만, 더 예쁜 UI를 위해 다운로드를 권장합니다. + +```bash +python download_resources.py +``` + +이 스크립트는 무료 리소스 다운로드 링크를 안내합니다. + +### 수동 다운로드: + +#### 아이콘 +다음 사이트에서 다운로드: +- **Flaticon**: https://www.flaticon.com/ +- **Material Design Icons**: https://materialdesignicons.com/ +- **Icons8**: https://icons8.com/ + +다운로드 후 `resources/icons/` 폴더에 저장 + +필요한 아이콘: +- `app_icon.ico` (512x512) +- `clock.png`, `timer.png`, `lunch.png` +- `calendar.png`, `statistics.png`, `vacation.png` +- `settings.png`, `notification.png` + +#### 사운드 +다음 사이트에서 다운로드: +- **Mixkit**: https://mixkit.co/free-sound-effects/ (추천) +- **Freesound**: https://freesound.org/ +- **Zapsplat**: https://www.zapsplat.com/ + +다운로드 후 `resources/sounds/` 폴더에 저장 + +필요한 사운드: +- `clock_out_alarm.wav` - 퇴근시간 알림 +- `notification.wav` - 일반 알림 +- `success.wav` - 성공 효과음 + +## 5. 실행 + +```bash +python main.py +``` + +### 관리자 권한으로 실행 (권장) + +Windows 이벤트 뷰어 접근을 위해 관리자 권한이 필요할 수 있습니다. + +1. **방법 1**: cmd를 관리자 권한으로 실행 + - Windows 키 + X + - "명령 프롬프트(관리자)" 또는 "Windows PowerShell(관리자)" 선택 + - 프로젝트 폴더로 이동: `cd "경로"` + - `python main.py` 실행 + +2. **방법 2**: 바로가기 생성 + - `python main.py`를 실행하는 배치 파일(.bat) 생성 + - 우클릭 → 속성 → 고급 → "관리자 권한으로 실행" 체크 + +## 6. 첫 실행 + +1. 프로그램이 실행되면 자동으로 데이터베이스(`database.db`) 생성 +2. Windows 이벤트 뷰어에서 오늘의 부팅 시간 자동 감지 +3. 감지된 시간이 출근시간으로 설정됨 + +## 문제 해결 + +### pywin32 설치 오류 +```bash +pip install --upgrade pywin32 +python -m pywin32_postinstall -install +``` + +### PyQt5 설치 오류 +```bash +pip install --upgrade pip +pip install PyQt5 +``` + +### "DLL load failed" 오류 +Visual C++ Redistributable 설치 필요: +https://aka.ms/vs/17/release/vc_redist.x64.exe + +### 이벤트 뷰어 접근 불가 +- 관리자 권한으로 실행 +- 또는 설정에서 수동 입력 모드 사용 + +## 업그레이드 + +```bash +pip install --upgrade -r requirements.txt +``` + +## 제거 + +1. 프로젝트 폴더 삭제 +2. 패키지 제거 (선택): +```bash +pip uninstall PyQt5 pywin32 python-dateutil pandas matplotlib plyer +``` + +## 프로덕션 빌드 (PyInstaller) + +소스 없이 실행 파일만 배포하려면: +```bash +python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB) +python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB, 자가 업데이터) +``` + +배포 패키지에는 두 .exe를 함께 포함시켜 같은 폴더에 두세요. +자동 업데이트는 main.exe가 같은 폴더의 updater.exe를 호출해야 동작합니다. + +빌드 시 주의: +- `dist/main.exe`가 실행 중이면 `PermissionError` 발생 → 종료 후 재실행 +- `holidays` 등 옵셔널 패키지는 설치된 환경에서 빌드해야 포함됨 + +## 환경 변수 + +- `CLOCKOUT_DEBUG=1` — 디버그 로그를 `~/.clockout_logs/debug.log`로 출력 +- `CLOCKOUT_DEBUG_DIR=경로` — 로그 저장 위치 변경 + +## 다음 단계 + +설치가 완료되었다면 [README.md](README.md)를 참고하여 프로그램을 사용하세요! diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b924e2 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Clock-out Time Calculator ⏰ + +퇴근시간을 자동으로 계산하고 연장근무를 관리하는 Windows용 데스크톱 애플리케이션 + +## 주요 기능 + +### 1. 자동 출퇴근 관리 +- Windows 이벤트 뷰어에서 컴퓨터 부팅/로그인 시간 자동 감지 +- 오늘의 첫 부팅 시간을 출근시간으로 자동 기록 +- 실시간 퇴근까지 남은 시간 카운트다운 (1초 갱신) +- 진행률 프로그레스 바 표시 + +### 2. 다양한 근무 패턴 지원 +- **표준 8시간** (점심 60분) +- **단축근무 7시간 30분** (점심 30분) +- **단축근무 7시간 / 6시간** +- **반일 4시간** (점심 없음) +- **사용자 정의** — 시간/분 5분 단위 자유 입력 +- 점심시간 자동 적용 옵션 (출근 후 4시간 경과) + +### 3. 연장근무 30분 단위 적립 시스템 +- 정규 퇴근 이후 시간을 30분 단위 절삭하여 적립 + - 1h 35m → 1h 30m, 55m → 30m, 29m → 0m +- 적립된 시간을 30분/1시간 단위로 사용 가능 +- 사용 시 그날 퇴근시간 자동 단축 +- 주말·공휴일 근무: 모든 시간이 연장근무로 적립 + +### 4. 연차/반차 관리 +- 1.0일 / 0.5일(반차, 4h) / 0.25일(반반차, 2h) 지원 +- 연간 연차 잔액 자동 계산 +- 단축근무자 자동 환산 (7h30m 근무자 → 1일 = 450분) + +### 5. 외출(자리 비움) 추적 +- 외출 시작/복귀 버튼으로 분 단위 측정 +- **화면 잠금 자동 외출** — PC 잠금 시 자동 시작, 풀리면 복귀 + +### 6. 알림 시스템 +- 퇴근 30분 전 알림 +- 점심시간 미등록 알림 (출근 4시간 후) +- 연장근무 적립 예정 알림 +- 연장근무 누적 20시간 알림 +- **건강 경고** — 3일 이상 연속 연장근무 +- **주 52시간 초과** 경고 +- 각 알림 개별 ON/OFF 가능 + +### 7. 통계·분석 +- 주간/월간 요약 + matplotlib 차트 +- 일별 근무시간 + 연장 누적 막대 그래프 +- 요일별 평균 근무시간 +- 근무 패턴 인사이트 (정적 통계 요약) + +### 8. 공휴일 관리 +- 한국 공휴일 자동 등록 (`holidays` 패키지) + - 양력 + **음력 명절(설날/추석/석가탄신일)** + 임시공휴일 +- 사용자 정의 공휴일 직접 추가 +- 공휴일 근무 시 모든 시간 연장근무 적립 + +### 9. 미니 위젯 + 시스템 트레이 +- Always-on-top 미니 위젯으로 남은 시간 큰 글씨 표시 +- 트레이 메뉴에서 빠른 점심/외출/퇴근/통계/캘린더 접근 + +### 10. 데이터 신뢰성 +- **DB 자동 백업** — 1일 1회 `~/.clockout_backups/`에 회전 (최신 7개 보관) +- **클라우드 동기화** — DB 경로를 OneDrive/Dropbox 폴더로 변경 가능 +- 마이그레이션 sentinel로 1회 실행 보장 + +### 11. 자동 업데이트 +- 시작 시 백그라운드 버전 체크 (Gitea Releases API) +- 새 버전 발견 시 알림 + 사용자 동의 후 자동 다운로드·교체·재시작 +- F5 또는 설정 → 데이터 관리 → "업데이트 확인" 으로 수동 트리거 +- 실패 시 자동 롤백 + +### 12. 다국어 지원 (i18n) +- 한국어 / English 전환 +- 알림 메시지·UI 라벨 28개 카테고리 +- HelpView 6개 탭 ko/en HTML 콘텐츠 + +### 13. 단축키 +- `Ctrl+O` 출/퇴근 토글 +- `Ctrl+L` 점심 토글, `Ctrl+D` 저녁 토글 +- `Ctrl+B` 외출 관리, `Ctrl+,` 설정, `F1` 도움말 +- `F5` 업데이트 확인 +- `Ctrl+R` 일일보고 + +## 설치 + +### 요구사항 +- Windows 10/11 +- Python 3.9+ +- RAM 2GB+ + +### 설치 단계 +```bash +# 1. 저장소 가져오기 (또는 ZIP 압축 해제) + +# 2. 패키지 설치 +pip install -r requirements.txt + +# 3. 실행 +python main.py +``` + +### 필수 패키지 (requirements.txt) +- PyQt5 — GUI +- pywin32 — Windows 이벤트 뷰어 접근 +- python-dateutil — 날짜 처리 +- matplotlib — 그래프 +- plyer — 시스템 알림 +- holidays — 공휴일 자동 등록 (음력 포함) + +## 사용 방법 + +### 첫 실행 +1. 실행 시 오늘의 부팅 시간을 자동 감지하여 출근으로 기록 +2. **설정 → 근무 시간** 에서 본인 근무 패턴 선택 (단축근무자는 프리셋에서) +3. **설정 → 휴가 → 연간 연차** 에서 본인 연차 일수 입력 + +### 단축근무 사용자 (예: 7시간 30분 + 점심 30분) +1. 설정 → 근무 시간 → **근무 패턴** 에서 "단축근무 7시간 30분 (점심 30분)" 선택 +2. 또는 사용자 정의로 시간/분 직접 입력 (5분 단위) +3. 저장 → 즉시 메인 화면 반영 + +### 클라우드 동기화 (여러 PC) +1. OneDrive/Dropbox 폴더 안에 `database.db` 위치 결정 +2. 설정 → 데이터 관리 → DB 경로 → 변경 +3. 기존 DB 파일을 새 위치로 복사 → 재시작 +4. 다른 PC에서도 같은 경로 지정 시 동일 데이터 공유 + +## 데이터 저장 +- SQLite 데이터베이스 (`database.db`, 실행 폴더) +- 자동 백업: `~/.clockout_backups/database-YYYY-MM-DD.db` (7개 회전) + +## 프로덕션 빌드 + +```bash +# 메인 앱 + 자가 업데이터 두 개 빌드 +python -m PyInstaller --clean main.spec # → dist/main.exe (~73MB) +python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB) +``` + +배포 시 두 .exe를 같은 폴더에 둬야 자동 업데이트가 동작합니다. 빌드 시 +`dist/main.exe`가 실행 중이면 PermissionError가 발생 — 종료 후 재실행하세요. + +## 릴리스 (Gitea Actions) + +태그 push로 자동 릴리스: +```bash +# version.py 업데이트 후 +git tag v2.2.0 +git push origin v2.2.0 +``` + +[.gitea/workflows/release.yml](.gitea/workflows/release.yml)이 자동으로: +1. 단위/통합 테스트 실행 +2. main.exe + updater.exe 빌드 +3. ZIP 패키징 +4. Gitea Releases에 첨부 + +⚠️ Gitea Actions 활성화 + `RELEASE_TOKEN` secret(저장소 쓰기 권한) 등록 필요. + +## 주의사항 + +### 관리자 권한 +일부 Windows 이벤트 로그 접근 시 관리자 권한이 필요할 수 있습니다. +자동 감지 실패 시 수동으로 출근시간 입력 가능. + +### 디버그 로그 +`CLOCKOUT_DEBUG=1` 환경변수 설정 시 `~/.clockout_logs/debug.log`에 진단 로그가 기록됩니다. + +## 문제 해결 + +### Q. 프로그램이 실행되지 않아요 +A: `pip install -r requirements.txt` 로 모든 패키지 설치 확인. + +### Q. 출근시간이 자동으로 감지되지 않아요 +A: 관리자 권한으로 실행하거나, 메인 화면 출근시각 옆 편집 아이콘으로 수동 입력. + +### Q. 단축근무인데 7시간 30분 설정이 안 돼요 +A: 설정 → 근무 시간 → 근무 패턴에서 프리셋 선택 또는 시·분 직접 입력 (5분 단위). + +### Q. 데이터가 사라졌어요 +A: `~/.clockout_backups/` 에서 가장 최근 백업을 `database.db` 로 복사. + +### Q. 영어로 사용하고 싶어요 +A: 설정 → 알림 및 표시 → 언어/Language 콤보에서 English 선택 → 저장 → 재시작. + +## 개발자 정보 + +- Version: 2.0.0 +- Tech Stack: Python 3.9+, PyQt5, SQLite, pywin32, matplotlib +- 라이선스: 개인 및 상업적 사용 가능 diff --git a/_gui_smoke_test.py b/_gui_smoke_test.py new file mode 100644 index 0000000..5fa0407 --- /dev/null +++ b/_gui_smoke_test.py @@ -0,0 +1,164 @@ +""" +GUI smoke 테스트 — QApplication을 띄우지 않고 UI 다이얼로그 instantiation만 확인. +실제 .show()는 헤드리스 환경에서 위험하므로 생성 단계까지만 검증. +""" +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Qt platform plugin: offscreen으로 실제 창 안 뜨게 +os.environ['QT_QPA_PLATFORM'] = 'offscreen' + +from PyQt5.QtWidgets import QApplication +app = QApplication.instance() or QApplication(sys.argv) + +PASS, FAIL = [], [] + + +def case(label, fn): + try: + fn() + PASS.append(label) + print(f"[PASS] {label}") + except Exception as e: + FAIL.append((label, f"{type(e).__name__}: {e}")) + print(f"[FAIL] {label}: {type(e).__name__}: {e}") + + +# Fresh DB +from core.database import Database +db_path = os.path.join(tempfile.gettempdir(), 'clockout_gui_test.db') +if os.path.exists(db_path): + os.remove(db_path) +db = Database(db_path) + + +def test_settings_view(): + from ui.settings_view import SettingsView + dlg = SettingsView(db=db) + # 단축근무 7h30m 프리셋 적용 + dlg.work_preset_combo.setCurrentIndex(1) # "단축근무 7시간 30분" + assert dlg.work_hours_spin.value() == 7 + assert dlg.work_minutes_spin.value() == 30 + assert dlg.lunch_spin.value() == 30 + dlg.deleteLater() + + +def test_help_view(): + from ui.help_view import HelpView + dlg = HelpView() + # 6개 탭 존재 + tabs = dlg.findChild(type(dlg).__mro__[0]) + from PyQt5.QtWidgets import QTabWidget + tab_widget = dlg.findChild(QTabWidget) + assert tab_widget is not None + assert tab_widget.count() == 6 + dlg.deleteLater() + + +def test_mini_widget(): + from ui.mini_widget import MiniWidget + w = MiniWidget() + w.update_remaining("01:30:00") + assert w.time_label.text() == "01:30:00" + w.update_remaining("+00:30:00") + assert "추가" in w.title_label.text() + w.deleteLater() + + +def test_chart_widget(): + from ui.chart_widget import make_chart_widget, draw_daily_hours, draw_weekday_avg + w = make_chart_widget() + # 빈 records로도 안전하게 그려져야 + draw_daily_hours(w, []) + # 데이터가 있을 때 + records = [ + {'date': '2026-04-25', 'total_hours': 8.5, 'overtime_minutes': 30}, + {'date': '2026-04-26', 'total_hours': 9.0, 'overtime_minutes': 60}, + ] + draw_daily_hours(w, records) + draw_weekday_avg(w, records) + w.deleteLater() + + +def test_stats_view(): + from ui.stats_view import StatsView + dlg = StatsView(db=db) + # 데이터 없어도 정상 로드 + assert dlg.weekly_total_hours.text() is not None + dlg.deleteLater() + + +def test_calendar_view(): + from ui.calendar_view import CalendarView + dlg = CalendarView(db=db) + dlg.deleteLater() + + +def test_main_window_init(): + """MainWindow 초기화 — 가장 무거운 케이스""" + # QLocalServer 충돌 방지: 프로세스 ID 기반 이름 변경 어려움 → init만 확인 + from ui.main_window import MainWindow + w = MainWindow() + # 기본 상태 + assert w.is_clocked_in == False + assert w.lunch_break_enabled == False + # auto_lunch 캐시 초기 None + assert w._auto_lunch_enabled_cache is None + # 단축키 7개 등록되었는지 + from PyQt5.QtWidgets import QShortcut + shortcuts = w.findChildren(QShortcut) + assert len(shortcuts) >= 7, f"shortcuts: {len(shortcuts)}" + w.deleteLater() + + +def test_settings_save_load_round_trip(): + """설정 저장→로드 라운드트립""" + from ui.settings_view import SettingsView + dlg = SettingsView(db=db) + # 단축근무 프리셋 → 저장 + dlg.work_preset_combo.setCurrentIndex(1) + # save 호출 시 메시지박스가 뜨므로 save_settings 직접 호출 회피 + # 대신 저장 로직과 동일한 dict 만들기 + work_min = dlg.work_hours_spin.value() * 60 + dlg.work_minutes_spin.value() + db.save_settings({ + 'work_minutes': work_min, + 'lunch_duration_minutes': dlg.lunch_spin.value(), + }) + # 새 다이얼로그에 다시 로드 + dlg2 = SettingsView(db=db) + assert dlg2.work_hours_spin.value() == 7 + assert dlg2.work_minutes_spin.value() == 30 + assert dlg2.lunch_spin.value() == 30 + # 프리셋이 자동 매칭되었는지 + assert dlg2.work_preset_combo.currentIndex() == 1 + dlg.deleteLater() + dlg2.deleteLater() + + +# 실행 +case("UI-1. SettingsView: 단축근무 프리셋 적용", test_settings_view) +case("UI-2. HelpView: 6개 탭 생성 확인", test_help_view) +case("UI-3. MiniWidget: 시간 업데이트 + 추가근무 표시", test_mini_widget) +case("UI-4. ChartWidget: 빈/유효 데이터 그리기", test_chart_widget) +case("UI-5. StatsView: 데이터 없이 로드", test_stats_view) +case("UI-6. CalendarView: 인스턴스화", test_calendar_view) +case("UI-7. MainWindow: 단축키 7개 등록 + 초기 상태", test_main_window_init) +case("UI-8. Settings 저장→재로드: 단축근무 7h30m round-trip", test_settings_save_load_round_trip) + +print() +print("=" * 60) +print(f"PASS: {len(PASS)} FAIL: {len(FAIL)}") +if FAIL: + print("\nFAILED:") + for label, err in FAIL: + print(f" - {label}: {err}") + +# Cleanup +if os.path.exists(db_path): + try: os.remove(db_path) + except: pass + +sys.exit(1 if FAIL else 0) diff --git a/_i18n_gui_test.py b/_i18n_gui_test.py new file mode 100644 index 0000000..001f8b8 --- /dev/null +++ b/_i18n_gui_test.py @@ -0,0 +1,157 @@ +""" +i18n GUI 검증: 언어 전환 시 UI 라벨이 실제로 바뀌는지. + +동일 SettingsView를 한국어로 만들고 확인 → 영어로 만들고 확인. +(런타임 언어 전환은 위젯 재생성을 요구하므로 새 인스턴스로 검증) +""" +import os +import sys +import tempfile + +os.environ['QT_QPA_PLATFORM'] = 'offscreen' +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PyQt5.QtWidgets import QApplication, QPushButton, QGroupBox + +app = QApplication.instance() or QApplication(sys.argv) + +from core.database import Database +from core.i18n import set_language + +db_path = os.path.join(tempfile.gettempdir(), 'clockout_i18n.db') +if os.path.exists(db_path): + os.remove(db_path) +db = Database(db_path) + +PASS, FAIL = [], [] + + +def case(label, fn): + try: + fn() + PASS.append(label) + print(f"[PASS] {label}") + except Exception as e: + FAIL.append((label, f"{type(e).__name__}: {e}")) + print(f"[FAIL] {label}: {type(e).__name__}: {e}") + + +def find_button_text(widget, text_substring): + for btn in widget.findChildren(QPushButton): + if text_substring in btn.text(): + return btn + return None + + +def find_groupbox_title(widget, title_substring): + for gb in widget.findChildren(QGroupBox): + if title_substring in gb.title(): + return gb + return None + + +def test_settings_korean_labels(): + """한국어 모드에서 SettingsView 라벨 검증.""" + set_language('ko') + from ui.settings_view import SettingsView + dlg = SettingsView(db=db) + # 윈도우 제목 + assert '설정' in dlg.windowTitle(), f"title: {dlg.windowTitle()}" + # 그룹박스 + assert find_groupbox_title(dlg, '근무 시간') is not None + assert find_groupbox_title(dlg, '연장근무') is not None + assert find_groupbox_title(dlg, '데이터') is not None + # 저장 버튼 + save_btn = find_button_text(dlg, '저장') + assert save_btn is not None + dlg.deleteLater() + + +def test_settings_english_labels(): + """영어 모드에서 SettingsView 라벨 검증.""" + set_language('en') + from ui.settings_view import SettingsView + dlg = SettingsView(db=db) + assert 'Settings' in dlg.windowTitle() + assert find_groupbox_title(dlg, 'Work Time') is not None + assert find_groupbox_title(dlg, 'Overtime') is not None + assert find_groupbox_title(dlg, 'Data') is not None + save_btn = find_button_text(dlg, 'Save') + assert save_btn is not None + dlg.deleteLater() + set_language('ko') # 원복 + + +def test_main_window_language(): + """MainWindow 라벨 — 한국어 / 영어""" + set_language('ko') + from ui.main_window import MainWindow + w = MainWindow() + assert '퇴근시간' in w.windowTitle() + # 메뉴 버튼: 통계/캘린더/일일보고/도움말/설정/퇴근하기 + assert find_button_text(w, '통계') is not None + assert find_button_text(w, '캘린더') is not None + assert find_button_text(w, '도움말') is not None + assert find_button_text(w, '점심') is not None + w.deleteLater() + + +def test_mini_widget_language(): + set_language('ko') + from ui.mini_widget import MiniWidget + w = MiniWidget() + assert '남은 시간' in w.title_label.text() + w.update_remaining('+00:30:00') + assert '추가' in w.title_label.text() + w.deleteLater() + + set_language('en') + w2 = MiniWidget() + assert 'Remaining' in w2.title_label.text() + w2.update_remaining('+00:30:00') + assert 'Overtime' in w2.title_label.text() + w2.deleteLater() + set_language('ko') + + +def test_notifier_language(): + """알림 메시지가 언어에 맞춰 나오는지""" + set_language('ko') + from core.notifier import Notifier + from datetime import datetime, timedelta + n = Notifier(db=db) + fired = [] + n.notification_signal.connect(lambda t, m: fired.append((t, m))) + n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now()) + assert len(fired) == 1 + assert '퇴근' in fired[0][0] + + set_language('en') + n2 = Notifier(db=db) + fired2 = [] + n2.notification_signal.connect(lambda t, m: fired2.append((t, m))) + n2.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now()) + assert len(fired2) == 1 + assert 'Clock-out' in fired2[0][0] + set_language('ko') + + +case("I18N-1. SettingsView 한국어 라벨", test_settings_korean_labels) +case("I18N-2. SettingsView 영어 라벨", test_settings_english_labels) +case("I18N-3. MainWindow 한국어 (메뉴/버튼)", test_main_window_language) +case("I18N-4. MiniWidget ko/en 전환 후 라벨", test_mini_widget_language) +case("I18N-5. Notifier 알림 메시지 ko/en", test_notifier_language) + +print() +print("=" * 60) +print(f"PASS: {len(PASS)} FAIL: {len(FAIL)}") +if FAIL: + print("\nFAILED:") + for label, err in FAIL: + print(f" - {label}: {err}") + +if os.path.exists(db_path): + try: os.remove(db_path) + except: pass + +sys.exit(1 if FAIL else 0) diff --git a/_integration_test.py b/_integration_test.py new file mode 100644 index 0000000..0756f2f --- /dev/null +++ b/_integration_test.py @@ -0,0 +1,541 @@ +""" +실사용 시나리오 통합 검증. + +GUI 없이 비즈니스 로직 + DB + 알림 + API + 백업 + 마이그레이션을 시나리오별로 검증. +실패 시 어떤 시나리오가 깨졌는지 명확히 출력. +""" +from __future__ import annotations +import os +import sys +import shutil +import tempfile +import sqlite3 +from datetime import date, datetime, timedelta +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +PASS = [] +FAIL = [] +WARN = [] + + +def case(label): + """테스트 케이스 데코레이터.""" + def deco(fn): + def wrapper(*args, **kwargs): + try: + fn(*args, **kwargs) + PASS.append(label) + print(f"[PASS] {label}") + except AssertionError as e: + FAIL.append((label, str(e))) + print(f"[FAIL] {label}: {e}") + except Exception as e: + FAIL.append((label, f"{type(e).__name__}: {e}")) + print(f"[ERROR] {label}: {type(e).__name__}: {e}") + return wrapper + return deco + + +def fresh_db(name='_test') -> 'Database': + from core.database import Database + p = os.path.join(tempfile.gettempdir(), f'clockout_{name}.db') + if os.path.exists(p): + os.remove(p) + return Database(p) + + +# ============================================================ +# 시나리오 1: 신규 사용자 첫 실행 — 마이그레이션 + 기본값 +# ============================================================ +@case("S1. 신규 DB: 모든 기본값 설정 키가 채워짐") +def s1_fresh_install(): + db = fresh_db('s1') + expected_keys = ['work_hours', 'work_minutes', 'lunch_duration_minutes', + 'dinner_duration_minutes', 'auto_lunch', 'theme', + 'notification_clock_out', 'notification_lunch', + 'notification_overtime', 'notification_health', + 'annual_leave_total', 'annual_leave_days', + 'workday_boundary_hour', 'overtime_unit', 'time_format'] + for k in expected_keys: + v = db.get_setting(k) + assert v is not None, f"missing default: {k}" + assert db.get_work_minutes() == 480 + assert db.get_setting('annual_leave_keys_migrated') == 'true' + + +@case("S2. 레거시 DB(work_hours만) → work_minutes 자동 마이그레이션") +def s2_migration(): + p = os.path.join(tempfile.gettempdir(), 'clockout_legacy.db') + if os.path.exists(p): os.remove(p) + conn = sqlite3.connect(p) + conn.execute("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT, updated_at TIMESTAMP)") + conn.execute("INSERT INTO settings VALUES ('work_hours', '8', CURRENT_TIMESTAMP)") + conn.execute("INSERT INTO settings VALUES ('lunch_duration', '1', CURRENT_TIMESTAMP)") + conn.execute("INSERT INTO settings VALUES ('annual_leave_total', '15', CURRENT_TIMESTAMP)") + conn.commit() + conn.close() + + from core.database import Database + db = Database(p) + assert db.get_setting('work_minutes') == '480' + assert db.get_setting('lunch_duration_minutes') == '60' + # 양쪽 동기화 + assert db.get_setting('annual_leave_days') == '15' + os.remove(p) + + +# ============================================================ +# 시나리오 3-6: 다양한 근무 패턴 +# ============================================================ +@case("S3. 단축근무 7h30m + 점심30m → 09:00 출근 → 17:00 퇴근") +def s3_short_work(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_minutes=450, lunch_duration_minutes=30) + ci = datetime(2026, 4, 29, 9, 0) + co = calc.calculate_clock_out_time(ci, include_lunch=True) + assert co == datetime(2026, 4, 29, 17, 0), f"got {co}" + + +@case("S4. 표준 8h + 점심60m → 09:00 → 18:00, 1h35m 연장 → 90분 적립") +def s4_standard(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60) + ci = datetime(2026, 4, 29, 9, 0) + co = calc.calculate_clock_out_time(ci, include_lunch=True) + assert co == datetime(2026, 4, 29, 18, 0) + actual = co + timedelta(hours=1, minutes=35) + a, e = calc.calculate_overtime(ci, actual, include_lunch=True) + assert a == 95 and e == 90 + + +@case("S5. 반일 4시간 + 점심없음 → 09:00 → 13:00") +def s5_half_day(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_minutes=240, lunch_duration_minutes=0) + ci = datetime(2026, 4, 29, 9, 0) + co = calc.calculate_clock_out_time(ci, include_lunch=False) + assert co == datetime(2026, 4, 29, 13, 0) + + +@case("S6. 야근 (저녁 적용): 8h + 점심60m + 저녁60m → 09:00 → 19:00") +def s6_dinner(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60, dinner_duration_minutes=60) + ci = datetime(2026, 4, 29, 9, 0) + co = calc.calculate_clock_out_time(ci, include_lunch=True, include_dinner=True) + assert co == datetime(2026, 4, 29, 19, 0) + + +@case("S7. 외출 30분 추가 시 퇴근 시각 30분 뒤로") +def s7_break(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60) + ci = datetime(2026, 4, 29, 9, 0) + co_no = calc.calculate_clock_out_time(ci, include_lunch=True) + co_break = calc.calculate_clock_out_time(ci, include_lunch=True, break_minutes=30) + assert (co_break - co_no) == timedelta(minutes=30) + + +# ============================================================ +# 시나리오 8-10: 연장근무 절삭/잔액 +# ============================================================ +@case("S8. 연장근무 30분 절삭: 29분→0, 30분→30, 35분→30, 60분→60") +def s8_truncation(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_hours=8) + ci = datetime(2026, 4, 29, 9, 0) + base_co = ci + timedelta(hours=8) + cases = [(29, 0), (30, 30), (35, 30), (60, 60), (89, 60), (90, 90)] + for actual_min, expected_earned in cases: + co = base_co + timedelta(minutes=actual_min) + _, earned = calc.calculate_overtime(ci, co, include_lunch=False) + assert earned == expected_earned, f"{actual_min}min → {earned}, expected {expected_earned}" + + +@case("S9. 연장근무 잔액: bank - usage 합산") +def s9_balance(): + db = fresh_db('s9') + today = date.today().isoformat() + db.add_initial_overtime_balance(180) # 3시간 적립 + bal = db.get_total_overtime_balance() + assert bal == 180, f"after add 3h: {bal}" + db.add_overtime_usage(today, 60, '1h 사용') + bal = db.get_total_overtime_balance() + assert bal == 120, f"after use 1h: {bal}" + + +# ============================================================ +# 시나리오 10-11: 연차 +# ============================================================ +@case("S10. 단축근무자 1일 연차 = 분 단위 정확 환산 (7h30m → 450분)") +def s10_leave_minutes(): + db = fresh_db('s10') + db.save_settings({'work_minutes': 450}) + today = date.today().isoformat() + db.add_leave_record(today, 'annual', 1.0) + assert db.get_today_leave_minutes() == 450 + + +@case("S11. 반차/반반차: 0.5일 → 4시간, 0.25일 → 2시간 (8h 기준)") +def s11_half_leave(): + db = fresh_db('s11') + today = date.today().isoformat() + # 8h 근무자 기본 (work_minutes=480) + db.add_leave_record(today, 'half_am', 0.5) + assert db.get_today_leave_minutes() == 240 + # 0.25 추가하여 누적 + db.add_leave_record(today, 'quarter', 0.25) + assert db.get_today_leave_minutes() == 360 # 0.75 * 480 + + +# ============================================================ +# 시나리오 12: 공휴일/주말 +# ============================================================ +@case("S12. 주말 근무: 모든 시간이 연장근무로 인정 (TimeCalculator.is_weekend)") +def s12_weekend(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator() + sat = datetime(2026, 5, 2, 9, 0) # Saturday + assert calc.is_weekend(sat) + mon = datetime(2026, 5, 4, 9, 0) + assert not calc.is_weekend(mon) + assert calc.get_day_type(sat) == 'weekend' + + +@case("S13. 공휴일 자동 등록 (holidays 패키지 사용 가능 시)") +def s13_holidays(): + db = fresh_db('s13') + n = db.add_korean_holidays_auto(2026) + if n == -1: + WARN.append("S13: holidays 패키지 미설치 — fallback 동작 OK") + # fallback도 가능한지 확인 + db.add_korean_holidays(2026) + hols = db.get_holidays_by_year(2026) + assert len(hols) >= 8, f"fixed holidays: {len(hols)}" + else: + hols = db.get_holidays_by_year(2026) + assert n >= 15, f"only {n} added (expected music holidays included)" + # 음력 명절 포함 확인 + names = [h['name'] for h in hols] + has_lunar = any('설날' in nm or '추석' in nm for nm in names) + assert has_lunar, f"missing lunar holidays: {names[:5]}" + + +# ============================================================ +# 시나리오 14-15: 알림 가드 +# ============================================================ +@case("S14. 알림 가드: notification_clock_out=false → 30분 전 알림 안 옴") +def s14_notif_off(): + from core.notifier import Notifier + db = fresh_db('s14') + db.set_setting('notification_clock_out', 'false') + n = Notifier(db=db) + fired = [] + n.notification_signal.connect(lambda t, m: fired.append((t, m))) + n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now()) + assert len(fired) == 0, f"should be guarded: {fired}" + + +@case("S15. 알림 가드: notification_clock_out=true → 알림 발생") +def s15_notif_on(): + from core.notifier import Notifier + db = fresh_db('s15') + db.set_setting('notification_clock_out', 'true') + n = Notifier(db=db) + fired = [] + n.notification_signal.connect(lambda t, m: fired.append((t, m))) + n.check_clock_out_soon(datetime.now() + timedelta(minutes=20), datetime.now()) + assert len(fired) == 1 + assert '퇴근' in fired[0][0] + + +@case("S16. 건강 경고: 3일 연속 연장근무 시에만 fire (notified_health flag)") +def s16_health(): + from core.notifier import Notifier + db = fresh_db('s16') + n = Notifier(db=db) + fired = [] + n.notification_signal.connect(lambda t, m: fired.append((t, m))) + n.notify_health_warning(2) # 미달 + assert len(fired) == 0 + n.notify_health_warning(3) # 발화 + assert len(fired) == 1 + n.notify_health_warning(5) # 같은 날 중복 안 됨 + assert len(fired) == 1 + + +@case("S17. consecutive_overtime_days: 연속 연장근무 카운트") +def s17_consecutive(): + db = fresh_db('s17') + today = date.today() + # 3일 연속 연장 (오늘부터 거꾸로) + for i in range(3): + d = (today - timedelta(days=i)).isoformat() + rid = db.add_work_record(d, '09:00:00') + db.update_clock_out(d, '20:00:00', total_hours=11.0, + overtime_minutes=120, overtime_earned=120) + # 4일 전엔 미연장 + d4 = (today - timedelta(days=3)).isoformat() + db.add_work_record(d4, '09:00:00') + db.update_clock_out(d4, '18:00:00', total_hours=8.0, + overtime_minutes=0, overtime_earned=0) + assert db.get_consecutive_overtime_days() == 3 + + +# ============================================================ +# 시나리오 18-19: 백업 +# ============================================================ +@case("S18. 백업: 첫 호출 시 파일 생성, 같은 날 두번째 호출은 skip") +def s18_backup(): + from utils.backup import backup_db_if_needed + db = fresh_db('s18') + bdir = Path(tempfile.gettempdir()) / 'clockout_test_backups' + if bdir.exists(): + shutil.rmtree(bdir) + src = db.db_path + + r1 = backup_db_if_needed(db, source_path=src, backup_dir=bdir, keep=3) + assert r1 is not None and r1.exists() + r2 = backup_db_if_needed(db, source_path=src, backup_dir=bdir, keep=3) + assert r2 is None # 같은 날 + shutil.rmtree(bdir) + + +@case("S19. 백업 회전: keep=3 초과 시 오래된 파일 삭제") +def s19_backup_rotate(): + from utils.backup import _rotate + bdir = Path(tempfile.gettempdir()) / 'clockout_rotate' + if bdir.exists(): + shutil.rmtree(bdir) + bdir.mkdir(parents=True) + # 5개 백업 가짜 생성 + for i in range(5): + f = bdir / f'database-2026-04-{20+i:02d}.db' + f.write_text('dummy') + # 다른 mtime 부여 + ts = (datetime.now() - timedelta(days=5-i)).timestamp() + os.utime(f, (ts, ts)) + + _rotate(bdir, keep=3) + remaining = list(bdir.glob('database-*.db')) + assert len(remaining) == 3, f"after rotate: {len(remaining)}" + # 최신 3개만 남았는지 + names = sorted(r.name for r in remaining) + assert '2026-04-22' in names[0] # 오래된 2개 삭제 + shutil.rmtree(bdir) + + +# ============================================================ +# 시나리오 23-24: 설정 동기화 +# ============================================================ +@case("S23. save_settings: work_minutes만 보내도 work_hours 자동 동기화 (floor)") +def s23_sync_work(): + db = fresh_db('s23') + db.save_settings({'work_minutes': 450}) + assert db.get_setting('work_hours') == '7' # floor(7.5) + assert db.get_setting('work_minutes') == '450' + + +@case("S24. save_settings: annual_leave_days ↔ annual_leave_total 양방향") +def s24_sync_leave(): + db = fresh_db('s24') + db.save_settings({'annual_leave_days': 20}) + assert db.get_setting('annual_leave_total') == '20' + db2 = fresh_db('s24b') + db2.save_settings({'annual_leave_total': 18}) + assert db2.get_setting('annual_leave_days') == '18' + + +@case("S25. get_setting_int/float/bool 헬퍼: 잘못된 값 → default") +def s25_setting_helpers(): + db = fresh_db('s25') + db.set_setting('valid_int', '42') + db.set_setting('invalid_int', 'abc') + db.set_setting('truthy', 'yes') + db.set_setting('falsy', 'no') + assert db.get_setting_int('valid_int') == 42 + assert db.get_setting_int('invalid_int', 99) == 99 + assert db.get_setting_int('missing', 7) == 7 + assert db.get_setting_bool('truthy') == True + assert db.get_setting_bool('falsy') == False + + +# ============================================================ +# 시나리오 26: i18n +# ============================================================ +@case("S26. i18n: ko ↔ en 전환, 미번역 키 fallback") +def s26_i18n(): + from core.i18n import tr, set_language + set_language('ko') + assert '저장' in tr('btn.save') + set_language('en') + assert 'Save' in tr('btn.save') + # 존재하지 않는 키 + assert tr('missing.key') == 'missing.key' + # 포맷 인자 + set_language('ko') + msg = tr('notif.clock_out_soon.body', minutes=15) + assert '15' in msg + # 영어로도 포맷 인자 동작 + set_language('en') + msg_en = tr('notif.clock_out_soon.body', minutes=15) + assert '15' in msg_en and 'minutes' in msg_en + # 사전에 정의된 새 키들도 한국어/영어 둘 다 OK + set_language('en') + assert tr('menu.stats') == 'Stats' + set_language('ko') + assert tr('menu.stats') == '통계' + + + +# ============================================================ +# 시나리오 28-30: 에지 케이스 +# ============================================================ +@case("S28. work_minutes=0 입력 → settings_view가 거부 (UI 단계, 비즈니스 로직 직접 호출은 허용)") +def s28_zero_work(): + from core.time_calculator import TimeCalculator + # TimeCalculator는 0도 허용 (UI에서 검증) + calc = TimeCalculator(work_minutes=0, lunch_duration_minutes=0) + ci = datetime(2026, 4, 29, 9, 0) + co = calc.calculate_clock_out_time(ci, include_lunch=False) + # 0 근무 → 출근 즉시 퇴근 + assert co == ci + + +@case("S29. 자정 넘김 외출 시간 계산 (break_in이 다음 날)") +def s29_midnight_break(): + # break 시간 계산은 main_window 내부 로직이라 직접 검증 어려움 + # 대신 datetime 비교 로직만 빠르게 확인 + break_out = datetime(2026, 4, 29, 23, 30) + break_in = datetime(2026, 4, 30, 0, 30) + # 같은 날짜로 잘못 만들면 음수가 나옴 + same_day_in = datetime(2026, 4, 29, 0, 30) + if same_day_in < break_out: + same_day_in += timedelta(days=1) + assert (same_day_in - break_out) == timedelta(hours=1) + + +@case("S30. holidays 양력만 fallback: add_korean_holidays 8개 등록") +def s30_holidays_fallback(): + db = fresh_db('s30') + db.add_korean_holidays(2026) + hols = db.get_holidays_by_year(2026) + assert len(hols) == 8 + + +# ============================================================ +# 시나리오 31-33: 마이그레이션 idempotency +# ============================================================ +@case("S31. annual_leave_keys_migrated sentinel: 두번째 init은 skip") +def s31_migration_idempotent(): + p = os.path.join(tempfile.gettempdir(), 'clockout_idem.db') + if os.path.exists(p): os.remove(p) + from core.database import Database + db1 = Database(p) + assert db1.get_setting('annual_leave_keys_migrated') == 'true' + # 다시 init — sentinel이 있으면 스킵 + db2 = Database(p) + assert db2.get_setting('annual_leave_keys_migrated') == 'true' + os.remove(p) + + +@case("S32. UI imports: 모든 view 모듈 정상 import") +def s32_ui_imports(): + # PyQt5 의존이라 헤드리스에선 import만 확인 + from ui import main_window, settings_view, calendar_view, help_view + from ui import mini_widget, chart_widget, stats_view, break_view + from ui import overtime_view, leave_view, clock_in_dialog, styles + + +@case("S33. 단축근무 사용자 종합: 7h30m 근무 + 30분 연장 시 = 적립 0분 (절삭)") +def s33_short_overtime(): + from core.time_calculator import TimeCalculator + calc = TimeCalculator(work_minutes=450, lunch_duration_minutes=30) + ci = datetime(2026, 4, 29, 9, 0) + base_co = calc.calculate_clock_out_time(ci, include_lunch=True) # 17:00 + # 17:29 퇴근 → 29분 연장 → 0분 적립 + actual_29 = base_co + timedelta(minutes=29) + _, e29 = calc.calculate_overtime(ci, actual_29, include_lunch=True) + assert e29 == 0 + # 17:30 퇴근 → 30분 연장 → 30분 적립 + actual_30 = base_co + timedelta(minutes=30) + _, e30 = calc.calculate_overtime(ci, actual_30, include_lunch=True) + assert e30 == 30 + + +@case("S34. format_hours_minutes: 다양한 분 입력 변환") +def s34_format(): + from utils.time_format import format_hours_minutes + assert format_hours_minutes(0) == '0시간 0분' + assert format_hours_minutes(0, omit_zero_minutes=True) == '0분' + assert format_hours_minutes(60, omit_zero_minutes=True) == '1시간' + assert format_hours_minutes(90) == '1시간 30분' + assert format_hours_minutes(-90) == '1시간 30분' # abs + assert format_hours_minutes(450) == '7시간 30분' + + +@case("S35. lock_detector: 정상 환경에선 False (현재 PC 잠겨있지 않음)") +def s35_lock(): + from utils.lock_detector import is_screen_locked + # 헤드리스/non-Windows일 수도 있으니 결과만 확인 + result = is_screen_locked() + assert result in (True, False) + # 일반적으로 False여야 함 (PC 잠금 안된 상태에서 테스트) + + +# ============================================================ +# Run all +# ============================================================ +def main(): + s1_fresh_install() + s2_migration() + s3_short_work() + s4_standard() + s5_half_day() + s6_dinner() + s7_break() + s8_truncation() + s9_balance() + s10_leave_minutes() + s11_half_leave() + s12_weekend() + s13_holidays() + s14_notif_off() + s15_notif_on() + s16_health() + s17_consecutive() + s18_backup() + s19_backup_rotate() + s23_sync_work() + s24_sync_leave() + s25_setting_helpers() + s26_i18n() + s28_zero_work() + s29_midnight_break() + s30_holidays_fallback() + s31_migration_idempotent() + s32_ui_imports() + s33_short_overtime() + s34_format() + s35_lock() + + print() + print("=" * 60) + print(f"PASS: {len(PASS)} FAIL: {len(FAIL)} WARN: {len(WARN)}") + if FAIL: + print() + print("FAILED:") + for label, err in FAIL: + print(f" - {label}: {err}") + if WARN: + print() + print("WARNINGS:") + for w in WARN: + print(f" - {w}") + return 0 if not FAIL else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/check_db.py b/check_db.py new file mode 100644 index 0000000..750c64b --- /dev/null +++ b/check_db.py @@ -0,0 +1,25 @@ +import sqlite3 +from datetime import date + +conn = sqlite3.connect('database.db') +cursor = conn.cursor() + +print("=== Recent overtime usage ===") +cursor.execute('SELECT date, used_minutes, reason FROM overtime_usage ORDER BY date DESC LIMIT 5') +for row in cursor.fetchall(): + print(f"{row[0]}: {row[1]} min - {row[2]}") + +print("\n=== Recent work records ===") +cursor.execute('SELECT date, clock_in, clock_out, lunch_break, dinner_break FROM work_records ORDER BY date DESC LIMIT 3') +for row in cursor.fetchall(): + lunch = "Yes" if row[3] else "No" + dinner = "Yes" if row[4] if row[4] is not None else 0 else "No" + print(f"{row[0]}: {row[1]} ~ {row[2]}, Lunch:{lunch}, Dinner:{dinner}") + +print(f"\n=== Today date: {date.today().isoformat()} ===") +today = date.today().isoformat() +cursor.execute('SELECT SUM(used_minutes) FROM overtime_usage WHERE date = ?', (today,)) +used_today = cursor.fetchone()[0] or 0 +print(f"Overtime used today: {used_today} minutes") + +conn.close() diff --git a/clock_icon.ico b/clock_icon.ico new file mode 100644 index 0000000..3e9ad29 Binary files /dev/null and b/clock_icon.ico differ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..0424fee --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# core 모듈 diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..a93829f --- /dev/null +++ b/core/database.py @@ -0,0 +1,1603 @@ +""" +데이터베이스 관리 모듈 +SQLite를 사용하여 근무 기록, 연장근무, 휴가 등을 관리 +""" +import sqlite3 +from datetime import datetime, date +from typing import Optional, List, Dict, Tuple +import os + +from core.settings_keys import ( + WORK_HOURS, WORK_MINUTES, ANNUAL_LEAVE_TOTAL, ANNUAL_LEAVE_DAYS, + INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, +) + + +class Database: + def __init__(self, db_path: str = "database.db"): + self.db_path = db_path + self.init_database() + self._enable_concurrency() + + def get_connection(self) -> sqlite3.Connection: + """데이터베이스 연결 반환. + + timeout=5초: 다른 PC/프로세스가 쓰는 동안 락 충돌 시 대기. + 클라우드 동기화(OneDrive 등)로 같은 DB를 두 PC에서 쓸 때 안전. + """ + conn = sqlite3.connect(self.db_path, timeout=5.0) + conn.row_factory = sqlite3.Row + return conn + + def _enable_concurrency(self): + """WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화.""" + try: + conn = sqlite3.connect(self.db_path, timeout=5.0) + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + conn.commit() + conn.close() + except sqlite3.OperationalError: + # WAL 미지원 환경(읽기전용 폴더 등) — 기본 모드로 fallback + pass + + def init_database(self): + """데이터베이스 초기화 및 테이블 생성""" + conn = self.get_connection() + cursor = conn.cursor() + + # 일일 근무 기록 테이블 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS work_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL UNIQUE, + clock_in TIME NOT NULL, + clock_out TIME, + lunch_break BOOLEAN DEFAULT 0, + total_hours REAL, + overtime_minutes INTEGER DEFAULT 0, + overtime_earned INTEGER DEFAULT 0, + overtime_used INTEGER DEFAULT 0, + work_type TEXT DEFAULT 'normal', + memo TEXT, + is_manual BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 연장근무 적립 내역 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS overtime_bank ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + earned_minutes INTEGER NOT NULL, + date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) + ) + ''') + + # 연장근무 사용 내역 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS overtime_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + used_minutes INTEGER NOT NULL, + date DATE NOT NULL, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) + ) + ''') + + # 휴가 기록 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS leave_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + leave_type TEXT NOT NULL, + days REAL, + memo TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 설정 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 업적 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + earned_date DATE, + badge_icon TEXT + ) + ''') + + # 외출 기록 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS break_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + date DATE NOT NULL, + break_out TIME NOT NULL, + break_in TIME, + total_minutes INTEGER, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE + ) + ''') + + # 공휴일 테이블 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS holidays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL UNIQUE, + name TEXT NOT NULL, + is_recurring BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + conn.commit() + conn.close() + + # 데이터베이스 마이그레이션 실행 + self.migrate_break_records_cascade() + self.migrate_lunch_duration_to_minutes() + self.migrate_leave_records_hours_to_days() + self.migrate_add_dinner_break() + self.migrate_cleanup_balance_adjustments() + self.migrate_work_hours_to_minutes() + self.migrate_annual_leave_keys() + + # 기본 설정 초기화 + self.init_default_settings() + + def migrate_break_records_cascade(self): + """break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션)""" + conn = self.get_connection() + cursor = conn.cursor() + + # 기존 테이블에 CASCADE가 있는지 확인 + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='break_records'") + result = cursor.fetchone() + + if result and 'ON DELETE CASCADE' not in result[0]: + # CASCADE가 없으면 테이블 재생성 + try: + # 1. 기존 데이터 백업 + cursor.execute('SELECT * FROM break_records') + backup_data = cursor.fetchall() + + # 2. 기존 테이블 삭제 + cursor.execute('DROP TABLE IF EXISTS break_records') + + # 3. CASCADE 포함한 새 테이블 생성 + cursor.execute(''' + CREATE TABLE break_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + date DATE NOT NULL, + break_out TIME NOT NULL, + break_in TIME, + total_minutes INTEGER, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE + ) + ''') + + # 4. 데이터 복원 + for row in backup_data: + cursor.execute(''' + INSERT INTO break_records + (id, work_record_id, date, break_out, break_in, total_minutes, reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', tuple(row)) + + conn.commit() + print("break_records 테이블 CASCADE 마이그레이션 완료") + except Exception as e: + conn.rollback() + print(f"마이그레이션 오류: {e}") + + conn.close() + + def migrate_lunch_duration_to_minutes(self): + """lunch_duration을 시간 단위에서 분 단위로 마이그레이션""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 기존 lunch_duration 설정 확인 + cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration'") + result = cursor.fetchone() + + if result: + # 기존 값이 있으면 시간 단위로 저장되어 있으므로 분으로 변환 + lunch_hours = float(result['value']) + lunch_minutes = int(lunch_hours * 60) + + # lunch_duration_minutes로 저장 + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('lunch_duration_minutes', ?, CURRENT_TIMESTAMP) + ''', (str(lunch_minutes),)) + + # 기존 lunch_duration은 삭제하지 않음 (호환성 유지) + + # lunch_duration_minutes가 없으면 기본값 설정 + cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration_minutes'") + if not cursor.fetchone(): + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('lunch_duration_minutes', '60') + ''') + + conn.commit() + except Exception as e: + # 마이그레이션 실패 시 무시 (이미 마이그레이션됨) + # 단, 예상치 못한 오류는 로그에 기록 + import sys + if "no such column" not in str(e).lower() and "already exists" not in str(e).lower(): + print(f"lunch_duration 마이그레이션 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_leave_records_hours_to_days(self): + """leave_records.hours 컬럼을 days로 변경 (마이그레이션)""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 현재 테이블 스키마 확인 + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='leave_records'") + result = cursor.fetchone() + + if result and 'hours REAL' in result[0]: + # hours 컬럼이 있으면 days로 변경 + # 1. 기존 데이터 백업 + cursor.execute('SELECT * FROM leave_records') + backup_data = cursor.fetchall() + + # 2. 기존 테이블 삭제 + cursor.execute('DROP TABLE IF EXISTS leave_records') + + # 3. days 컬럼으로 새 테이블 생성 + cursor.execute(''' + CREATE TABLE leave_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + leave_type TEXT NOT NULL, + days REAL, + memo TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 4. 데이터 복원 (hours -> days, 같은 값) + for row in backup_data: + cursor.execute(''' + INSERT INTO leave_records + (id, date, leave_type, days, memo, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', tuple(row)) + + conn.commit() + print("leave_records 테이블 hours->days 마이그레이션 완료") + except Exception as e: + conn.rollback() + print(f"leave_records 마이그레이션 오류: {e}") + finally: + conn.close() + + def migrate_add_dinner_break(self): + """work_records 테이블에 dinner_break 컬럼 추가 (마이그레이션)""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 현재 테이블 스키마 확인 + cursor.execute("PRAGMA table_info(work_records)") + columns = [row[1] for row in cursor.fetchall()] + + if 'dinner_break' not in columns: + # dinner_break 컬럼 추가 + cursor.execute(''' + ALTER TABLE work_records + ADD COLUMN dinner_break BOOLEAN DEFAULT 0 + ''') + conn.commit() + print("work_records 테이블에 dinner_break 컬럼 추가 완료") + except Exception as e: + import sys + if "duplicate column name" not in str(e).lower(): + print(f"dinner_break 컬럼 추가 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_cleanup_balance_adjustments(self): + """기존 잘못된 조정 데이터 정리 마이그레이션 + + 이전 버전에서 '덮어쓰기' 방식으로 생성된 조정 레코드들을 정리: + - overtime_bank: work_record_id가 NULL인 레코드는 삭제 (초기값은 settings로 이동) + - leave_records: 'manual' 타입이고 '이전 사용분 일괄 추가' 메모가 있는 레코드 삭제 (초기값은 settings로 이동) + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 마이그레이션 완료 여부 확인 (v2로 버전 업) + cursor.execute("SELECT value FROM settings WHERE key = 'balance_adjustment_migrated_v2'") + result = cursor.fetchone() + + if result: + # 이미 마이그레이션 완료 + conn.close() + return + + # 1. overtime_bank에서 수동 추가 레코드 삭제 + # (work_record_id가 NULL인 것 - 이전 방식의 수동 조정) + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id IS NULL + ''') + deleted_overtime = cursor.rowcount + + # 2. leave_records에서 '이전 사용분 일괄 추가' 레코드 삭제 + cursor.execute(''' + DELETE FROM leave_records + WHERE leave_type = 'manual' AND memo LIKE '%이전 사용분 일괄 추가%' + ''') + deleted_leave = cursor.rowcount + + # 마이그레이션 완료 표시 + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('balance_adjustment_migrated_v2', 'true', CURRENT_TIMESTAMP) + ''') + + conn.commit() + + if deleted_overtime > 0 or deleted_leave > 0: + print(f"잔액 조정 마이그레이션 v2 완료: 연장근무 {deleted_overtime}건, 연차 {deleted_leave}건 삭제") + + except Exception as e: + conn.rollback() + import sys + print(f"잔액 조정 마이그레이션 오류: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_work_hours_to_minutes(self): + """work_hours(시간 단위, 정수)를 work_minutes(분 단위)로 마이그레이션. + + 단축근무자(예: 7시간 30분)를 위해 분 단위 저장이 필요. + 기존 work_hours는 호환성 유지를 위해 보존. + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # work_minutes가 이미 있으면 스킵 + cursor.execute("SELECT value FROM settings WHERE key = 'work_minutes'") + if cursor.fetchone(): + conn.close() + return + + # work_hours에서 분으로 변환 + cursor.execute("SELECT value FROM settings WHERE key = 'work_hours'") + row = cursor.fetchone() + if row: + try: + # float 허용 (혹시 외부에서 7.5 등 저장된 경우) + work_hours_val = float(row[0]) + work_minutes_val = int(round(work_hours_val * 60)) + except (ValueError, TypeError): + work_minutes_val = 480 + else: + work_minutes_val = 480 + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('work_minutes', ?, CURRENT_TIMESTAMP) + ''', (str(work_minutes_val),)) + + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"work_minutes 마이그레이션 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_annual_leave_keys(self): + """annual_leave_total(레거시) ↔ annual_leave_days(UI) 동기화. + + UI는 annual_leave_days를 사용하지만 일부 메서드는 annual_leave_total을 읽음. + 둘 중 하나만 있으면 다른 쪽에 복사. sentinel로 1회만 실행. + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # sentinel 체크: 이미 마이그레이션 완료면 스킵 + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_keys_migrated'") + if cursor.fetchone(): + return + + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_days'") + days_row = cursor.fetchone() + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_total'") + total_row = cursor.fetchone() + + if days_row and not total_row: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_total', ?, CURRENT_TIMESTAMP) + ''', (days_row[0],)) + elif total_row and not days_row: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_days', ?, CURRENT_TIMESTAMP) + ''', (total_row[0],)) + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_keys_migrated', 'true', CURRENT_TIMESTAMP) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"annual_leave 키 동기화 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def get_setting_int(self, key: str, default: int = 0) -> int: + """설정을 int로 안전하게 조회 (변환 실패 시 default).""" + raw = self.get_setting(key, None) + if raw is None: + return default + try: + return int(raw) + except (ValueError, TypeError): + return default + + def get_setting_float(self, key: str, default: float = 0.0) -> float: + """설정을 float로 안전하게 조회.""" + raw = self.get_setting(key, None) + if raw is None: + return default + try: + return float(raw) + except (ValueError, TypeError): + return default + + def get_setting_bool(self, key: str, default: bool = False) -> bool: + """설정을 bool로 안전하게 조회 ('true'/'1'/'yes' = True).""" + raw = self.get_setting(key, None) + if raw is None: + return default + return str(raw).lower() in ('1', 'true', 'yes') + + def get_work_minutes(self) -> int: + """기본 근무시간 (분 단위) 조회. + + work_minutes 우선, 없으면 work_hours * 60으로 폴백. + 7시간 30분 같은 단축근무 케이스에서 분 단위 정확도 보장. + """ + wm = self.get_setting(WORK_MINUTES, None) + if wm is not None: + try: + return int(wm) + except (ValueError, TypeError): + pass + + wh = self.get_setting(WORK_HOURS, '8') + try: + return int(round(float(wh) * 60)) + except (ValueError, TypeError): + return 480 + + def init_default_settings(self): + """기본 설정 초기화""" + default_settings = { + 'work_hours': '8', + 'work_minutes': '480', + 'lunch_duration_minutes': '60', + 'dinner_duration_minutes': '60', + 'auto_detect_boot': 'true', + 'auto_lunch': 'false', + 'theme': 'light', + 'notification_enabled': 'true', + 'notification_before_minutes': '30', + 'notification_clock_out': 'true', + 'notification_lunch': 'true', + 'notification_overtime': 'true', + 'notification_health': 'true', + 'annual_leave_total': '15', + 'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화) + 'annual_leave_used': '0', + 'workday_boundary_hour': '6', + 'overtime_unit': '30', + 'time_format': '24' + } + + conn = self.get_connection() + cursor = conn.cursor() + + for key, value in default_settings.items(): + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?) + ''', (key, value)) + + conn.commit() + conn.close() + + # ===== 근무 기록 관련 메서드 ===== + + def add_work_record(self, date: str, clock_in: str, lunch_break: bool = False, + is_manual: bool = False) -> int: + """근무 기록 추가""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO work_records (date, clock_in, lunch_break, is_manual) + VALUES (?, ?, ?, ?) + ''', (date, clock_in, lunch_break, is_manual)) + + record_id = cursor.lastrowid + conn.commit() + conn.close() + return record_id + + def update_clock_out(self, date: str, clock_out: str, total_hours: float, + overtime_minutes: int, overtime_earned: int): + """퇴근 시간 및 연장근무 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET clock_out = ?, total_hours = ?, overtime_minutes = ?, + overtime_earned = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (clock_out, total_hours, overtime_minutes, overtime_earned, date)) + + conn.commit() + conn.close() + + def cancel_clock_out(self, date: str) -> bool: + """퇴근 취소 (퇴근 시간 및 연장근무 기록 삭제) + + Returns: + bool: 성공 여부 + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 1. 해당 날짜의 work_record 조회 + cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) + record = cursor.fetchone() + + if not record: + conn.close() + return False + + work_record_id = record[0] + + # 2. 해당 날짜의 연장근무 적립 내역 삭제 + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id = ? AND date = ? + ''', (work_record_id, date)) + + # 3. work_records의 퇴근 관련 필드 초기화 + cursor.execute(''' + UPDATE work_records + SET clock_out = NULL, + total_hours = NULL, + overtime_minutes = 0, + overtime_earned = 0, + updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (date,)) + + conn.commit() + conn.close() + return True + + except Exception as e: + conn.rollback() + conn.close() + raise e + + def get_today_record(self) -> Optional[Dict]: + """오늘 근무 기록 조회""" + today = date.today().isoformat() + return self.get_work_record(today) + + def get_work_record(self, date: str) -> Optional[Dict]: + """특정 날짜 근무 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM work_records WHERE date = ? + ''', (date,)) + + row = cursor.fetchone() + conn.close() + + if row: + return dict(row) + return None + + def update_lunch_break(self, date: str, lunch_break: bool): + """점심시간 사용 여부 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET lunch_break = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (lunch_break, date)) + + conn.commit() + conn.close() + + def update_dinner_break(self, date: str, dinner_break: bool): + """저녁시간 사용 여부 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET dinner_break = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (dinner_break, date)) + + conn.commit() + conn.close() + + def delete_work_record(self, date: str): + """특정 날짜의 근무 기록 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + # 먼저 해당 기록의 ID 조회 + cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) + record = cursor.fetchone() + + if record: + record_id = record[0] + + # 연관된 연장근무 적립 기록 삭제 + cursor.execute('DELETE FROM overtime_bank WHERE work_record_id = ?', (record_id,)) + + # 연관된 연장근무 사용 기록 삭제 + cursor.execute('DELETE FROM overtime_usage WHERE work_record_id = ?', (record_id,)) + + # 근무 기록 삭제 + cursor.execute('DELETE FROM work_records WHERE id = ?', (record_id,)) + + conn.commit() + conn.close() + + def get_work_records_by_range(self, start_date: str, end_date: str) -> List[Dict]: + """기간별 근무 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM work_records + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + ''', (start_date, end_date)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + # ===== 연장근무 관련 메서드 ===== + + def add_overtime_earned(self, work_record_id: int, earned_minutes: int, date: str): + """연장근무 적립""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (?, ?, ?) + ''', (work_record_id, earned_minutes, date)) + + conn.commit() + conn.close() + + def add_overtime_usage(self, work_record_id: int, used_minutes: int, + date: str, reason: str = None): + """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + cursor.execute(''' + INSERT INTO overtime_usage (work_record_id, used_minutes, date, reason) + VALUES (?, ?, ?, ?) + ''', (work_record_id, used_minutes, date, reason)) + + # work_records 테이블도 업데이트 (work_record_id가 있을 때만) + if work_record_id is not None: + cursor.execute(''' + UPDATE work_records + SET overtime_used = overtime_used + ? + WHERE id = ? + ''', (used_minutes, work_record_id)) + + conn.commit() + except Exception as e: + conn.rollback() + raise e + finally: + conn.close() + + def get_total_overtime_balance(self) -> int: + """총 연장근무 잔액 조회 (초기값 + 적립 - 사용)""" + conn = self.get_connection() + cursor = conn.cursor() + + # 초기값 (프로그램 사용 전 쌓인 연장근무) + initial_overtime = int(self.get_setting(INITIAL_OVERTIME_MINUTES, '0')) + + # 단일 쿼리로 적립과 사용을 동시에 조회 (원자성 보장) + cursor.execute(''' + SELECT + COALESCE((SELECT SUM(earned_minutes) FROM overtime_bank), 0) - + COALESCE((SELECT SUM(used_minutes) FROM overtime_usage), 0) AS balance + ''') + balance = cursor.fetchone()[0] + + conn.close() + + return initial_overtime + balance + + def get_today_overtime_usage(self) -> int: + """오늘 사용한 추가근무 시간 조회 (분)""" + from datetime import date + + today = date.today().isoformat() + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT SUM(used_minutes) + FROM overtime_usage + WHERE date = ? + ''', (today,)) + + used = cursor.fetchone()[0] or 0 + conn.close() + + return used + + def get_today_leave_minutes(self) -> int: + """오늘 사용한 연차/반차 시간 조회 (분)""" + from datetime import date + + today = date.today().isoformat() + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT SUM(days) + FROM leave_records + WHERE date = ? + ''', (today,)) + + days = cursor.fetchone()[0] or 0.0 + conn.close() + + return int(days * self.get_work_minutes()) + + def add_initial_overtime_balance(self, minutes: int): + """초기 연장근무 잔액 추가""" + from datetime import datetime + + conn = self.get_connection() + cursor = conn.cursor() + + today = datetime.now().date().isoformat() + + # work_record_id 없이 직접 추가 + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (NULL, ?, ?) + ''', (minutes, today)) + + conn.commit() + conn.close() + + def get_overtime_history(self, limit: int = 30) -> List[Dict]: + """연장근무 내역 조회 (적립 + 사용)""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT 'earned' as type, earned_minutes as minutes, date, + wr.clock_in, wr.clock_out + FROM overtime_bank ob + LEFT JOIN work_records wr ON ob.work_record_id = wr.id + UNION ALL + SELECT 'used' as type, used_minutes as minutes, date, + wr.clock_in, wr.clock_out + FROM overtime_usage ou + LEFT JOIN work_records wr ON ou.work_record_id = wr.id + ORDER BY date DESC + LIMIT ? + ''', (limit,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + # ===== 휴가 관련 메서드 ===== + + def add_leave_record(self, date: str, leave_type: str, days: float, memo: str = None): + """휴가 기록 추가""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO leave_records (date, leave_type, days, memo) + VALUES (?, ?, ?, ?) + ''', (date, leave_type, days, memo)) + + conn.commit() + conn.close() + + def get_leave_records(self, start_date: str = None, end_date: str = None, exclude_bulk: bool = False) -> List[Dict]: + """휴가 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + if start_date and end_date: + if exclude_bulk: + cursor.execute(''' + SELECT * FROM leave_records + WHERE date BETWEEN ? AND ? + AND COALESCE(memo, '') != '이전 사용분 일괄 추가' + ORDER BY date DESC + ''', (start_date, end_date)) + else: + cursor.execute(''' + SELECT * FROM leave_records + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + ''', (start_date, end_date)) + else: + if exclude_bulk: + cursor.execute(''' + SELECT * FROM leave_records + WHERE COALESCE(memo, '') != '이전 사용분 일괄 추가' + ORDER BY date DESC + ''') + else: + cursor.execute('SELECT * FROM leave_records ORDER BY date DESC') + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_annual_leave_balance(self) -> Tuple[float, float]: + """연차 잔여 조회 (총 연차, 사용한 연차) + + Note: + 현재 UI에서는 get_leave_balance()만 사용됨. + 이 메서드는 leave_records 테이블에서 직접 계산하므로 + settings.leave_balance와 불일치할 수 있음. + 향후 연차 관리 기능 개선 시 활용 가능. + """ + total = float(self.get_setting(ANNUAL_LEAVE_TOTAL, '15')) + + conn = self.get_connection() + cursor = conn.cursor() + + # manual 타입이 아닌 모든 연차 사용 기록 합산 + cursor.execute(''' + SELECT SUM(days) FROM leave_records + WHERE leave_type IS NULL OR leave_type NOT IN ('manual', 'bulk') + ''') + + used = cursor.fetchone()[0] or 0 + conn.close() + + return total, used + + # ===== 설정 관련 메서드 ===== + + def get_setting(self, key: str, default: str = None) -> str: + """설정 값 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) + row = cursor.fetchone() + conn.close() + + if row: + return row[0] + return default + + def set_setting(self, key: str, value: str): + """설정 값 저장""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (key, value)) + + conn.commit() + conn.close() + + # ===== 통계 관련 메서드 ===== + + def get_weekly_stats(self) -> Dict: + """주간 통계""" + from datetime import datetime, timedelta + + today = datetime.now().date() + week_ago = today - timedelta(days=7) + + records = self.get_work_records_by_range(week_ago.isoformat(), today.isoformat()) + + total_hours = sum(r.get('total_hours', 0) or 0 for r in records) + total_overtime = sum(r.get('overtime_minutes', 0) or 0 for r in records) + work_days = len([r for r in records if r.get('clock_out')]) + + return { + 'total_hours': total_hours, + 'total_overtime_minutes': total_overtime, + 'work_days': work_days, + 'avg_hours_per_day': total_hours / work_days if work_days > 0 else 0 + } + + def get_consecutive_overtime_days(self, threshold_minutes: int = 30) -> int: + """오늘부터 거꾸로 연속 연장근무한 일수. + + Args: + threshold_minutes: 연장근무로 카운트할 최소 분 (기본 30분) + Returns: + 연속 일수 (오늘 미적립이거나 휴무일이면 0) + """ + from datetime import date, timedelta + count = 0 + d = date.today() + for _ in range(60): # 최대 60일까지만 거슬러 검사 + rec = self.get_work_record(d.isoformat()) + if not rec or not rec.get('clock_out'): + break + if (rec.get('overtime_minutes') or 0) < threshold_minutes: + break + count += 1 + d -= timedelta(days=1) + return count + + def get_monthly_stats(self, year: int, month: int) -> Dict: + """월간 통계""" + from calendar import monthrange + + start_date = f"{year}-{month:02d}-01" + last_day = monthrange(year, month)[1] + end_date = f"{year}-{month:02d}-{last_day}" + + records = self.get_work_records_by_range(start_date, end_date) + + total_hours = sum(r.get('total_hours', 0) or 0 for r in records) + total_overtime = sum(r.get('overtime_minutes', 0) or 0 for r in records) + work_days = len([r for r in records if r.get('clock_out')]) + + return { + 'year': year, + 'month': month, + 'total_hours': total_hours, + 'total_overtime_minutes': total_overtime, + 'work_days': work_days, + 'records': records + } + + # ===== 휴가 관련 메서드 (중복 제거됨 - 356줄의 함수 사용) ===== + + def get_leave_record(self, date: str) -> Optional[Dict]: + """특정 날짜의 휴가 기록 조회""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM leave_records WHERE date = ? + ''', (date,)) + + row = cursor.fetchone() + conn.close() + + return dict(row) if row else None + + def get_all_leave_records(self, limit: int = 100) -> List[Dict]: + """모든 휴가 기록 조회 (최신순)""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM leave_records + ORDER BY date DESC + LIMIT ? + ''', (limit,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def delete_leave_record(self, leave_id: int): + """휴가 기록 삭제""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('DELETE FROM leave_records WHERE id = ?', (leave_id,)) + + conn.commit() + conn.close() + + # ===== 설정 관련 메서드 ===== + + def get_settings(self) -> Dict: + """설정 가져오기""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute('SELECT * FROM settings') + rows = cursor.fetchall() + conn.close() + + # 딕셔너리로 변환 + settings = {} + for row in rows: + key = row['key'] + value = row['value'] + + # 타입 변환 + if value.lower() in ['true', 'false']: + settings[key] = value.lower() == 'true' + else: + # 정수 변환 시도 (음수 포함) + try: + settings[key] = int(value) + except ValueError: + # float 변환 시도 + try: + settings[key] = float(value) + except ValueError: + settings[key] = value + + return settings + + def save_settings(self, settings: Dict): + """설정 저장. + + 키 동기화 처리: + - work_minutes 저장 시 work_hours도 갱신 (호환성) + - work_hours 저장 시 work_minutes도 갱신 + - annual_leave_days ↔ annual_leave_total 양방향 동기화 + """ + # 동기화 키 미리 보강 (호출자가 일부만 줘도 양쪽 다 저장) + synced = dict(settings) + + if 'work_minutes' in synced and 'work_hours' not in synced: + try: + # floor로 통일 (settings_view와 일관성: 450분 → 7시간) + synced['work_hours'] = int(float(synced['work_minutes'])) // 60 + except (ValueError, TypeError): + pass + elif 'work_hours' in synced and 'work_minutes' not in synced: + try: + synced['work_minutes'] = int(round(float(synced['work_hours']) * 60)) + except (ValueError, TypeError): + pass + + if 'annual_leave_days' in synced and 'annual_leave_total' not in synced: + synced['annual_leave_total'] = synced['annual_leave_days'] + elif 'annual_leave_total' in synced and 'annual_leave_days' not in synced: + synced['annual_leave_days'] = synced['annual_leave_total'] + + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + for key, value in synced.items(): + value_str = str(value) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value) + VALUES (?, ?) + ''', (key, value_str)) + + conn.commit() + conn.close() + + # ===== 외출 관련 메서드 ===== + + def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None) -> int: + """외출 기록 추가""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO break_records (work_record_id, date, break_out, reason) + VALUES (?, ?, ?, ?) + ''', (work_record_id, date, break_out, reason)) + + record_id = cursor.lastrowid + conn.commit() + conn.close() + return record_id + + def update_break_return(self, break_id: int, break_in: str): + """외출 복귀 시간 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + # 복귀 시간 업데이트 + cursor.execute(''' + UPDATE break_records + SET break_in = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_in, break_id)) + + # 총 외출 시간 계산 + cursor.execute(''' + SELECT break_out, break_in FROM break_records WHERE id = ? + ''', (break_id,)) + row = cursor.fetchone() + + if row and row['break_out'] and row['break_in']: + from datetime import datetime, timedelta + break_out_time = datetime.strptime(row['break_out'], "%H:%M:%S") + break_in_time = datetime.strptime(row['break_in'], "%H:%M:%S") + + # 복귀 시간이 외출 시간보다 이전이면 자정을 넘긴 것으로 판단 + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) # 복귀는 다음 날로 처리 + + total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + + # 음수 방지 (혹시 모를 케이스) + if total_minutes < 0: + total_minutes = 0 + + cursor.execute(''' + UPDATE break_records + SET total_minutes = ? + WHERE id = ? + ''', (total_minutes, break_id)) + + conn.commit() + conn.close() + + def get_today_break_records(self) -> List[Dict]: + """오늘의 외출 기록 조회""" + from datetime import date + today = date.today().isoformat() + return self.get_break_records_by_date(today) + + def get_break_records_by_date(self, date: str) -> List[Dict]: + """특정 날짜의 외출 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM break_records + WHERE date = ? + ORDER BY break_out ASC + ''', (date,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_active_break_record(self, target_date: str = None) -> Optional[Dict]: + """현재 진행 중인 외출 기록 조회 (복귀하지 않은 외출) + + Args: + target_date: 조회할 날짜 (YYYY-MM-DD), None이면 오늘 + """ + from datetime import date + if target_date is None: + target_date = date.today().isoformat() + + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM break_records + WHERE date = ? AND break_in IS NULL + ORDER BY break_out DESC + LIMIT 1 + ''', (target_date,)) + + row = cursor.fetchone() + conn.close() + + if row: + return dict(row) + return None + + def update_break_record(self, break_id: int, break_out: str, break_in: str = None, reason: str = None): + """외출 기록 수정""" + conn = self.get_connection() + cursor = conn.cursor() + + if break_in: + # 총 외출 시간 계산 + from datetime import datetime, timedelta + break_out_time = datetime.strptime(break_out, "%H:%M:%S") + break_in_time = datetime.strptime(break_in, "%H:%M:%S") + + # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) + + total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + + # 음수 방지 (혹시 모를 케이스) + if total_minutes < 0: + total_minutes = 0 + + cursor.execute(''' + UPDATE break_records + SET break_out = ?, break_in = ?, total_minutes = ?, reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_out, break_in, total_minutes, reason, break_id)) + else: + cursor.execute(''' + UPDATE break_records + SET break_out = ?, reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_out, reason, break_id)) + + conn.commit() + conn.close() + + def delete_break_record(self, break_id: int): + """외출 기록 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,)) + + conn.commit() + conn.close() + + def get_total_break_minutes_today(self) -> int: + """오늘의 총 외출 시간 (분), 진행 중인 외출 포함""" + from datetime import date, datetime + today = date.today().isoformat() + + conn = self.get_connection() + cursor = conn.cursor() + + # 완료된 외출 시간 합계 + cursor.execute(''' + SELECT SUM(total_minutes) FROM break_records + WHERE date = ? AND total_minutes IS NOT NULL + ''', (today,)) + + total = cursor.fetchone()[0] or 0 + + # 진행 중인 외출 시간 계산 + cursor.execute(''' + SELECT break_out FROM break_records + WHERE date = ? AND break_in IS NULL + ORDER BY break_out DESC + LIMIT 1 + ''', (today,)) + + active_break = cursor.fetchone() + if active_break: + break_out_str = active_break[0] + now = datetime.now() + break_out_time = datetime.strptime(f"{today} {break_out_str}", "%Y-%m-%d %H:%M:%S") + active_minutes = int((now - break_out_time).total_seconds() / 60) + total += active_minutes + + conn.close() + + return total + + def update_work_memo(self, date: str, memo: str): + """근무 기록 메모 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET memo = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (memo, date)) + + conn.commit() + conn.close() + + def get_leave_balance(self) -> float: + """연차 잔여 개수 조회 (총 연차 - 초기 사용량 - 프로그램 기록 사용량)""" + from datetime import datetime + + # 총 연차 일수 + total_annual = int(self.get_setting(ANNUAL_LEAVE_DAYS, '15')) + + # 초기 사용 연차 (프로그램 사용 전) + initial_leave_hours = float(self.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) + initial_leave_days = initial_leave_hours / 8.0 + + # 올해 프로그램에서 기록된 사용량 + current_year = datetime.now().year + all_leaves = self.get_all_leave_records(limit=365) + year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] + used_days = sum(r['days'] for r in year_leaves) + + # 잔여 = 총 - 초기사용 - 프로그램기록 + return total_annual - initial_leave_days - used_days + + def set_leave_balance(self, balance: float): + """연차 잔여 개수 설정""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('leave_balance', ?, CURRENT_TIMESTAMP) + ''', (str(balance),)) + + conn.commit() + conn.close() + + def use_leave(self, days: float, date: str, leave_type: str = "연차", memo: str = None): + """연차 사용 + + Args: + days: 사용할 연차 일수 (예: 1.0=하루, 0.5=반차, 0.25=반반차) + + Note: + leave_records 테이블의 'days' 컬럼에 일수를 저장함 + 예: 1.0 = 1일, 0.5 = 반차, 0.125 = 1시간(8분의 1일) + """ + current_balance = self.get_leave_balance() + + if current_balance < days: + raise ValueError(f"연차 잔여 개수가 부족합니다. (잔여: {current_balance}일)") + + conn = self.get_connection() + cursor = conn.cursor() + + # 연차 기록 추가 + cursor.execute(''' + INSERT INTO leave_records (date, leave_type, days, memo) + VALUES (?, ?, ?, ?) + ''', (date, leave_type, days, memo)) + + conn.commit() + conn.close() + + # 잔여 개수 차감 + self.set_leave_balance(current_balance - days) + + # ===== 공휴일 관련 메서드 ===== + + def add_holiday(self, date: str, name: str, is_recurring: bool = False) -> int: + """공휴일 추가 + + Args: + date: 공휴일 날짜 (YYYY-MM-DD) + name: 공휴일 이름 + is_recurring: 매년 반복 여부 (음력 명절 등은 False) + + Returns: + int: 추가된 공휴일 ID + """ + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO holidays (date, name, is_recurring, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ''', (date, name, is_recurring)) + + holiday_id = cursor.lastrowid + conn.commit() + conn.close() + + return holiday_id + + def delete_holiday(self, holiday_id: int): + """공휴일 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('DELETE FROM holidays WHERE id = ?', (holiday_id,)) + + conn.commit() + conn.close() + + def delete_holiday_by_date(self, date: str): + """날짜로 공휴일 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('DELETE FROM holidays WHERE date = ?', (date,)) + + conn.commit() + conn.close() + + def is_holiday(self, date: str) -> bool: + """해당 날짜가 공휴일인지 확인 + + Args: + date: 확인할 날짜 (YYYY-MM-DD) + + Returns: + bool: 공휴일이면 True + """ + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT id FROM holidays WHERE date = ? + ''', (date,)) + + result = cursor.fetchone() + conn.close() + + return result is not None + + def get_holiday(self, date: str) -> Optional[Dict]: + """해당 날짜의 공휴일 정보 조회 + + Args: + date: 조회할 날짜 (YYYY-MM-DD) + + Returns: + Dict: 공휴일 정보 또는 None + """ + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM holidays WHERE date = ? + ''', (date,)) + + row = cursor.fetchone() + conn.close() + + if row: + return dict(row) + return None + + def get_holidays_by_year(self, year: int) -> List[Dict]: + """해당 연도의 공휴일 목록 조회 + + Args: + year: 조회할 연도 + + Returns: + List[Dict]: 공휴일 목록 + """ + conn = self.get_connection() + cursor = conn.cursor() + + # LIKE 대신 정확한 날짜 범위 비교 사용 (더 효율적) + cursor.execute(''' + SELECT * FROM holidays + WHERE date >= ? AND date < ? + ORDER BY date ASC + ''', (f"{year}-01-01", f"{year + 1}-01-01")) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_all_holidays(self) -> List[Dict]: + """모든 공휴일 목록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM holidays + ORDER BY date ASC + ''') + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def add_korean_holidays(self, year: int): + """한국 공휴일 일괄 추가 (고정 공휴일만) + + Args: + year: 추가할 연도 + + Note: + 음력 기반 명절(설날, 추석)은 매년 날짜가 변경되므로 + 수동으로 추가해야 합니다. + """ + fixed_holidays = [ + (f"{year}-01-01", "신정"), + (f"{year}-03-01", "삼일절"), + (f"{year}-05-05", "어린이날"), + (f"{year}-06-06", "현충일"), + (f"{year}-08-15", "광복절"), + (f"{year}-10-03", "개천절"), + (f"{year}-10-09", "한글날"), + (f"{year}-12-25", "크리스마스"), + ] + + for date, name in fixed_holidays: + self.add_holiday(date, name, is_recurring=True) + + def add_korean_holidays_auto(self, year: int) -> int: + """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. + + Returns: + 추가된 공휴일 개수. 패키지 미설치 시 -1. + """ + try: + import holidays as _holidays + except ImportError: + return -1 + + kr = _holidays.country_holidays('KR', years=year) + added = 0 + for d, name in kr.items(): + date_str = d.isoformat() + # 이미 등록된 동일 날짜는 스킵 (중복 방지) + if not self.is_holiday(date_str): + self.add_holiday(date_str, name, is_recurring=False) + added += 1 + return added + + def copy_recurring_holidays(self, from_year: int, to_year: int): + """반복 공휴일을 다음 연도로 복사 + + Args: + from_year: 복사할 원본 연도 + to_year: 복사 대상 연도 + """ + holidays = self.get_holidays_by_year(from_year) + + for holiday in holidays: + if holiday['is_recurring']: + new_date = holiday['date'].replace(str(from_year), str(to_year)) + self.add_holiday(new_date, holiday['name'], is_recurring=True) diff --git a/core/event_monitor.py b/core/event_monitor.py new file mode 100644 index 0000000..c0f5744 --- /dev/null +++ b/core/event_monitor.py @@ -0,0 +1,359 @@ +""" +Windows 이벤트 뷰어 모니터 +시스템 부팅, 로그인, 종료 시간을 감지 +""" +import win32evtlog +import win32evtlogutil +import win32con +import win32security +import subprocess +from datetime import datetime, timedelta, date +from typing import Optional, List, Dict + + +class EventMonitor: + """Windows 이벤트 로그 모니터링 클래스""" + + # 이벤트 ID 정의 + EVENT_SYSTEM_BOOT = 6005 # 시스템 부팅 + EVENT_SYSTEM_START = 6009 # 시스템 시작 + EVENT_USER_LOGON = 4624 # 사용자 로그인 + EVENT_SYSTEM_SHUTDOWN = 6006 # 시스템 종료 + EVENT_SLEEP_ENTER = 42 # 절전 모드 진입 (Kernel-Power) + + def __init__(self): + self.server = None # 로컬 컴퓨터 + self.logtype = "System" # 시스템 로그 + + def get_boot_time_powershell(self) -> Optional[datetime]: + """ + PowerShell을 사용하여 시스템 부팅 시간 조회 + 오늘 부팅한 경우에만 반환 + Returns: + datetime: 오늘의 부팅 시간, 오늘 부팅 안 했으면 None + """ + try: + # PowerShell 명령어 실행 + result = subprocess.run( + ['powershell', '-Command', + '(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString("yyyy-MM-dd HH:mm:ss")'], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0 and result.stdout.strip(): + boot_time_str = result.stdout.strip() + # "yyyy-MM-dd HH:mm:ss" 형식으로 파싱 + boot_time = datetime.strptime(boot_time_str, "%Y-%m-%d %H:%M:%S") + + # 오늘 부팅한 경우에만 반환 + today = datetime.now().date() + if boot_time.date() == today: + return boot_time + else: + # 오늘 부팅하지 않음 (절전 모드에서 깨어난 경우) + print(f"Last boot was on {boot_time.date()}, not today ({today})") + return None + + except Exception as e: + print(f"PowerShell boot time query failed: {e}") + + return None + + def get_today_boot_time(self) -> Optional[datetime]: + """ + 오늘 첫 부팅 시간 조회 (절전 모드 복귀 포함) + 오늘의 가장 빠른 시스템 시작 시간을 반환 + Returns: + datetime: 첫 부팅/시작 시간, 없으면 None + """ + today = datetime.now().date() + earliest_time = None + + # Kernel-General (Event ID 1, 12) - 시스템 시작/재개 + kernel_general_events = self._get_events_by_id(1, limit=50) + for event in kernel_general_events: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + # Event ID 12도 확인 + kernel_general_12 = self._get_events_by_id(12, limit=50) + for event in kernel_general_12: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + # Kernel-General에서 찾았으면 반환 + if earliest_time: + return earliest_time + + # 못 찾았으면 전통적인 부팅 이벤트 확인 + # 6005 - 시스템 부팅 + boot_events = self._get_events_by_id(self.EVENT_SYSTEM_BOOT, limit=10) + for event in boot_events: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + # 6009 - 시스템 시작 + start_events = self._get_events_by_id(self.EVENT_SYSTEM_START, limit=10) + for event in start_events: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + return earliest_time + + def get_last_shutdown_time(self) -> Optional[datetime]: + """ + 마지막 종료 시간 조회 + Returns: + datetime: 마지막 종료 시간, 없으면 None + """ + shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=5) + + if shutdown_events: + return shutdown_events[0]['time'] + + return None + + def get_yesterday_shutdown_time(self) -> Optional[datetime]: + """ + 어제의 종료 시간 조회 (퇴근 시간으로 사용) + Returns: + datetime: 어제의 종료 시간, 없으면 None + """ + yesterday = (datetime.now().date() - timedelta(days=1)) + shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=20) + + for event in shutdown_events: + event_time = event['time'] + if event_time.date() == yesterday: + return event_time + + return None + + def get_shutdown_time_by_date(self, target_date: date) -> Optional[datetime]: + """ + 특정 날짜의 종료 시간 조회 (정상 종료 + 절전 모드) + Args: + target_date: 조회할 날짜 + Returns: + datetime: 해당 날짜의 종료 시간, 없으면 None + """ + # 정상 종료 이벤트 검색 + shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=2000) + + # 절전 모드 진입 이벤트도 검색 + sleep_events = self._get_events_by_id(self.EVENT_SLEEP_ENTER, limit=2000) + + # 해당 날짜의 모든 종료/절전 시간을 찾아서 가장 늦은 시간 반환 + matching_events = [] + + # 정상 종료 이벤트 확인 + for event in shutdown_events: + event_time = event['time'] + if event_time.date() == target_date: + matching_events.append(('shutdown', event_time)) + + # 절전 모드 이벤트 확인 + for event in sleep_events: + event_time = event['time'] + if event_time.date() == target_date: + matching_events.append(('sleep', event_time)) + + if matching_events: + # 가장 늦은 시간 반환 (타입에 관계없이) + latest_type, latest_time = max(matching_events, key=lambda x: x[1]) + return latest_time + + return None + + def get_today_logon_time(self) -> Optional[datetime]: + """ + 오늘 첫 로그인 시간 조회 (보조 수단) + Returns: + datetime: 첫 로그인 시간, 없으면 None + """ + try: + today = datetime.now().date() + hand = win32evtlog.OpenEventLog(self.server, "Security") + flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ + + events = [] + total = 0 + + while total < 100: # 최근 100개만 확인 + event_list = win32evtlog.ReadEventLog(hand, flags, 0) + if not event_list: + break + + for event in event_list: + if event.EventID == self.EVENT_USER_LOGON: + event_time = datetime.fromtimestamp(int(event.TimeGenerated)) + if event_time.date() == today: + events.append(event_time) + + total += len(event_list) + + win32evtlog.CloseEventLog(hand) + + if events: + return min(events) # 가장 빠른 로그인 시간 + + except Exception as e: + print(f"로그인 이벤트 조회 실패: {e}") + + return None + + def _get_events_by_id(self, event_id: int, limit: int = 10) -> List[Dict]: + """ + 특정 이벤트 ID로 이벤트 조회 + Args: + event_id: 이벤트 ID + limit: 조회할 최대 개수 + Returns: + List[Dict]: 이벤트 목록 + """ + try: + hand = win32evtlog.OpenEventLog(self.server, self.logtype) + flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ + + events = [] + total = 0 + + while len(events) < limit and total < 10000: + event_list = win32evtlog.ReadEventLog(hand, flags, 0) + if not event_list: + break + + for event in event_list: + # 하위 16비트가 실제 이벤트 ID + actual_event_id = event.EventID & 0xFFFF + + if actual_event_id == event_id: + # pywintypes.datetime을 Python datetime으로 변환 + time_generated = event.TimeGenerated + if hasattr(time_generated, 'timestamp'): + # pywintypes.datetime인 경우 + event_time = datetime.fromtimestamp(time_generated.timestamp()) + else: + # 이미 타임스탬프인 경우 + event_time = datetime.fromtimestamp(int(time_generated)) + + events.append({ + 'event_id': actual_event_id, + 'time': event_time, + 'source': event.SourceName + }) + + if len(events) >= limit: + break + + total += len(event_list) + + win32evtlog.CloseEventLog(hand) + return events + + except Exception as e: + print(f"이벤트 조회 실패: {e}") + return [] + + def get_work_start_time(self) -> Optional[datetime]: + """ + 출근 시간 자동 감지 (부팅 또는 로그인 중 빠른 시간) + 우선순위: + 1. PowerShell을 통한 부팅 시간 (가장 확실) + 2. 이벤트 뷰어를 통한 부팅 시간 + 3. 이벤트 뷰어를 통한 로그인 시간 + Returns: + datetime: 출근 시간, 없으면 None + """ + # 1순위: PowerShell로 부팅 시간 조회 (가장 확실한 방법) + powershell_boot_time = self.get_boot_time_powershell() + if powershell_boot_time: + return powershell_boot_time + + # 2순위: 이벤트 뷰어로 부팅 시간 조회 + boot_time = self.get_today_boot_time() + if boot_time: + return boot_time + + # 3순위: 로그인 시간 조회 + logon_time = self.get_today_logon_time() + if logon_time: + return logon_time + + return None + + def test_event_log_access(self) -> bool: + """ + 이벤트 로그 접근 가능 여부 테스트 + Returns: + bool: 접근 가능하면 True + """ + try: + hand = win32evtlog.OpenEventLog(self.server, self.logtype) + win32evtlog.CloseEventLog(hand) + return True + except Exception as e: + print(f"이벤트 로그 접근 실패: {e}") + print("관리자 권한으로 실행해야 할 수 있습니다.") + return False + + +# 테스트 코드 +if __name__ == "__main__": + import sys + # UTF-8 출력 설정 + if sys.platform == 'win32': + import codecs + sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') + + monitor = EventMonitor() + + print("=== Windows Event Monitor Test ===\n") + + # PowerShell 부팅 시간 (1순위) + print("1. PowerShell Boot Time Check:") + powershell_boot = monitor.get_boot_time_powershell() + if powershell_boot: + print(f" [OK] Boot time: {powershell_boot.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print(" [X] PowerShell query failed") + + print("\n2. Event Log Access Check:") + if monitor.test_event_log_access(): + print(" [OK] Event log access successful") + else: + print(" [X] Event log access failed (try running as administrator)") + + print("\n3. Event Viewer Boot Time Check:") + boot_time = monitor.get_today_boot_time() + if boot_time: + print(f" [OK] Boot time: {boot_time.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print(" [X] No boot record found today") + + print("\n4. Event Viewer Logon Time Check:") + logon_time = monitor.get_today_logon_time() + if logon_time: + print(f" [OK] Logon time: {logon_time.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print(" [X] No logon record found today") + + print("\n" + "="*50) + # 출근 시간 (자동 감지 - 최종 결과) + work_start = monitor.get_work_start_time() + if work_start: + print(f"FINAL: Detected work start time") + print(f" {work_start.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print("FINAL: Work start time detection FAILED") + print("="*50) diff --git a/core/i18n.py b/core/i18n.py new file mode 100644 index 0000000..44f0896 --- /dev/null +++ b/core/i18n.py @@ -0,0 +1,568 @@ +""" +경량 i18n. + +`tr('key')` → 현재 언어의 번역 문자열. 키 미존재 시 ko 폴백, ko도 없으면 +키 그대로 반환 (미번역도 동작). 점진 도입 가능. + +새 언어 추가: +1. _DICT['en'] 옆에 'ja'/'zh' 등 추가 +2. 사용자가 설정 → 언어 콤보에서 선택 +""" +from __future__ import annotations + +_current_lang = 'ko' + +_DICT = { + 'ko': { + # === 메뉴/버튼 === + 'menu.stats': '통계', + 'menu.calendar': '캘린더', + 'menu.daily_report': '일일보고', + 'menu.help': '도움말', + 'menu.settings': '설정', + 'btn.clock_out': '퇴근하기', + 'btn.clock_out_cancel': '🔄 퇴근 취소', + 'btn.lunch_add': '점심시간 추가', + 'btn.lunch_applied': '점심시간 (적용됨)', + 'btn.dinner_add': '저녁시간 추가', + 'btn.dinner_applied': '저녁시간 (적용됨)', + 'btn.break_out': '🚪 외출 시작', + 'btn.break_in': '↩️ 복귀', + 'btn.save': '💾 저장', + 'btn.close': '닫기', + 'btn.apply': '적용', + 'btn.cancel': '취소', + 'btn.add': '추가', + 'btn.delete': '삭제', + 'btn.edit': '편집', + 'btn.confirm': '확인', + 'btn.ok': '확인', + + # === 윈도우/다이얼로그 제목 === + 'window.main_title': '퇴근시간 계산기', + 'window.settings': '⚙️ 설정', + 'window.help': '📖 사용 설명서', + 'window.stats': '📊 근무 통계', + 'window.calendar': '📅 캘린더', + 'window.mini_widget': '퇴근시간', + 'window.clock_in_dialog': '출근 시간', + 'window.break_view': '외출 관리', + 'window.overtime_view': '연장근무 관리', + 'window.leave_view': '연차 관리', + + # === 라벨 === + 'label.remaining': '남은 시간', + 'label.overtime_progress': '추가 근무 중', + 'label.expected_clock_out': '예상 퇴근', + 'label.clock_in_time': '출근 시간', + 'label.clock_out_time': '퇴근 시간', + 'label.work_hours_label': '하루 기본 근무:', + 'label.lunch_default': '점심시간 기본:', + 'label.dinner_default': '저녁시간 기본:', + 'label.work_pattern': '근무 패턴:', + 'label.time_format': '시간 형식:', + 'label.theme': '테마:', + 'label.language': '언어 / Language:', + 'label.unit_hour': '시간', + 'label.unit_minute': '분', + 'label.unit_day': '일', + 'label.unit_count': '개', + 'label.weekday_mon': '월', + 'label.weekday_tue': '화', + 'label.weekday_wed': '수', + 'label.weekday_thu': '목', + 'label.weekday_fri': '금', + 'label.weekday_sat': '토', + 'label.weekday_sun': '일', + 'label.am': '오전', + 'label.pm': '오후', + + # === 알림 === + 'notif.clock_out_soon.title': '⏰ 퇴근 시간 임박', + 'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!', + 'notif.lunch_reminder.title': '🍱 점심시간 등록', + 'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?', + 'notif.overtime_earning.title': '🔥 연장근무 적립 예정', + 'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!', + 'notif.overtime_threshold.title': '💰 연장근무 적립 알림', + 'notif.overtime_threshold.body': '적립된 연장근무가 {hours:.1f}시간 입니다.\n사용을 고려해보세요!', + 'notif.health.title': '⚠️ 건강 경고', + 'notif.health.body': '{days}일 연속 연장근무 중입니다.\n건강을 챙기세요!', + 'notif.weekly_52.title': '🚨 주 52시간 초과', + 'notif.weekly_52.body': '이번 주 총 근무시간이 {hours:.1f}시간입니다.\n법정 근로시간을 초과했습니다!', + + # === 메시지박스 === + 'msg.save_success.title': '저장 완료', + 'msg.save_success.body': '설정이 저장되었습니다.', + 'msg.input_error.title': '입력 오류', + 'msg.work_min_too_small': '하루 기본 근무 시간은 최소 30분 이상이어야 합니다.', + 'msg.no_clock_in.title': '외출 불가', + 'msg.no_clock_in.body': '출근하지 않은 상태입니다.', + 'msg.already_break.body': '이미 외출 중입니다.', + 'msg.no_record.title': '기록 없음', + 'msg.no_record.body': '오늘 출근 기록이 없습니다.', + 'msg.confirm_delete.title': '삭제 확인', + 'msg.no_data.title': '데이터 없음', + + # === 트레이 === + 'tray.open': '프로그램 열기', + 'tray.mini_widget': '📌 미니 위젯', + 'tray.toggle_lunch': '🍱 점심시간 토글', + 'tray.quit': '종료', + 'tray.tooltip_remaining': '퇴근까지: {time}', + 'tray.tooltip_overtime': '추가 근무 중: {time}', + 'tray.background': '프로그램이 트레이에서 실행 중입니다.', + + # === 그룹 박스 === + 'group.work_time': '근무 시간 설정', + 'group.notification': '알림 및 표시 설정', + 'group.overtime': '연장근무 설정', + 'group.leave': '휴가 설정', + 'group.holiday': '공휴일 설정', + 'group.data': '데이터 관리', + + # === StatsView === + 'stats.title': '근무 통계', + 'stats.tab_weekly': '주간', + 'stats.tab_monthly': '월간', + 'stats.tab_pattern': '패턴 분석', + 'stats.weekly_summary': '이번 주 요약', + 'stats.monthly_summary': '이번 달 요약', + 'stats.total_hours': '총 근무시간:', + 'stats.work_days': '근무일수:', + 'stats.avg_hours': '평균 근무시간:', + 'stats.total_overtime': '총 연장근무:', + 'stats.pattern_insights': '근무 패턴 인사이트', + 'stats.analyzing': '데이터를 분석 중입니다...', + 'stats.no_data': '아직 충분한 데이터가 없습니다.', + + # === CalendarView === + 'cal.title': '월간 근무 캘린더', + 'cal.year_label': '년', + 'cal.month_label': '월', + 'cal.prev': '◀ 이전', + 'cal.next': '다음 ▶', + 'cal.today': '오늘', + 'cal.no_record': '기록 없음', + 'cal.edit_record': '기록 편집', + + # === HelpView (각 탭의 큰 HTML은 별도 키) === + 'help.tab_intro': '👋 시작하기', + 'help.tab_work_hours': '🕘 근무시간', + 'help.tab_overtime': '🏦 연장근무', + 'help.tab_leave': '🌴 연차/휴가', + 'help.tab_break': '🚪 외출/저녁', + 'help.tab_faq': '❓ 자주 묻는 질문', + }, + 'en': { + # === Menu/Buttons === + 'menu.stats': 'Stats', + 'menu.calendar': 'Calendar', + 'menu.daily_report': 'Daily Report', + 'menu.help': 'Help', + 'menu.settings': 'Settings', + 'btn.clock_out': 'Clock Out', + 'btn.clock_out_cancel': '🔄 Cancel Clock-out', + 'btn.lunch_add': 'Add Lunch', + 'btn.lunch_applied': 'Lunch (Applied)', + 'btn.dinner_add': 'Add Dinner', + 'btn.dinner_applied': 'Dinner (Applied)', + 'btn.break_out': '🚪 Start Break', + 'btn.break_in': '↩️ Return', + 'btn.save': '💾 Save', + 'btn.close': 'Close', + 'btn.apply': 'Apply', + 'btn.cancel': 'Cancel', + 'btn.add': 'Add', + 'btn.delete': 'Delete', + 'btn.edit': 'Edit', + 'btn.confirm': 'Confirm', + 'btn.ok': 'OK', + + # === Windows === + 'window.main_title': 'Clock-out Time Calculator', + 'window.settings': '⚙️ Settings', + 'window.help': '📖 User Guide', + 'window.stats': '📊 Statistics', + 'window.calendar': '📅 Calendar', + 'window.mini_widget': 'Clock-out', + 'window.clock_in_dialog': 'Clock-in Time', + 'window.break_view': 'Break Management', + 'window.overtime_view': 'Overtime Management', + 'window.leave_view': 'Leave Management', + + # === Labels === + 'label.remaining': 'Remaining', + 'label.overtime_progress': 'Overtime', + 'label.expected_clock_out': 'Expected Clock-out', + 'label.clock_in_time': 'Clock-in Time', + 'label.clock_out_time': 'Clock-out Time', + 'label.work_hours_label': 'Daily work:', + 'label.lunch_default': 'Lunch break:', + 'label.dinner_default': 'Dinner break:', + 'label.work_pattern': 'Work pattern:', + 'label.time_format': 'Time format:', + 'label.theme': 'Theme:', + 'label.language': 'Language / 언어:', + 'label.unit_hour': 'h', + 'label.unit_minute': 'min', + 'label.unit_day': 'day(s)', + 'label.unit_count': '', + 'label.weekday_mon': 'Mon', + 'label.weekday_tue': 'Tue', + 'label.weekday_wed': 'Wed', + 'label.weekday_thu': 'Thu', + 'label.weekday_fri': 'Fri', + 'label.weekday_sat': 'Sat', + 'label.weekday_sun': 'Sun', + 'label.am': 'AM', + 'label.pm': 'PM', + + # === Notifications === + 'notif.clock_out_soon.title': '⏰ Clock-out Soon', + 'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!", + 'notif.lunch_reminder.title': '🍱 Lunch Reminder', + 'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?", + 'notif.overtime_earning.title': '🔥 Overtime Will Accrue', + 'notif.overtime_earning.body': "{time_str} of overtime will be banked today!", + 'notif.overtime_threshold.title': '💰 Overtime Balance High', + 'notif.overtime_threshold.body': "{hours:.1f} hours of overtime banked.\nConsider using some.", + 'notif.health.title': '⚠️ Health Warning', + 'notif.health.body': "{days} consecutive days of overtime.\nTake care of your health!", + 'notif.weekly_52.title': '🚨 Weekly 52h Exceeded', + 'notif.weekly_52.body': "This week's total work hours: {hours:.1f}\nLegal limit exceeded!", + + # === Message Boxes === + 'msg.save_success.title': 'Saved', + 'msg.save_success.body': 'Settings saved successfully.', + 'msg.input_error.title': 'Input Error', + 'msg.work_min_too_small': 'Daily work time must be at least 30 minutes.', + 'msg.no_clock_in.title': 'Cannot Take Break', + 'msg.no_clock_in.body': 'Not clocked in.', + 'msg.already_break.body': 'Already on break.', + 'msg.no_record.title': 'No Record', + 'msg.no_record.body': 'No clock-in record for today.', + 'msg.confirm_delete.title': 'Confirm Delete', + 'msg.no_data.title': 'No Data', + + # === Tray === + 'tray.open': 'Open Program', + 'tray.mini_widget': '📌 Mini Widget', + 'tray.toggle_lunch': '🍱 Toggle Lunch', + 'tray.quit': 'Quit', + 'tray.tooltip_remaining': 'Until clock-out: {time}', + 'tray.tooltip_overtime': 'Overtime: {time}', + 'tray.background': 'Program is running in the tray.', + + # === Groups === + 'group.work_time': 'Work Time Settings', + 'group.notification': 'Notifications & Display', + 'group.overtime': 'Overtime Settings', + 'group.leave': 'Leave Settings', + 'group.holiday': 'Holidays', + 'group.data': 'Data Management', + + # === StatsView === + 'stats.title': 'Work Statistics', + 'stats.tab_weekly': 'Weekly', + 'stats.tab_monthly': 'Monthly', + 'stats.tab_pattern': 'Patterns', + 'stats.weekly_summary': 'This Week', + 'stats.monthly_summary': 'This Month', + 'stats.total_hours': 'Total hours:', + 'stats.work_days': 'Work days:', + 'stats.avg_hours': 'Avg hours/day:', + 'stats.total_overtime': 'Total overtime:', + 'stats.pattern_insights': 'Work Pattern Insights', + 'stats.analyzing': 'Analyzing...', + 'stats.no_data': 'Not enough data yet.', + + # === CalendarView === + 'cal.title': 'Monthly Calendar', + 'cal.year_label': 'Y', + 'cal.month_label': 'M', + 'cal.prev': '◀ Prev', + 'cal.next': 'Next ▶', + 'cal.today': 'Today', + 'cal.no_record': 'No record', + 'cal.edit_record': 'Edit record', + + # === HelpView === + 'help.tab_intro': '👋 Getting Started', + 'help.tab_work_hours': '🕘 Work Hours', + 'help.tab_overtime': '🏦 Overtime', + 'help.tab_leave': '🌴 Leave', + 'help.tab_break': '🚪 Break/Dinner', + 'help.tab_faq': '❓ FAQ', + }, +} + + +# === HelpView 큰 HTML 콘텐츠 (별도 사전) === +_HELP_HTML = { + 'ko': { + 'help.html.intro': """ +

👋 환영합니다!

+

퇴근시간 계산기는 출근 시간 자동 감지부터 연장근무 적립·사용까지 + 하루 근무를 정리해 주는 데스크톱 앱입니다.

+ +

한눈에 보는 기본 흐름

+
    +
  1. 출근 — 컴퓨터 켜진 시각이 자동으로 출근 시간으로 기록돼요.
  2. +
  3. 근무 중 — 메인 화면에 퇴근까지 남은 시간이 1초마다 갱신됩니다.
  4. +
  5. 점심/저녁 — 식사 시간 버튼을 누르면 그만큼 근무시간이 늘어납니다.
  6. +
  7. 외출 — "외출 시작/복귀" 버튼으로 잠깐 자리 비운 시간을 추적합니다.
  8. +
  9. 퇴근 — 퇴근 버튼을 누르면 연장근무가 30분 단위로 자동 적립됩니다.
  10. +
+ +

처음 켰다면 꼭 확인하세요

+ + """, + 'help.html.work_hours': """ +

🕘 근무시간 설정

+ +

표준 근무 / 단축근무 / 시간제 모두 지원

+ + +

💡 단축근무 사용자 안내

+

예) 하루 7시간 30분 근무 + 점심 30분

+
    +
  1. 설정 → 근무 시간 → 근무 패턴에서 + "단축근무 7시간 30분 (점심 30분)" 선택
  2. +
  3. 또는 직접 입력: 하루 기본 근무7 시간 30 분, + 점심시간 기본30 분
  4. +
  5. 저장 클릭하면 즉시 메인 화면 계산이 갱신됩니다.
  6. +
+ +

점심시간 자동 적용

+

설정에서 "자동 적용"을 체크하면 출근 후 4시간 경과 시 + 점심시간이 자동으로 켜집니다.

+ """, + 'help.html.overtime': """ +

🏦 연장근무 30분 단위 적립 시스템

+ +

적립 규칙

+

정규 퇴근시간 이후 일한 시간은 30분 단위로 절삭되어 적립됩니다.

+ + +

사용 방법

+

적립된 연장근무는 메인 화면 "30분 사용" / "1시간 사용" 버튼으로 + 쓸 수 있어요. 사용한 만큼 그날 퇴근시간이 앞당겨집니다.

+ +

주말·공휴일 근무

+

주말 또는 등록된 공휴일에 일한 시간은 + 모든 시간이 연장근무로 적립됩니다.

+ """, + 'help.html.leave': """ +

🌴 연차·반차 관리

+ +

연차 잔액 자동 계산

+

잔액 = 연간 연차 − (프로그램 외 사용분 + 프로그램에서 기록된 사용분)

+ +

반차·반반차 지원

+ + +

단축근무자의 연차 환산

+

1일 연차의 시간 길이는 설정의 하루 기본 근무를 따릅니다. + 예: 7시간 30분 근무자는 1일 연차가 7시간 30분(=450분)으로 환산됩니다.

+ """, + 'help.html.break': """ +

🚪 외출 / 저녁시간

+ +

외출 (잠깐 자리 비움)

+

병원, 잠깐 외근 등으로 자리를 비울 때 외출 시작 → 복귀 버튼으로 + 시간을 추적하세요.

+ +

화면 잠금 자동 외출

+

설정에서 "화면 잠금 시 자동 외출/복귀"를 켜면 PC 잠금 시 자동으로 + 외출이 시작되고, 풀리면 복귀로 처리됩니다.

+ +

저녁시간

+

야근하면서 저녁을 먹는다면 저녁시간 추가 버튼을 눌러주세요.

+ """, + 'help.html.faq': """ +

❓ 자주 묻는 질문

+ +

Q. 출근 시간이 잘못 잡혔어요

+

메인 화면 출근 시각 옆 편집(연필) 아이콘으로 수정할 수 있어요.

+ +

Q. 단축근무 7시간 30분으로 설정하고 싶어요

+

설정 → 근무 시간 → 근무 패턴에서 프리셋을 선택하거나 시·분을 직접 입력하세요.

+ +

Q. 데이터는 어디에 저장되나요?

+

실행 폴더의 database.db (SQLite). 자동 백업은 + ~/.clockout_backups/에 1일 1회 회전됩니다.

+ + """, + }, + 'en': { + 'help.html.intro': """ +

👋 Welcome!

+

Clock-out Time Calculator is a desktop app that organizes your daily work + — from auto-detecting clock-in time to banking and using overtime.

+ +

Basic Flow

+
    +
  1. Clock in — System boot time is auto-recorded as your clock-in.
  2. +
  3. Working — Remaining time updates every second on the main screen.
  4. +
  5. Lunch/Dinner — Press the meal buttons to extend work time.
  6. +
  7. Break — Track time away with "Start Break / Return" buttons.
  8. +
  9. Clock out — Press the button; overtime is banked in 30-min units.
  10. +
+ +

First-Time Setup

+ + """, + 'help.html.work_hours': """ +

🕘 Work Time Settings

+ +

Supports Standard / Reduced / Part-time

+ + +

💡 For Reduced-Hours Users

+

Example: 7h30m daily + 30-min lunch

+
    +
  1. Settings → Work Time → pick "Reduced 7h30m (30-min lunch)" preset
  2. +
  3. Or enter directly: 7h 30min daily, 30min lunch
  4. +
  5. Click Save — main screen recalculates immediately.
  6. +
+ +

Auto Lunch

+

Enable "Auto Apply" in settings to automatically turn on lunch + after 4 hours from clock-in.

+ """, + 'help.html.overtime': """ +

🏦 30-Minute Overtime Banking

+ +

Banking Rule

+

Time worked past your scheduled clock-out is truncated to 30-min units.

+ + +

Using Banked Overtime

+

Use buttons on the main screen to consume banked overtime. + Each unit lets you clock out earlier on a chosen day.

+ +

Weekend/Holiday Work

+

All hours worked on weekends or registered holidays are + banked entirely as overtime.

+ """, + 'help.html.leave': """ +

🌴 Annual Leave Management

+ +

Auto Balance Calculation

+

Balance = annual leave − (pre-program usage + recorded usage)

+ +

Half / Quarter Day

+ + +

Reduced-Hours Conversion

+

1 leave day equals your configured daily work time. + Example: 7h30m worker → 1 day = 7h30m (450 min).

+ """, + 'help.html.break': """ +

🚪 Break / Dinner

+ +

Break (Briefly Away)

+

For short absences (medical, errands), use Start Break / Return.

+ +

Auto Break on Screen Lock

+

Enable "Auto break on screen lock" in settings — when the PC locks, + a break starts automatically; on unlock, you return.

+ +

Dinner

+

For overtime with dinner, press Add Dinner.

+ """, + 'help.html.faq': """ +

❓ FAQ

+ +

Q. Clock-in time is wrong

+

Click the pencil icon next to clock-in time on the main screen to edit.

+ +

Q. How to set 7h30m work day?

+

Settings → Work Time → pick the preset or enter hours/minutes directly.

+ +

Q. Where is data stored?

+

database.db in the program folder (SQLite). Daily auto-backups + rotate in ~/.clockout_backups/.

+ + """, + }, +} + + +def set_language(lang: str) -> None: + global _current_lang + if lang in _DICT: + _current_lang = lang + + +def get_language() -> str: + return _current_lang + + +def tr(key: str, **kwargs) -> str: + """번역 조회. 키 미존재 시 ko 폴백 → 키 그대로.""" + table = _DICT.get(_current_lang, _DICT['ko']) + text = table.get(key) or _DICT['ko'].get(key) or key + if kwargs: + try: + return text.format(**kwargs) + except (KeyError, IndexError): + return text + return text + + +def tr_html(key: str) -> str: + """HTML 콘텐츠 조회 (HelpView용).""" + table = _HELP_HTML.get(_current_lang, _HELP_HTML['ko']) + return table.get(key) or _HELP_HTML['ko'].get(key) or f"

missing: {key}

" + + +def available_languages() -> list: + return list(_DICT.keys()) + + +def language_label(code: str) -> str: + return {'ko': '한국어', 'en': 'English'}.get(code, code) diff --git a/core/notifier.py b/core/notifier.py new file mode 100644 index 0000000..c267813 --- /dev/null +++ b/core/notifier.py @@ -0,0 +1,187 @@ +""" +알림 시스템 +퇴근 시간 알림, 점심시간 알림 등 +""" +from datetime import datetime, timedelta +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, +) +from core.i18n import tr + + +class Notifier(QObject): + """알림 시스템 클래스""" + + notification_signal = pyqtSignal(str, str) # (제목, 메시지) + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db # 설정 키 가드용 (None이면 모든 알림 활성) + self.timer = QTimer() + self.timer.timeout.connect(self.check_notifications) + self.timer.start(60000) # 1분마다 체크 + + # 알림 상태 추적 + self.notified_30min = False + self.notified_lunch = False + self.notified_overtime = False + self.notified_health = False + self.notified_weekly = False + self.notified_threshold = False + + self.last_check_date = datetime.now().date() + + def _enabled(self, key: str) -> bool: + """설정에서 해당 알림이 켜져 있는지. db 없으면 기본 켜짐.""" + if self.db is None: + return True + val = self.db.get_setting(key, 'true') + return str(val).lower() in ('1', 'true', 'yes') + + def check_notifications(self): + """알림 체크""" + now = datetime.now() + current_date = now.date() + + # 날짜가 바뀌면 알림 상태 리셋 + if current_date != self.last_check_date: + self.reset_notifications() + self.last_check_date = current_date + + def check_clock_out_soon(self, clock_out_time: datetime, current_time: Optional[datetime] = None): + """ + 퇴근 30분 전 알림 + Args: + clock_out_time: 예상 퇴근 시간 + current_time: 현재 시간 (None이면 지금) + """ + if current_time is None: + current_time = datetime.now() + + if not self._enabled(NOTIF_CLOCK_OUT): + return + time_diff = clock_out_time - current_time + + # 30분 이내, 아직 알림 안 했으면 + if 0 < time_diff.total_seconds() <= 1800 and not self.notified_30min: + minutes_left = int(time_diff.total_seconds() / 60) + self.notification_signal.emit( + tr('notif.clock_out_soon.title'), + tr('notif.clock_out_soon.body', minutes=minutes_left), + ) + self.notified_30min = True + + 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: 현재 시간 + """ + 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: + self.notification_signal.emit( + tr('notif.lunch_reminder.title'), + tr('notif.lunch_reminder.body'), + ) + self.notified_lunch = True + + def check_overtime_earning(self, overtime_minutes: int): + """ + 연장근무 적립 알림 + Args: + overtime_minutes: 예상 연장근무 시간 (분) + """ + if not self._enabled(NOTIF_OVERTIME): + return + if overtime_minutes >= 30 and not self.notified_overtime: + hours = overtime_minutes // 60 + mins = overtime_minutes % 60 + + from utils.time_format import format_hours_minutes + time_str = format_hours_minutes(overtime_minutes, omit_zero_minutes=True) + + self.notification_signal.emit( + tr('notif.overtime_earning.title'), + tr('notif.overtime_earning.body', time_str=time_str), + ) + self.notified_overtime = True + + def notify_overtime_threshold(self, total_overtime_hours: float): + """연장근무 누적 알림 (20시간 이상)""" + if not self._enabled(NOTIF_OVERTIME): + return + if total_overtime_hours >= 20 and not self.notified_threshold: + self.notification_signal.emit( + tr('notif.overtime_threshold.title'), + tr('notif.overtime_threshold.body', hours=total_overtime_hours), + ) + self.notified_threshold = True + + def notify_health_warning(self, consecutive_overtime_days: int): + """건강 경고 (연속 연장근무 일수)""" + if not self._enabled(NOTIF_HEALTH): + return + if consecutive_overtime_days >= 3 and not self.notified_health: + self.notification_signal.emit( + tr('notif.health.title'), + tr('notif.health.body', days=consecutive_overtime_days), + ) + self.notified_health = True + + def notify_weekly_hours(self, total_hours: float): + """주 52시간 경고""" + if not self._enabled(NOTIF_HEALTH): + return + if total_hours > 52 and not self.notified_weekly: + self.notification_signal.emit( + tr('notif.weekly_52.title'), + tr('notif.weekly_52.body', hours=total_hours), + ) + self.notified_weekly = True + + def reset_notifications(self): + """알림 상태 리셋 (날짜 변경 시)""" + self.notified_30min = False + self.notified_lunch = False + self.notified_overtime = False + self.notified_health = False + self.notified_weekly = False + self.notified_threshold = False + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication, QMessageBox + import sys + + app = QApplication(sys.argv) + + notifier = Notifier() + + # 시그널 연결 (테스트) + def show_notification(title, message): + QMessageBox.information(None, title, message) + + notifier.notification_signal.connect(show_notification) + + # 테스트: 퇴근 30분 전 + clock_out = datetime.now() + timedelta(minutes=25) + notifier.check_clock_out_soon(clock_out) + + print("Notifier test completed") diff --git a/core/settings_keys.py b/core/settings_keys.py new file mode 100644 index 0000000..d5b1a6c --- /dev/null +++ b/core/settings_keys.py @@ -0,0 +1,52 @@ +""" +설정 키 상수. + +여러 모듈에서 raw 문자열로 쓰던 키를 단일 출처로 모음. 오타 방지·grep 용이. +새 키 추가 시 여기에 등록 후 `init_default_settings()`에 기본값도 추가. +""" + +# 근무시간 +WORK_HOURS = 'work_hours' +WORK_MINUTES = 'work_minutes' +LUNCH_DURATION_MINUTES = 'lunch_duration_minutes' +DINNER_DURATION_MINUTES = 'dinner_duration_minutes' +WORKDAY_BOUNDARY_HOUR = 'workday_boundary_hour' + +# 자동화 +AUTO_DETECT_BOOT = 'auto_detect_boot' +AUTO_LUNCH = 'auto_lunch' +AUTO_OVERTIME = 'auto_overtime' +AUTO_BREAK_ON_LOCK = 'auto_break_on_lock' +CLOCK_IN_ON_UNLOCK = 'clock_in_on_unlock' # 첫 잠금 해제를 출근으로 사용 (PC 안 끄는 사용자용) + +# 알림 +NOTIFICATION_ENABLED = 'notification_enabled' +NOTIFICATION_BEFORE_MINUTES = 'notification_before_minutes' +NOTIF_CLOCK_OUT = 'notification_clock_out' +NOTIF_LUNCH = 'notification_lunch' +NOTIF_OVERTIME = 'notification_overtime' +NOTIF_HEALTH = 'notification_health' + +# 연차 +ANNUAL_LEAVE_TOTAL = 'annual_leave_total' +ANNUAL_LEAVE_DAYS = 'annual_leave_days' +ANNUAL_LEAVE_USED = 'annual_leave_used' +LEAVE_BALANCE = 'leave_balance' +INITIAL_OVERTIME_MINUTES = 'initial_overtime_minutes' +INITIAL_LEAVE_USED_HOURS = 'initial_leave_used_hours' + +# UI/표시 +THEME = 'theme' +TIME_FORMAT = 'time_format' +LANGUAGE = 'language' +OVERTIME_UNIT = 'overtime_unit' + +# 통합/외부 +DB_PATH_OVERRIDE = 'db_path_override' + +# 백업 +LAST_BACKUP_DATE = 'last_backup_date' + +# 마이그레이션 sentinel +ANNUAL_LEAVE_KEYS_MIGRATED = 'annual_leave_keys_migrated' +BALANCE_ADJUSTMENT_MIGRATED_V2 = 'balance_adjustment_migrated_v2' diff --git a/core/time_calculator.py b/core/time_calculator.py new file mode 100644 index 0000000..6b6eea8 --- /dev/null +++ b/core/time_calculator.py @@ -0,0 +1,326 @@ +""" +시간 계산 로직 +근무시간, 퇴근시간, 연장근무 계산 +""" +from datetime import datetime, time, timedelta +from typing import Tuple, Optional + + +class TimeCalculator: + """근무시간 계산 클래스""" + + def __init__(self, work_hours=None, lunch_duration_minutes: int = 60, dinner_duration_minutes: int = 60, + work_minutes: int = None): + """ + Args: + work_hours: 기본 근무시간 (시간 단위, float 허용 - 호환성용) + work_minutes: 기본 근무시간 (분 단위, 지정 시 work_hours보다 우선) + lunch_duration_minutes: 점심시간 (분) + dinner_duration_minutes: 저녁시간 (분) + """ + 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)) + else: + self.work_minutes = 480 + + self.lunch_duration_minutes = lunch_duration_minutes + self.dinner_duration_minutes = dinner_duration_minutes + + @property + def work_hours(self): + return self.work_minutes / 60.0 + + def calculate_clock_out_time(self, clock_in: datetime, + include_lunch: bool = False, + include_dinner: bool = False, + break_minutes: int = 0) -> datetime: + """ + 퇴근시간 계산 + Args: + clock_in: 출근 시간 + include_lunch: 점심시간 포함 여부 + include_dinner: 저녁시간 포함 여부 + break_minutes: 외출 시간 (분) + Returns: + datetime: 예상 퇴근 시간 + """ + total_minutes = self.work_minutes + break_minutes + if include_lunch: + total_minutes += self.lunch_duration_minutes + if include_dinner: + total_minutes += self.dinner_duration_minutes + + clock_out = clock_in + timedelta(minutes=total_minutes) + return clock_out + + def calculate_remaining_time(self, clock_in: datetime, + include_lunch: bool = False, + include_dinner: bool = False, + current_time: Optional[datetime] = None, + break_minutes: int = 0) -> timedelta: + """ + 퇴근까지 남은 시간 계산 + Args: + clock_in: 출근 시간 + include_lunch: 점심시간 포함 여부 + include_dinner: 저녁시간 포함 여부 + current_time: 현재 시간 (None이면 지금 시간 사용) + break_minutes: 외출 시간 (분) + Returns: + timedelta: 남은 시간 (음수면 이미 퇴근 시간 경과) + """ + if current_time is None: + current_time = datetime.now() + + # calculate_clock_out_time()에서 이미 break_minutes를 처리하므로 + # 여기서 중복으로 추가하지 않음 + clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes) + + remaining = clock_out - current_time + return remaining + + def calculate_work_progress(self, clock_in: datetime, + include_lunch: bool = False, + include_dinner: bool = False, + current_time: Optional[datetime] = None, + break_minutes: int = 0, + overtime_used_minutes: int = 0) -> float: + """ + 근무 진행률 계산 (0.0 ~ 1.0) + Args: + clock_in: 출근 시간 + include_lunch: 점심시간 포함 여부 + include_dinner: 저녁시간 포함 여부 + current_time: 현재 시간 + break_minutes: 외출 시간 (분) - 필요 근무시간 증가 + overtime_used_minutes: 사용한 추가근무 (분) - 필요 근무시간 감소 + Returns: + float: 진행률 (0.0 ~ 1.0) + """ + if current_time is None: + current_time = datetime.now() + + # 전체 필요 근무 시간 (초) + # = 기본 근무시간 + 점심시간 + 저녁시간 + 외출시간 - 추가근무 사용시간 + base_work_seconds = self.work_minutes * 60 + lunch_seconds = self.lunch_duration_minutes * 60 if include_lunch else 0 + dinner_seconds = self.dinner_duration_minutes * 60 if include_dinner else 0 + total_work_seconds = base_work_seconds + lunch_seconds + dinner_seconds + (break_minutes * 60) - (overtime_used_minutes * 60) + + # 경과 시간 (초) + elapsed_seconds = (current_time - clock_in).total_seconds() + + # 진행률 계산 (0.0 ~ 1.0 범위로 제한) + if total_work_seconds <= 0: + # 필요 시간이 0 이하면 100% 완료로 처리 + return 1.0 + + progress = elapsed_seconds / total_work_seconds + return max(min(progress, 1.0), 0.0) + + def calculate_total_work_time(self, clock_in: datetime, + clock_out: datetime) -> float: + """ + 총 근무시간 계산 (시간 단위) + Args: + clock_in: 출근 시간 + clock_out: 퇴근 시간 + Returns: + float: 총 근무시간 (시간) + """ + work_duration = clock_out - clock_in + return work_duration.total_seconds() / 3600 + + def calculate_overtime(self, clock_in: datetime, clock_out: datetime, + include_lunch: bool = False, include_dinner: bool = False, + break_minutes: int = 0) -> Tuple[int, int]: + """ + 연장근무 시간 계산 (실제 시간, 30분 단위 적립) + Args: + clock_in: 출근 시간 + clock_out: 퇴근 시간 + include_lunch: 점심시간 포함 여부 + include_dinner: 저녁시간 포함 여부 + break_minutes: 외출 시간 (분) - 연장근무 계산에서 제외 + Returns: + Tuple[int, int]: (실제 연장근무 분, 30분 단위 적립 분) + """ + expected_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner, break_minutes) + + if clock_out <= expected_clock_out: + return 0, 0 # 연장근무 없음 + + overtime_duration = clock_out - expected_clock_out + overtime_minutes = int(overtime_duration.total_seconds() / 60) + + # 30분 단위로 절삭 + overtime_earned = (overtime_minutes // 30) * 30 + + return overtime_minutes, overtime_earned + + def format_time_delta(self, td: timedelta) -> str: + """ + timedelta를 HH:MM:SS 형식으로 변환 + Args: + td: timedelta 객체 + Returns: + str: "HH:MM:SS" 형식 문자열 + """ + total_seconds = int(abs(td.total_seconds())) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + sign = "-" if td.total_seconds() < 0 else "" + return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}" + + def format_overtime_tokens(self, total_minutes: int) -> Tuple[int, str]: + """ + 연장근무 시간을 토큰 형식으로 변환 + Args: + total_minutes: 총 연장근무 시간 (분) + Returns: + Tuple[int, str]: (토큰 개수, 시간 문자열) + """ + tokens = total_minutes // 30 # 30분 = 1토큰 + hours = total_minutes // 60 + minutes = total_minutes % 60 + + if hours > 0 and minutes > 0: + time_str = f"{hours}시간 {minutes}분" + elif hours > 0: + time_str = f"{hours}시간" + else: + time_str = f"{minutes}분" + + return tokens, time_str + + def parse_time_string(self, time_str: str) -> datetime: + """ + 시간 문자열을 datetime으로 변환 + Args: + time_str: "HH:MM:SS" 또는 "HH:MM" 형식 + Returns: + datetime: 오늘 날짜 + 해당 시간 + """ + try: + # "HH:MM:SS" 형식 + if len(time_str.split(':')) == 3: + t = datetime.strptime(time_str, "%H:%M:%S").time() + else: + # "HH:MM" 형식 + t = datetime.strptime(time_str, "%H:%M").time() + + return datetime.combine(datetime.today(), t) + except ValueError as e: + raise ValueError(f"잘못된 시간 형식: {time_str}") from e + + def is_overtime_needed(self, clock_in: datetime, + target_overtime_minutes: int, + include_lunch: bool = False, + include_dinner: bool = False) -> datetime: + """ + 특정 연장근무 시간을 채우기 위한 퇴근 시간 계산 + Args: + clock_in: 출근 시간 + target_overtime_minutes: 목표 연장근무 시간 (분) + include_lunch: 점심시간 포함 여부 + include_dinner: 저녁시간 포함 여부 + Returns: + datetime: 목표 달성을 위한 퇴근 시간 + """ + normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner) + return normal_clock_out + timedelta(minutes=target_overtime_minutes) + + def is_weekend(self, date_obj: datetime) -> bool: + """ + 주말 여부 확인 + Args: + date_obj: 확인할 날짜 + Returns: + bool: 토요일(5) 또는 일요일(6)이면 True + """ + return date_obj.weekday() in [5, 6] + + def is_holiday(self, date_obj: datetime, db=None) -> bool: + """ + 공휴일 여부 확인 + Args: + date_obj: 확인할 날짜 + db: Database 인스턴스 (None이면 False 반환) + Returns: + bool: 공휴일이면 True + """ + if db is None: + return False + date_str = date_obj.strftime("%Y-%m-%d") + return db.is_holiday(date_str) + + def is_non_working_day(self, date_obj: datetime, db=None) -> bool: + """ + 비근무일 여부 확인 (주말 또는 공휴일) + Args: + date_obj: 확인할 날짜 + db: Database 인스턴스 (공휴일 체크용) + Returns: + bool: 주말 또는 공휴일이면 True + """ + if self.is_weekend(date_obj): + return True + return self.is_holiday(date_obj, db) + + def get_day_type(self, date_obj: datetime, db=None) -> str: + """ + 근무일 유형 반환 + Args: + date_obj: 확인할 날짜 + db: Database 인스턴스 + Returns: + str: 'weekend', 'holiday', 'normal' 중 하나 + """ + if self.is_weekend(date_obj): + return 'weekend' + if self.is_holiday(date_obj, db): + return 'holiday' + return 'normal' + + +# 테스트 코드 +if __name__ == "__main__": + calc = TimeCalculator(work_hours=8, lunch_duration_minutes=60) + + print("=== 시간 계산기 테스트 ===\n") + + # 출근 시간 설정 + clock_in = datetime.now().replace(hour=9, minute=0, second=0, microsecond=0) + print(f"출근 시간: {clock_in.strftime('%H:%M:%S')}") + + # 퇴근 시간 계산 (점심 없음) + clock_out_no_lunch = calc.calculate_clock_out_time(clock_in, include_lunch=False) + print(f"퇴근 시간 (점심 제외): {clock_out_no_lunch.strftime('%H:%M:%S')}") + + # 퇴근 시간 계산 (점심 포함) + clock_out_with_lunch = calc.calculate_clock_out_time(clock_in, include_lunch=True) + print(f"퇴근 시간 (점심 포함): {clock_out_with_lunch.strftime('%H:%M:%S')}") + + # 현재 시간 기준 남은 시간 + remaining = calc.calculate_remaining_time(clock_in, include_lunch=True) + print(f"\n남은 시간: {calc.format_time_delta(remaining)}") + + # 진행률 + progress = calc.calculate_work_progress(clock_in, include_lunch=True) + print(f"진행률: {progress * 100:.1f}%") + + # 연장근무 계산 + actual_clock_out = clock_out_with_lunch + timedelta(hours=2, minutes=15) + overtime_actual, overtime_earned = calc.calculate_overtime( + clock_in, actual_clock_out, include_lunch=True + ) + print(f"\n실제 퇴근: {actual_clock_out.strftime('%H:%M:%S')}") + print(f"연장근무: {overtime_actual}분 (적립: {overtime_earned}분)") + + # 토큰 형식 + tokens, time_str = calc.format_overtime_tokens(overtime_earned) + print(f"토큰: 🕐 × {tokens} ({time_str})") diff --git a/core/version.py b/core/version.py new file mode 100644 index 0000000..b96780e --- /dev/null +++ b/core/version.py @@ -0,0 +1,7 @@ +""" +앱 버전 상수. + +릴리스 시 이 값을 올린 후 git tag → push. +CHANGELOG.md의 최상단 항목과 일치시킬 것. +""" +__version__ = '2.2.0' diff --git a/main.py b/main.py new file mode 100644 index 0000000..cc57af6 --- /dev/null +++ b/main.py @@ -0,0 +1,138 @@ +""" +Clock-out Time Calculator +퇴근시간 계산 프로그램 - 메인 실행 파일 +""" +import sys +import os + +# PyQt5 임포트 +from PyQt5.QtWidgets import QApplication, QMessageBox +from PyQt5.QtGui import QFont +from PyQt5.QtCore import QLockFile, QDir +from PyQt5.QtNetwork import QLocalServer, QLocalSocket + +# 프로젝트 루트를 경로에 추가 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from ui.main_window import MainWindow +from core.database import Database + + +def check_requirements(): + """필수 요구사항 확인""" + try: + import win32evtlog + return True + except ImportError: + return False + + +def main(): + """메인 함수""" + app = QApplication(sys.argv) + + # 애플리케이션 정보 + app.setApplicationName("Clock-out Time Calculator") + app.setOrganizationName("DevUtil") + app.setApplicationVersion("1.0.0") + + # 중복 실행 방지 - 로컬 서버로 체크 + server_name = "ClockOutCalculatorInstance" + + # 이미 실행 중인지 확인 + socket = QLocalSocket() + socket.connectToServer(server_name) + + if socket.waitForConnected(500): + # 이미 실행 중 - 기존 인스턴스에 "show" 신호 전송 + socket.write(b"show") + socket.flush() + socket.waitForBytesWritten(1000) + socket.disconnectFromServer() + return 0 + + # 새로운 인스턴스 - 서버 시작 + server = QLocalServer() + # 기존 서버가 남아있을 수 있으므로 제거 + QLocalServer.removeServer(server_name) + + if not server.listen(server_name): + QMessageBox.warning( + None, + "서버 오류", + "프로그램 인스턴스 서버를 시작할 수 없습니다." + ) + return 1 + + # 폰트 설정 + app.setFont(QFont("Segoe UI", 9)) + + # 필수 패키지 확인 + if not check_requirements(): + QMessageBox.critical( + None, + "요구사항 오류", + "필수 패키지가 설치되지 않았습니다.\n\n" + "다음 명령어를 실행하세요:\n" + "pip install -r requirements.txt" + ) + return 1 + + # 데이터베이스 초기화 — db_path_override 설정 시 그 경로 사용 (클라우드 폴더 등) + # 부트스트랩: 기본 DB로 한 번 열어 override 키 확인 + from core.settings_keys import DB_PATH_OVERRIDE + bootstrap = Database() + override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or '' + if override_path and os.path.exists(os.path.dirname(override_path) or '.'): + db = Database(override_path) + else: + db = bootstrap + + # 1일 1회 자동 백업 (조용히 실패 — 백업 실패가 앱 실행을 막으면 안 됨) + try: + from utils.backup import backup_db_if_needed + backup_db_if_needed(db) + except Exception as e: + from utils.debug_log import dlog + dlog(f"backup failed: {e}") + + # 메인 윈도우 생성 및 표시 + try: + window = MainWindow() + + # 서버 연결 처리 - 다른 인스턴스에서 show 신호를 받으면 창을 보여줌 + def on_new_connection(): + client_socket = server.nextPendingConnection() + if client_socket: + client_socket.waitForReadyRead(1000) + data = client_socket.readAll().data() + if data == b"show": + # 창 표시 + window.show() + window.raise_() + window.activateWindow() + client_socket.disconnectFromServer() + + server.newConnection.connect(on_new_connection) + + window.show() + + result = app.exec_() + + # 서버 종료 + server.close() + + return result + + except Exception as e: + QMessageBox.critical( + None, + "오류", + f"프로그램 실행 중 오류가 발생했습니다:\n\n{str(e)}" + ) + server.close() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/main.spec b/main.spec new file mode 100644 index 0000000..4f11f95 --- /dev/null +++ b/main.spec @@ -0,0 +1,47 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[ + ('3d-alarm.png', '.'), + # updater.exe는 main과 같은 폴더에 배포되어야 함 (배포 시 ZIP에 함께 포함) + # PyInstaller datas로 안고 가지 않고 별도 파일로 유지 — 자가 교체 가능하도록 + ], + hiddenimports=[ + 'holidays', 'holidays.countries.south_korea', + 'win32evtlog', 'win32evtlogutil', + 'matplotlib.backends.backend_qt5agg', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='main', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=['3d-alarm.ico'], +) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9855d94 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b732925 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +PyQt5>=5.15.0 +pywin32>=305 +python-dateutil>=2.8.0 +matplotlib>=3.4.0 +plyer>=2.0.0 +holidays>=0.40 diff --git a/resources/resource_links.md b/resources/resource_links.md new file mode 100644 index 0000000..26a4196 --- /dev/null +++ b/resources/resource_links.md @@ -0,0 +1,82 @@ +# 리소스 다운로드 링크 + +## 무료 아이콘 사이트 + +### 1. Flaticon (추천) +- URL: https://www.flaticon.com/ +- 라이선스: 무료 (크레딧 표기 필요) +- 검색 키워드: + - "clock" - 시계 아이콘 + - "timer" - 타이머 아이콘 + - "lunch" - 점심 아이콘 + - "calendar" - 캘린더 아이콘 + - "statistics" - 통계 아이콘 + - "settings" - 설정 아이콘 + - "vacation" - 휴가 아이콘 + - "notification" - 알림 아이콘 + +### 2. Icons8 +- URL: https://icons8.com/ +- 라이선스: 무료 (링크 표기) +- 다양한 스타일 제공 + +### 3. Material Design Icons +- URL: https://materialdesignicons.com/ +- 라이선스: 무료 (오픈소스) +- 구글 Material Design 스타일 + +## 무료 사운드 사이트 + +### 1. Freesound +- URL: https://freesound.org/ +- 라이선스: Creative Commons +- 검색 키워드: + - "notification" - 알림음 + - "bell" - 벨 소리 + - "alarm" - 알람 + - "success" - 성공 효과음 + - "click" - 클릭 소리 + +### 2. Zapsplat +- URL: https://www.zapsplat.com/ +- 라이선스: 무료 (회원가입 필요) +- 고품질 효과음 + +### 3. Mixkit +- URL: https://mixkit.co/free-sound-effects/ +- 라이선스: 완전 무료 +- 알림음, 효과음 다양 + +## 필요한 리소스 목록 + +### 아이콘 (.png, 64x64 픽셀 권장) +- `app_icon.ico` - 메인 애플리케이션 아이콘 (512x512) +- `tray_icon.png` - 시스템 트레이 아이콘 (32x32) +- `clock.png` - 시계 +- `timer.png` - 타이머 +- `lunch.png` - 점심 +- `calendar.png` - 캘린더 +- `statistics.png` - 통계 +- `vacation.png` - 휴가 +- `settings.png` - 설정 +- `notification.png` - 알림 + +### 사운드 (.wav 또는 .mp3) +- `clock_out_alarm.wav` - 퇴근시간 알림음 +- `notification.wav` - 일반 알림음 +- `success.wav` - 성공 효과음 (퇴근 완료 시) +- `button_click.wav` - 버튼 클릭음 (선택) + +## 추천 조합 + +### 심플한 조합 +- 아이콘: Material Design Icons (단색, 깔끔) +- 사운드: Mixkit의 짧은 알림음 + +### 귀여운 조합 +- 아이콘: Flaticon의 귀여운 스타일 +- 사운드: Freesound의 부드러운 벨 소리 + +### 프로페셔널 조합 +- 아이콘: Icons8의 비즈니스 스타일 +- 사운드: Zapsplat의 시스템 알림음 diff --git a/run_as_admin.bat b/run_as_admin.bat new file mode 100644 index 0000000..1133422 --- /dev/null +++ b/run_as_admin.bat @@ -0,0 +1,5 @@ +@echo off +echo Starting Clock-out Time Calculator with Administrator privileges... +cd /d "%~dp0" +python main.py +pause diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..c42e2e4 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,110 @@ +""" +Database 단위 테스트 — 마이그레이션, 동기화, 헬퍼. +""" +import os +import sys +import tempfile +from datetime import date + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import Database + + +@pytest.fixture +def fresh_db(tmp_path): + """매 테스트마다 빈 DB.""" + return Database(str(tmp_path / "test.db")) + + +class TestSettingsHelpers: + def test_get_setting_int_valid(self, fresh_db): + fresh_db.set_setting('foo', '42') + assert fresh_db.get_setting_int('foo') == 42 + + def test_get_setting_int_invalid_returns_default(self, fresh_db): + fresh_db.set_setting('foo', 'abc') + assert fresh_db.get_setting_int('foo', 99) == 99 + + def test_get_setting_int_missing_returns_default(self, fresh_db): + assert fresh_db.get_setting_int('missing', 7) == 7 + + def test_get_setting_bool_truthy(self, fresh_db): + fresh_db.set_setting('flag', 'true') + assert fresh_db.get_setting_bool('flag') is True + + def test_get_setting_bool_falsy(self, fresh_db): + fresh_db.set_setting('flag', 'no') + assert fresh_db.get_setting_bool('flag') is False + + def test_get_setting_float(self, fresh_db): + fresh_db.set_setting('rate', '3.14') + assert fresh_db.get_setting_float('rate') == 3.14 + + +class TestSettingsAutoSync: + def test_work_minutes_to_work_hours_floor(self, fresh_db): + """work_minutes 저장 시 work_hours는 floor 동기화 (450 → 7)""" + fresh_db.save_settings({'work_minutes': 450}) + assert fresh_db.get_setting('work_hours') == '7' + + def test_work_hours_to_work_minutes(self, fresh_db): + fresh_db.save_settings({'work_hours': 8}) + assert fresh_db.get_setting('work_minutes') == '480' + + def test_annual_leave_bidirectional(self, fresh_db): + fresh_db.save_settings({'annual_leave_days': 12}) + assert fresh_db.get_setting('annual_leave_total') == '12' + + +class TestWorkMinutes: + def test_get_work_minutes_default(self, fresh_db): + assert fresh_db.get_work_minutes() == 480 + + def test_get_work_minutes_after_save(self, fresh_db): + fresh_db.save_settings({'work_minutes': 450}) + assert fresh_db.get_work_minutes() == 450 + + +class TestLeaveCalculation: + def test_leave_minutes_for_short_worker(self, fresh_db): + """단축근무자(7h30m) 1일 연차 = 450분""" + fresh_db.save_settings({'work_minutes': 450}) + today = date.today().isoformat() + fresh_db.add_leave_record(today, 'annual', 1.0) + assert fresh_db.get_today_leave_minutes() == 450 + + def test_half_day_leave(self, fresh_db): + today = date.today().isoformat() + fresh_db.add_leave_record(today, 'half', 0.5) + assert fresh_db.get_today_leave_minutes() == 240 # 8h * 0.5 + + +class TestMigrationIdempotency: + def test_annual_leave_keys_migrated_sentinel(self, fresh_db): + assert fresh_db.get_setting('annual_leave_keys_migrated') == 'true' + + def test_re_init_does_not_break(self, tmp_path): + path = str(tmp_path / "test.db") + db1 = Database(path) + db1.save_settings({'work_minutes': 450}) + # 두 번째 init + db2 = Database(path) + assert db2.get_work_minutes() == 450 + + +class TestConsecutiveOvertimeDays: + def test_no_records(self, fresh_db): + assert fresh_db.get_consecutive_overtime_days() == 0 + + def test_three_consecutive(self, fresh_db): + from datetime import date, timedelta + today = date.today() + for i in range(3): + d = (today - timedelta(days=i)).isoformat() + fresh_db.add_work_record(d, '09:00:00') + 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 diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..1a51d93 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,83 @@ +""" +i18n 단위 테스트. +""" +import os +import sys +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.i18n import tr, tr_html, set_language, available_languages, language_label + + +@pytest.fixture(autouse=True) +def reset_language(): + """매 테스트 후 ko로 원복.""" + yield + set_language('ko') + + +class TestBasicTranslation: + def test_korean_default(self): + set_language('ko') + assert '저장' in tr('btn.save') + + def test_english_switch(self): + set_language('en') + assert 'Save' in tr('btn.save') + + def test_missing_key_fallback_to_self(self): + set_language('en') + assert tr('non.existent.key') == 'non.existent.key' + + def test_missing_in_en_falls_back_to_ko(self): + # ko에만 있는 키가 있으면 en에서도 ko 값 (현재 사전엔 없지만 정책 검증) + # 이 테스트는 정책 보장만 함 (사전이 비대칭일 때 안전망) + set_language('en') + # 모든 카테고리는 양 언어 균형 있게 정의되어야 함 + for key in ['btn.save', 'menu.stats', 'window.settings']: + assert tr(key) != key # 빈 fallback이 아님 + + +class TestFormatArgs: + def test_minutes_arg_korean(self): + set_language('ko') + msg = tr('notif.clock_out_soon.body', minutes=15) + assert '15' in msg + + def test_hours_float_arg(self): + set_language('ko') + msg = tr('notif.weekly_52.body', hours=58.5) + assert '58.5' in msg + + def test_missing_arg_graceful(self): + # 필요한 인자 없이 format → 빈 문자열 아님 + set_language('ko') + msg = tr('notif.clock_out_soon.body') + assert msg # 키 그대로라도 비지 않음 + + +class TestHelpHtml: + def test_korean_html(self): + set_language('ko') + assert '환영' in tr_html('help.html.intro') + + def test_english_html(self): + set_language('en') + assert 'Welcome' in tr_html('help.html.intro') + + def test_missing_html_returns_placeholder(self): + set_language('ko') + result = tr_html('help.html.nonexistent') + assert '

missing:' in result + + +class TestLanguageMeta: + def test_available_languages_includes_ko_en(self): + langs = available_languages() + assert 'ko' in langs + assert 'en' in langs + + def test_language_label(self): + assert language_label('ko') == '한국어' + assert language_label('en') == 'English' diff --git a/tests/test_time_calculator.py b/tests/test_time_calculator.py new file mode 100644 index 0000000..076ae7a --- /dev/null +++ b/tests/test_time_calculator.py @@ -0,0 +1,100 @@ +""" +TimeCalculator 단위 테스트. +""" +import os +import sys +from datetime import datetime, timedelta + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.time_calculator import TimeCalculator + + +@pytest.fixture +def calc_8h(): + return TimeCalculator(work_hours=8, lunch_duration_minutes=60) + + +@pytest.fixture +def calc_short(): + """단축근무 7h30m + 점심 30m""" + return TimeCalculator(work_minutes=450, lunch_duration_minutes=30) + + +@pytest.fixture +def clock_in_9am(): + return datetime(2026, 4, 29, 9, 0, 0) + + +class TestClockOutTime: + def test_standard_8h_with_lunch(self, calc_8h, clock_in_9am): + co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True) + assert co == datetime(2026, 4, 29, 18, 0, 0) + + def test_short_7h30m_with_lunch(self, calc_short, clock_in_9am): + co = calc_short.calculate_clock_out_time(clock_in_9am, include_lunch=True) + assert co == datetime(2026, 4, 29, 17, 0, 0) + + def test_no_lunch(self, calc_8h, clock_in_9am): + co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=False) + assert co == datetime(2026, 4, 29, 17, 0, 0) + + def test_with_dinner(self, calc_8h, clock_in_9am): + co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True, include_dinner=True) + assert co == datetime(2026, 4, 29, 19, 0, 0) + + def test_with_break_minutes(self, calc_8h, clock_in_9am): + co_no = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True) + co = calc_8h.calculate_clock_out_time(clock_in_9am, include_lunch=True, break_minutes=30) + assert (co - co_no) == timedelta(minutes=30) + + +@pytest.mark.parametrize("actual_min,expected_earned", [ + (29, 0), # 30분 미만 절삭 + (30, 30), + (35, 30), + (60, 60), + (89, 60), + (90, 90), + (120, 120), +]) +def test_overtime_30min_truncation(calc_8h, clock_in_9am, actual_min, expected_earned): + base_co = clock_in_9am + timedelta(hours=8) + actual_co = base_co + timedelta(minutes=actual_min) + _, earned = calc_8h.calculate_overtime(clock_in_9am, actual_co, include_lunch=False) + assert earned == expected_earned + + +class TestCompatibility: + def test_work_hours_property_returns_float(self): + c = TimeCalculator(work_minutes=450) + assert c.work_hours == 7.5 + + def test_work_hours_constructor_accepts_float(self): + c = TimeCalculator(work_hours=7.5, lunch_duration_minutes=30) + assert c.work_minutes == 450 + + def test_work_minutes_takes_precedence(self): + # 둘 다 주면 work_minutes 우선 + c = TimeCalculator(work_hours=8, work_minutes=450) + assert c.work_minutes == 450 + + def test_default_8_hours(self): + c = TimeCalculator() + assert c.work_minutes == 480 + + +class TestDayType: + def test_weekend(self): + calc = TimeCalculator() + sat = datetime(2026, 5, 2) + assert calc.is_weekend(sat) + assert calc.get_day_type(sat) == 'weekend' + + def test_weekday(self): + calc = TimeCalculator() + mon = datetime(2026, 5, 4) + assert not calc.is_weekend(mon) + assert calc.get_day_type(mon) == 'normal' diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..2d70399 --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,90 @@ +""" +업데이터 단위 테스트. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.updater_client import _parse_version, is_newer, RELEASES_API + + +class TestVersionParsing: + @pytest.mark.parametrize("input_str,expected", [ + ('1.0.0', (1, 0, 0)), + ('v1.0.0', (1, 0, 0)), + ('V2.1.3', (2, 1, 3)), + ('1.0', (1, 0, 0)), # 짧은 버전 → 0 패딩 + ('2', (2, 0, 0)), + ('1.2.3.4', (1, 2, 3)), # 초과 부분 무시 + ('2.0rc1', (2, 0, 0)), # 접미사 제거 + ('', (0, 0, 0)), + ]) + def test_parse_version(self, input_str, expected): + assert _parse_version(input_str) == expected + + +class TestVersionComparison: + @pytest.mark.parametrize("remote,local,expected", [ + ('2.0.0', '1.0.0', True), + ('1.0.1', '1.0.0', True), + ('v2.1.0', '2.0.5', True), + ('1.0.0', '1.0.0', False), # 동일 버전 + ('1.0.0', '2.0.0', False), # 로컬이 더 최신 + ('v1.0.0', 'v1.0.0', False), + ]) + def test_is_newer(self, remote, local, expected): + assert is_newer(remote, local) == expected + + +class TestApiUrl: + def test_default_points_to_gitea(self): + """기본 URL이 자체 호스팅 Gitea를 가리키는지.""" + assert 'kindnick-git.duckdns.org' in RELEASES_API + assert '/api/v1/repos/' in RELEASES_API + assert '/releases/latest' in RELEASES_API + + def test_env_override(self, monkeypatch): + """환경변수로 URL 오버라이드 가능.""" + monkeypatch.setenv('CLOCKOUT_RELEASES_API', 'https://example.com/api/test') + # 모듈 재로드 필요 + import importlib + from utils import updater_client + importlib.reload(updater_client) + assert updater_client.RELEASES_API == 'https://example.com/api/test' + # 원복 + monkeypatch.delenv('CLOCKOUT_RELEASES_API', raising=False) + importlib.reload(updater_client) + + +class TestUpdaterScript: + """updater.py 자체 로직.""" + + def test_is_pid_running_self(self): + """현재 프로세스 PID는 running.""" + import updater + assert updater.is_pid_running(os.getpid()) + + def test_is_pid_running_dead(self): + """존재하지 않는 PID는 not running.""" + import updater + # 절대 사용되지 않을 PID (32비트 max + 1) + assert not updater.is_pid_running(99999999) + + def test_replace_file_round_trip(self, tmp_path): + """파일 교체 + 백업 생성 검증.""" + import updater + target = tmp_path / "main.exe" + new = tmp_path / "main_new.exe" + target.write_bytes(b'OLD VERSION') + new.write_bytes(b'NEW VERSION') + + backup = updater.replace_file(new, target) + assert backup is not None + assert backup.exists() + assert backup.read_bytes() == b'OLD VERSION' + assert target.exists() + assert target.read_bytes() == b'NEW VERSION' + assert not new.exists() # 이동되었으므로 사라짐 diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..c110e0a --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# ui 모듈 diff --git a/ui/break_view.py b/ui/break_view.py new file mode 100644 index 0000000..7ef00e5 --- /dev/null +++ b/ui/break_view.py @@ -0,0 +1,293 @@ +""" +외출 관리 화면 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QHeaderView, QMessageBox, QTimeEdit, QLineEdit, QWidget) +from PyQt5.QtCore import Qt, QTime +from datetime import datetime +from core.i18n import tr +from ui.styles import apply_dark_titlebar + + +class BreakEditDialog(QDialog): + """외출 기록 수정 다이얼로그""" + + def __init__(self, parent=None, break_record=None): + super().__init__(parent) + self.break_record = break_record + self.init_ui() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle("외출 기록 수정") + self.setFixedSize(380, 180) + + layout = QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(12, 10, 12, 10) + + # 외출 시간 + out_layout = QHBoxLayout() + out_label = QLabel("외출 시간:") + out_label.setFixedWidth(80) + self.out_time_edit = QTimeEdit() + self.out_time_edit.setDisplayFormat("HH:mm:ss") + out_layout.addWidget(out_label) + out_layout.addWidget(self.out_time_edit) + layout.addLayout(out_layout) + + # 복귀 시간 + in_layout = QHBoxLayout() + in_label = QLabel("복귀 시간:") + in_label.setFixedWidth(80) + self.in_time_edit = QTimeEdit() + self.in_time_edit.setDisplayFormat("HH:mm:ss") + in_layout.addWidget(in_label) + in_layout.addWidget(self.in_time_edit) + layout.addLayout(in_layout) + + # 사유 + reason_layout = QHBoxLayout() + reason_label = QLabel("사유:") + reason_label.setFixedWidth(80) + self.reason_edit = QLineEdit() + reason_layout.addWidget(reason_label) + reason_layout.addWidget(self.reason_edit) + layout.addLayout(reason_layout) + + # 기존 데이터 로드 + if self.break_record: + break_out = self.break_record.get('break_out', '00:00:00') + h, m, s = map(int, break_out.split(':')) + self.out_time_edit.setTime(QTime(h, m, s)) + + break_in = self.break_record.get('break_in') + if break_in: + h, m, s = map(int, break_in.split(':')) + self.in_time_edit.setTime(QTime(h, m, s)) + + reason = self.break_record.get('reason', '') + if reason: + self.reason_edit.setText(reason) + + # 버튼 + button_layout = QHBoxLayout() + save_button = QPushButton("저장") + cancel_button = QPushButton("취소") + + save_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) + + button_layout.addWidget(save_button) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def get_data(self): + """입력된 데이터 반환""" + out_time = self.out_time_edit.time() + in_time = self.in_time_edit.time() + reason = self.reason_edit.text() + + # 복귀 시간 처리: 기존 기록에 복귀 시간이 없으면 None 유지 + # 자정(00:00:00)도 유효한 시간으로 처리 + break_in_str = in_time.toString("HH:mm:ss") + if self.break_record and not self.break_record.get('break_in'): + # 기존에 복귀 시간이 없었고, 수정에서도 00:00:00이면 아직 복귀 안 한 것으로 간주 + if break_in_str == "00:00:00": + break_in_str = None + + return { + 'break_out': out_time.toString("HH:mm:ss"), + 'break_in': break_in_str, + 'reason': reason + } + + +class BreakView(QDialog): + """외출 관리 창""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db + self.parent_window = parent + self.init_ui() + self.load_break_records() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle(tr('window.break_view')) + self.setGeometry(200, 200, 550, 350) + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + title = QLabel("오늘의 외출 기록") + title.setObjectName("dialog_subtitle") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # 외출 리스트 테이블 + self.table = QTableWidget() + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""]) + + # 테이블 설정 + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setEditTriggers(QTableWidget.NoEditTriggers) + + layout.addWidget(self.table) + + # 총 외출 시간 표시 + self.total_label = QLabel("총 외출 시간: 0분") + self.total_label.setObjectName("section_title") + self.total_label.setAlignment(Qt.AlignRight) + layout.addWidget(self.total_label) + + # 버튼 + button_layout = QHBoxLayout() + + self.refresh_button = QPushButton("새로고침") + close_button = QPushButton("닫기") + + self.refresh_button.clicked.connect(self.load_break_records) + close_button.clicked.connect(self.accept) + + button_layout.addStretch() + button_layout.addWidget(self.refresh_button) + button_layout.addWidget(close_button) + + layout.addLayout(button_layout) + + self.setLayout(layout) + + def load_break_records(self): + """외출 기록 로드""" + records = self.db.get_today_break_records() + + self.table.setRowCount(len(records)) + + for i, record in enumerate(records): + # 외출 시간 + break_out = record.get('break_out', '') + self.table.setItem(i, 0, QTableWidgetItem(break_out)) + + # 복귀 시간 + break_in = record.get('break_in', '') + if break_in: + self.table.setItem(i, 1, QTableWidgetItem(break_in)) + else: + item = QTableWidgetItem("진행중") + item.setForeground(Qt.red) + self.table.setItem(i, 1, item) + + # 소요 시간 + total_minutes = record.get('total_minutes') + if total_minutes: + hours = total_minutes // 60 + minutes = total_minutes % 60 + duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분" + self.table.setItem(i, 2, QTableWidgetItem(duration_str)) + else: + self.table.setItem(i, 2, QTableWidgetItem("-")) + + # 사유 + reason = record.get('reason', '') + self.table.setItem(i, 3, QTableWidgetItem(reason)) + + # 액션 버튼 + action_widget = QWidget() + action_layout = QHBoxLayout() + action_layout.setContentsMargins(0, 0, 0, 0) + action_layout.setSpacing(5) + + edit_button = QPushButton("수정") + delete_button = QPushButton("삭제") + + edit_button.setFixedSize(50, 25) + delete_button.setFixedSize(50, 25) + + # 클릭 이벤트에 record id 전달 + record_id = record['id'] + edit_button.clicked.connect(lambda checked, rid=record_id: self.edit_record(rid)) + delete_button.clicked.connect(lambda checked, rid=record_id: self.delete_record(rid)) + + action_layout.addWidget(edit_button) + action_layout.addWidget(delete_button) + action_widget.setLayout(action_layout) + + self.table.setCellWidget(i, 4, action_widget) + + # 총 외출 시간 업데이트 + total_minutes = self.db.get_total_break_minutes_today() + hours = total_minutes // 60 + minutes = total_minutes % 60 + + if hours > 0: + self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분") + else: + self.total_label.setText(f"총 외출 시간: {minutes}분") + + def edit_record(self, record_id): + """외출 기록 수정""" + # 기존 기록 조회 + records = self.db.get_today_break_records() + record = next((r for r in records if r['id'] == record_id), None) + + if not record: + return + + # 수정 다이얼로그 표시 + dialog = BreakEditDialog(self, record) + + if dialog.exec_() == QDialog.Accepted: + data = dialog.get_data() + + # DB 업데이트 + self.db.update_break_record( + record_id, + data['break_out'], + data['break_in'], + data['reason'] + ) + + # 리스트 새로고침 + self.load_break_records() + + # 부모 창 업데이트 + if self.parent_window and hasattr(self.parent_window, 'update_break_status'): + self.parent_window.update_break_status() + if self.parent_window and hasattr(self.parent_window, 'update_times'): + self.parent_window.update_times() + + def delete_record(self, record_id): + """외출 기록 삭제""" + reply = QMessageBox.question( + self, + "삭제 확인", + "이 외출 기록을 삭제하시겠습니까?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.db.delete_break_record(record_id) + self.load_break_records() + + # 부모 창 업데이트 + if self.parent_window and hasattr(self.parent_window, 'update_break_status'): + self.parent_window.update_break_status() + if self.parent_window and hasattr(self.parent_window, 'update_times'): + self.parent_window.update_times() diff --git a/ui/calendar_view.py b/ui/calendar_view.py new file mode 100644 index 0000000..4ad6c45 --- /dev/null +++ b/ui/calendar_view.py @@ -0,0 +1,523 @@ +""" +캘린더 뷰 - 월간 근무 기록 조회 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QCalendarWidget, QTextEdit, QGroupBox, + QMessageBox) +from PyQt5.QtCore import QDate, Qt +from PyQt5.QtGui import QTextCharFormat, QColor +from datetime import datetime, date +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import Database +from ui.styles import ThemeColors, apply_dark_titlebar + + +class CalendarView(QDialog): + """캘린더 뷰 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db if db else Database() + self.init_ui() + self.load_calendar_data() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + from core.i18n import tr + self.setWindowTitle(tr('window.calendar')) + self.setModal(True) + self.setMinimumSize(520, 820) + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + title = QLabel("월간 근무 기록") + title.setObjectName("dialog_title") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # 캘린더 + self.calendar = QCalendarWidget() + self.calendar.setMinimumHeight(280) + self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader) + self.calendar.clicked.connect(self.date_selected) + layout.addWidget(self.calendar, 1) + + # 범례 + legend_layout = QHBoxLayout() + legend_layout.setSpacing(12) + legend_layout.addWidget(QLabel("🟢 정상")) + legend_layout.addWidget(QLabel("🔴 연장")) + legend_layout.addWidget(QLabel("🟡 휴가")) + legend_layout.addWidget(QLabel("⚪ 없음")) + legend_layout.addStretch() + layout.addLayout(legend_layout) + + # 선택된 날짜 상세 정보 + detail_group = QGroupBox("선택된 날짜 정보") + detail_layout = QVBoxLayout() + detail_layout.setSpacing(6) + detail_layout.setContentsMargins(10, 20, 10, 8) + + self.detail_text = QTextEdit() + self.detail_text.setReadOnly(True) + self.detail_text.setMaximumHeight(100) + detail_layout.addWidget(self.detail_text) + + # 버튼 레이아웃 + button_layout = QHBoxLayout() + button_layout.setSpacing(6) + + self.edit_time_button = QPushButton("✏️ 시간 수정") + self.edit_time_button.setObjectName("btn_primary") + self.edit_time_button.setEnabled(False) + self.edit_time_button.clicked.connect(self.edit_work_time) + button_layout.addWidget(self.edit_time_button) + + self.delete_record_button = QPushButton("🗑️ 기록 삭제") + self.delete_record_button.setObjectName("btn_danger") + self.delete_record_button.setEnabled(False) + self.delete_record_button.clicked.connect(self.delete_selected_record) + button_layout.addWidget(self.delete_record_button) + + detail_layout.addLayout(button_layout) + detail_group.setLayout(detail_layout) + layout.addWidget(detail_group) + + # 메모 그룹 + memo_group = QGroupBox("메모") + memo_layout = QVBoxLayout() + memo_layout.setSpacing(6) + memo_layout.setContentsMargins(10, 20, 10, 8) + + self.memo_edit = QTextEdit() + self.memo_edit.setMaximumHeight(70) + self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...") + memo_layout.addWidget(self.memo_edit) + + self.save_memo_button = QPushButton("💾 메모 저장") + self.save_memo_button.setObjectName("btn_primary") + self.save_memo_button.setEnabled(False) + self.save_memo_button.clicked.connect(self.save_memo) + memo_layout.addWidget(self.save_memo_button) + memo_group.setLayout(memo_layout) + layout.addWidget(memo_group) + + # 선택된 날짜 저장용 + self.selected_date_str = None + + # 닫기 버튼 + close_button = QPushButton(tr('btn.close')) + close_button.clicked.connect(self.close) + layout.addWidget(close_button) + + self.setLayout(layout) + + def load_calendar_data(self): + """캘린더 데이터 로드""" + # 현재 표시된 월의 데이터 가져오기 + current_date = self.calendar.selectedDate() + year = current_date.year() + month = current_date.month() + + # 월간 통계 가져오기 + stats = self.db.get_monthly_stats(year, month) + records = stats.get('records', []) + + # 캘린더에 마킹 + for record in records: + record_date = datetime.strptime(record['date'], '%Y-%m-%d').date() + qdate = QDate(record_date.year, record_date.month, record_date.day) + + # 포맷 설정 + fmt = QTextCharFormat() + + if record.get('overtime_earned', 0) > 0: + # 연장근무 + fmt.setBackground(QColor(ThemeColors.get('cal_overtime'))) + elif record.get('clock_out'): + # 정상 근무 + fmt.setBackground(QColor(ThemeColors.get('cal_normal'))) + else: + # 출근만 있음 + fmt.setBackground(QColor(ThemeColors.get('cal_incomplete'))) + + fmt.setForeground(QColor(ThemeColors.get('text_primary'))) + + self.calendar.setDateTextFormat(qdate, fmt) + + def date_selected(self, qdate): + """날짜 선택 시""" + selected_date = qdate.toPyDate() + date_str = selected_date.isoformat() + self.selected_date_str = date_str + + # 해당 날짜 기록 조회 + record = self.db.get_work_record(date_str) + + if record: + # 상세 정보 표시 + detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n" + detail += f"출근: {record['clock_in']}\n" + + if record.get('clock_out'): + detail += f"퇴근: {record['clock_out']}\n" + detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n" + + if record.get('lunch_break'): + detail += f"점심시간: 사용함\n" + else: + detail += f"점심시간: 미사용\n" + + if record.get('dinner_break'): + detail += f"저녁시간: 사용함\n" + else: + detail += f"저녁시간: 미사용\n" + + if record.get('overtime_earned', 0) > 0: + earned_min = record['overtime_earned'] + earned_hours = earned_min // 60 + earned_mins = earned_min % 60 + detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n" + else: + detail += f"퇴근: 미기록\n" + + if record.get('memo'): + detail += f"\n메모: {record['memo']}\n" + + self.detail_text.setText(detail) + self.edit_time_button.setEnabled(True) + self.delete_record_button.setEnabled(True) + + # 메모 필드 업데이트 + 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.edit_time_button.setEnabled(False) + self.delete_record_button.setEnabled(False) + self.memo_edit.setPlainText('') + self.save_memo_button.setEnabled(False) + + def delete_selected_record(self): + """선택된 날짜의 출근 기록 삭제""" + if not self.selected_date_str: + return + + reply = QMessageBox.question( + self, + "출근 기록 삭제", + f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n" + f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n" + f"※ 이 작업은 되돌릴 수 없습니다.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.db.delete_work_record(self.selected_date_str) + + QMessageBox.information( + self, + "삭제 완료", + f"{self.selected_date_str}의 출근 기록이 삭제되었습니다." + ) + + # 캘린더 새로고침 + self.load_calendar_data() + self.detail_text.clear() + self.delete_record_button.setEnabled(False) + + def save_memo(self): + """메모 저장""" + if not self.selected_date_str: + return + + memo = self.memo_edit.toPlainText().strip() + + # 메모 업데이트 + self.db.update_work_memo(self.selected_date_str, memo) + + QMessageBox.information( + self, + "메모 저장", + f"{self.selected_date_str}의 메모가 저장되었습니다." + ) + + # 상세 정보 새로고침 + qdate = self.calendar.selectedDate() + self.date_selected(qdate) + + def edit_work_time(self): + """출퇴근 시간 수정""" + if not self.selected_date_str: + return + + # 기존 기록 조회 + record = self.db.get_work_record(self.selected_date_str) + if not record: + return + + # 수정 다이얼로그 표시 + dialog = EditWorkTimeDialog(self, self.db, self.selected_date_str, record) + if dialog.exec_(): + # 수정 성공 시 캘린더 새로고침 + self.load_calendar_data() + qdate = self.calendar.selectedDate() + self.date_selected(qdate) + + # 부모 윈도우 업데이트 + if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): + self.parent().update_overtime_balance() + + +class EditWorkTimeDialog(QDialog): + """출퇴근 시간 수정 다이얼로그""" + + def __init__(self, parent, db, date_str, record): + super().__init__(parent) + self.db = db + self.date_str = date_str + self.record = record + self.init_ui() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + from PyQt5.QtWidgets import QTimeEdit + from PyQt5.QtCore import QTime + + self.setWindowTitle("출퇴근 시간 수정") + self.setModal(True) + self.setMinimumWidth(420) + + layout = QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정") + title.setObjectName("dialog_subtitle") + layout.addWidget(title) + + # 출근 시간 + clock_in_layout = QHBoxLayout() + clock_in_layout.setSpacing(4) + clock_in_label = QLabel("출근:") + 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.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) + + self.clock_in_edit = QTimeEdit() + self.clock_in_edit.setDisplayFormat("HH:mm:ss") + clock_in_time = QTime.fromString(self.record['clock_in'], "HH:mm:ss") + 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.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) + layout.addLayout(clock_in_layout) + + # 퇴근 시간 + clock_out_layout = QHBoxLayout() + clock_out_layout.setSpacing(4) + clock_out_label = QLabel("퇴근:") + clock_out_label.setObjectName("field_label") + clock_out_label.setFixedWidth(40) + clock_out_layout.addWidget(clock_out_label) + + clock_out_minus_btn = QPushButton("-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) + + self.clock_out_edit = QTimeEdit() + self.clock_out_edit.setDisplayFormat("HH:mm:ss") + if self.record.get('clock_out'): + clock_out_time = QTime.fromString(self.record['clock_out'], "HH:mm:ss") + 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.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) + layout.addLayout(clock_out_layout) + + # 점심/저녁 체크박스 - 한 줄에 + from PyQt5.QtWidgets import QCheckBox + check_layout = QHBoxLayout() + self.lunch_check = QCheckBox("점심 (1시간)") + self.lunch_check.setChecked(bool(self.record.get('lunch_break', False))) + check_layout.addWidget(self.lunch_check) + + self.dinner_check = QCheckBox("저녁 (1시간)") + self.dinner_check.setChecked(bool(self.record.get('dinner_break', False))) + check_layout.addWidget(self.dinner_check) + layout.addLayout(check_layout) + + # 안내 메시지 + note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.") + note.setObjectName("note_text") + layout.addWidget(note) + + # 버튼 + button_layout = QHBoxLayout() + save_button = QPushButton("저장") + save_button.setObjectName("btn_success") + save_button.clicked.connect(self.save_changes) + + cancel_button = QPushButton("취소") + cancel_button.clicked.connect(self.reject) + + button_layout.addWidget(save_button) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def adjust_time(self, time_edit, minutes: int): + """시간 조정 (±분)""" + current_time = time_edit.time() + new_time = current_time.addSecs(minutes * 60) + time_edit.setTime(new_time) + + def save_changes(self): + """변경사항 저장""" + clock_in = self.clock_in_edit.time().toString("HH:mm:ss") + clock_out = self.clock_out_edit.time().toString("HH:mm:ss") + lunch_break = self.lunch_check.isChecked() + dinner_break = self.dinner_check.isChecked() + + # 퇴근 시간이 출근 시간보다 빠른지 확인 + if clock_out <= clock_in: + QMessageBox.warning( + self, + "시간 오류", + "퇴근 시간은 출근 시간보다 늦어야 합니다." + ) + return + + # 근무 시간 계산 + from datetime import datetime, timedelta + from core.time_calculator import TimeCalculator + + # 해당 날짜의 datetime 객체 생성 + date_obj = datetime.strptime(self.date_str, "%Y-%m-%d").date() + clock_in_dt = datetime.combine(date_obj, datetime.strptime(clock_in, "%H:%M:%S").time()) + clock_out_dt = datetime.combine(date_obj, datetime.strptime(clock_out, "%H:%M:%S").time()) + + # 총 근무시간 계산 + total_hours = (clock_out_dt - clock_in_dt).total_seconds() / 3600 + + from core.settings_keys import ( + WORK_MINUTES, WORK_HOURS, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, + ) + settings = self.db.get_settings() + work_minutes = settings.get(WORK_MINUTES) + if work_minutes is None: + # 레거시 DB: work_hours만 있고 마이그레이션 전인 경우 폴백 + try: + work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60)) + except (ValueError, TypeError): + work_minutes = 480 + + time_calc = TimeCalculator( + work_minutes=int(work_minutes), + lunch_duration_minutes=int(settings.get(LUNCH_DURATION_MINUTES, 60)), + dinner_duration_minutes=int(settings.get(DINNER_DURATION_MINUTES, 60)), + ) + + # 해당 날짜의 외출 시간 조회 + break_records = self.db.get_break_records_by_date(self.date_str) + break_minutes = sum(r.get('total_minutes', 0) or 0 for r in break_records) + + # calculate_overtime 호출 + overtime_actual, overtime_earned = time_calc.calculate_overtime( + clock_in_dt, clock_out_dt, + include_lunch=lunch_break, + include_dinner=dinner_break, + break_minutes=break_minutes + ) + + # DB 업데이트 + conn = None + try: + conn = self.db.get_connection() + cursor = conn.cursor() + + # 기존 overtime_earned 값 조회 + old_overtime_earned = self.record.get('overtime_earned', 0) or 0 + + # work_records 업데이트 (dinner_break 포함) + cursor.execute(''' + UPDATE work_records + SET clock_in = ?, clock_out = ?, lunch_break = ?, dinner_break = ?, + total_hours = ?, overtime_minutes = ?, overtime_earned = ? + WHERE date = ? + ''', (clock_in, clock_out, lunch_break, dinner_break, total_hours, overtime_actual, overtime_earned, self.date_str)) + + # overtime_bank 테이블도 업데이트 (연장근무 적립 내역) + work_record_id = self.record.get('id') + if work_record_id: + # 기존 적립 내역 삭제 + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id = ? AND date = ? + ''', (work_record_id, self.date_str)) + + # 새로운 적립 내역 추가 (0보다 클 때만) + if overtime_earned > 0: + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (?, ?, ?) + ''', (work_record_id, overtime_earned, self.date_str)) + + conn.commit() + + 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}분 적립" + ) + + self.accept() + + except Exception as e: + QMessageBox.critical( + self, + "오류", + f"수정 중 오류가 발생했습니다:\n{str(e)}" + ) + finally: + if conn: + conn.close() + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = CalendarView() + dialog.exec_() diff --git a/ui/chart_widget.py b/ui/chart_widget.py new file mode 100644 index 0000000..abe7bc1 --- /dev/null +++ b/ui/chart_widget.py @@ -0,0 +1,111 @@ +""" +matplotlib 기반 차트 위젯. + +stats_view에서 주간/월간 추세를 시각화. matplotlib 미설치 시 +ImportError 안내 라벨로 fallback. +""" +from typing import List, Tuple + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt5.QtCore import Qt + +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'] + matplotlib.rcParams['axes.unicode_minus'] = False + _MPL = True +except ImportError: + _MPL = False + + +class _Fallback(QWidget): + """matplotlib 미설치 시 안내.""" + def __init__(self, message: str): + super().__init__() + layout = QVBoxLayout() + label = QLabel(message) + label.setAlignment(Qt.AlignCenter) + label.setWordWrap(True) + label.setStyleSheet("color: #888; padding: 20px;") + layout.addWidget(label) + self.setLayout(layout) + + +def make_chart_widget(parent=None) -> QWidget: + """차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback.""" + if not _MPL: + return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib") + widget = QWidget(parent) + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True) + canvas = FigureCanvas(fig) + layout.addWidget(canvas) + widget.setLayout(layout) + widget._figure = fig + widget._canvas = canvas + return widget + + +def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: + """일별 근무시간 막대 그래프. + + Args: + widget: make_chart_widget()로 만든 위젯 + records: [{date, total_hours, overtime_minutes}, ...] + """ + if not getattr(widget, '_figure', None): + return + fig = widget._figure + fig.clear() + if not records: + ax = fig.add_subplot(111) + ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes) + widget._canvas.draw() + return + + dates = [r['date'][5:] for r in records] # MM-DD만 + hours = [r.get('total_hours', 0) or 0 for r in records] + overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records] + base = [max(h - o, 0) for h, o in zip(hours, overtimes)] + + ax = fig.add_subplot(111) + ax.bar(dates, base, label='정상', color='#4a90e2') + ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b') + ax.set_ylabel('시간') + ax.legend(loc='upper left', fontsize=8) + ax.tick_params(axis='x', labelrotation=45, labelsize=8) + ax.grid(axis='y', alpha=0.3) + widget._canvas.draw() + + +def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None: + """요일별 평균 근무시간 막대 그래프.""" + if not getattr(widget, '_figure', None): + return + fig = widget._figure + fig.clear() + + from datetime import datetime as _dt + weekday_totals = [0.0] * 7 + weekday_counts = [0] * 7 + for r in records: + try: + d = _dt.strptime(r['date'], '%Y-%m-%d') + except (ValueError, TypeError): + continue + weekday_totals[d.weekday()] += r.get('total_hours', 0) or 0 + weekday_counts[d.weekday()] += 1 + + avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)] + labels = ['월', '화', '수', '목', '금', '토', '일'] + + ax = fig.add_subplot(111) + colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조 + ax.bar(labels, avg, color=colors) + ax.set_ylabel('평균 시간') + ax.set_title('요일별 평균 근무시간') + ax.grid(axis='y', alpha=0.3) + widget._canvas.draw() diff --git a/ui/clock_in_dialog.py b/ui/clock_in_dialog.py new file mode 100644 index 0000000..e443416 --- /dev/null +++ b/ui/clock_in_dialog.py @@ -0,0 +1,141 @@ +""" +출근시간 수동 입력 다이얼로그 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTimeEdit, QMessageBox) +from PyQt5.QtCore import QTime, Qt +from datetime import datetime +from core.i18n import tr +from ui.styles import apply_dark_titlebar + + +class ClockInDialog(QDialog): + """출근시간 입력 다이얼로그""" + + def __init__(self, parent=None, default_time=None): + super().__init__(parent) + self.selected_time = None + self.init_ui(default_time) + apply_dark_titlebar(self) + + def init_ui(self, default_time): + """UI 초기화""" + self.setWindowTitle(tr('window.clock_in_dialog')) + self.setModal(True) + self.setFixedSize(340, 200) + + layout = QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(12, 10, 12, 10) + + # 안내 문구 + info_label = QLabel("오늘의 출근시간을 입력해주세요") + info_label.setObjectName("field_label") + info_label.setAlignment(Qt.AlignCenter) + layout.addWidget(info_label) + + # 시간 입력 + time_layout = QHBoxLayout() + time_label = QLabel("출근시간:") + time_label.setObjectName("field_label") + + self.time_edit = QTimeEdit() + self.time_edit.setDisplayFormat("HH:mm:ss") + self.time_edit.setMinimumHeight(35) + + # 기본값 설정 + if default_time: + qtime = QTime(default_time.hour, default_time.minute, default_time.second) + else: + # 현재 시간으로 기본값 설정 + now = datetime.now() + qtime = QTime(now.hour, now.minute, now.second) + + self.time_edit.setTime(qtime) + + time_layout.addWidget(time_label) + time_layout.addWidget(self.time_edit) + layout.addLayout(time_layout) + + # 빠른 선택 버튼 + quick_layout = QHBoxLayout() + quick_label = QLabel("빠른 선택:") + quick_label.setObjectName("field_label") + + btn_8am = QPushButton("08:00") + btn_9am = QPushButton("09:00") + btn_10am = QPushButton("10:00") + btn_now = QPushButton("현재") + + for btn in [btn_8am, btn_9am, btn_10am, btn_now]: + btn.setMinimumHeight(30) + + btn_8am.clicked.connect(lambda: self.time_edit.setTime(QTime(8, 0, 0))) + btn_9am.clicked.connect(lambda: self.time_edit.setTime(QTime(9, 0, 0))) + btn_10am.clicked.connect(lambda: self.time_edit.setTime(QTime(10, 0, 0))) + btn_now.clicked.connect(lambda: self.time_edit.setTime(QTime.currentTime())) + + quick_layout.addWidget(quick_label) + quick_layout.addWidget(btn_8am) + quick_layout.addWidget(btn_9am) + quick_layout.addWidget(btn_10am) + quick_layout.addWidget(btn_now) + layout.addLayout(quick_layout) + + layout.addStretch() + + # 버튼 + button_layout = QHBoxLayout() + + ok_button = QPushButton("확인") + ok_button.setObjectName("btn_primary") + ok_button.setMinimumHeight(40) + ok_button.clicked.connect(self.accept) + + cancel_button = QPushButton("취소") + cancel_button.setMinimumHeight(40) + cancel_button.clicked.connect(self.reject) + + button_layout.addWidget(cancel_button) + button_layout.addWidget(ok_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def accept(self): + """확인 버튼 클릭""" + qtime = self.time_edit.time() + + # QTime을 datetime으로 변환 + today = datetime.now().date() + self.selected_time = datetime.combine( + today, + datetime.min.time().replace( + hour=qtime.hour(), + minute=qtime.minute(), + second=qtime.second() + ) + ) + + super().accept() + + def get_time(self): + """선택된 시간 반환""" + return self.selected_time + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication + import sys + + app = QApplication(sys.argv) + + dialog = ClockInDialog() + if dialog.exec_() == QDialog.Accepted: + selected_time = dialog.get_time() + print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}") + else: + print("취소됨") + + sys.exit() diff --git a/ui/controllers/__init__.py b/ui/controllers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/controllers/auto_lunch.py b/ui/controllers/auto_lunch.py new file mode 100644 index 0000000..f3b1b37 --- /dev/null +++ b/ui/controllers/auto_lunch.py @@ -0,0 +1,55 @@ +""" +자동 점심시간 적용 컨트롤러. + +조건: auto_lunch=true, 출근 후 4시간 이상, 점심 미적용, 오늘 미적용. +주말/공휴일은 스킵. 1Hz hot path에서 호출되므로 캐시 사용. +""" +from __future__ import annotations +from datetime import datetime + +from core.settings_keys import AUTO_LUNCH + + +class AutoLunchManager: + """update_display() 1Hz tick에서 호출.""" + + def __init__(self, window): + self.window = window + self.db = window.db + self._enabled_cache = None # None=미로딩, True/False=캐시값 + self._non_working_cache = None + self._non_working_date = None + + def invalidate(self) -> None: + """설정 변경 시 캐시 무효화 (reload_settings에서 호출).""" + self._enabled_cache = None + + def maybe_apply(self, now: datetime) -> None: + w = self.window + if w.auto_lunch_applied_today or w.lunch_break_enabled: + return + + if self._enabled_cache is None: + self._enabled_cache = ( + self.db.get_setting(AUTO_LUNCH, 'false').lower() == 'true' + ) + if not self._enabled_cache: + return + + today = now.date() + if self._non_working_date != today: + self._non_working_cache = w.time_calc.is_non_working_day(now, self.db) + self._non_working_date = today + if self._non_working_cache: + return + + elapsed = now - w.clock_in_time + if elapsed.total_seconds() < 4 * 3600: + return + + w.auto_lunch_applied_today = True + w.lunch_break_enabled = True + w.lunch_button.setChecked(True) + w.update_lunch_status() + if w.is_clocked_in: + self.db.update_lunch_break(today.isoformat(), True) diff --git a/ui/controllers/lock_monitor.py b/ui/controllers/lock_monitor.py new file mode 100644 index 0000000..244ba59 --- /dev/null +++ b/ui/controllers/lock_monitor.py @@ -0,0 +1,69 @@ +""" +화면 잠금 감지 컨트롤러. + +- AUTO_BREAK_ON_LOCK: 출근 후 잠금→외출, 해제→복귀 +- CLOCK_IN_ON_UNLOCK: 미출근 상태에서 잠금 해제 시 출근 자동 기록 +""" +from __future__ import annotations +from datetime import datetime + +from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK + + +class LockMonitor: + """MainWindow에서 5초마다 호출되는 잠금 상태 감시자.""" + + def __init__(self, window): + self.window = window + self.db = window.db + self.last_locked: bool = False + + def tick(self) -> None: + try: + from utils.lock_detector import is_screen_locked + locked = is_screen_locked() + except Exception: + return + + was_locked = self.last_locked + self.last_locked = locked + + # 출근 후 자동 외출/복귀 + if (self.db.get_setting(AUTO_BREAK_ON_LOCK, 'false').lower() == 'true' + and self.window.is_clocked_in): + if locked and not was_locked and not self.window.is_on_break: + self.window.break_out(silent=True) + elif not locked and was_locked and self.window.is_on_break: + self.window.break_in(silent=True) + + # 미출근 상태에서 잠금 해제 시 출근 + if (not locked and was_locked + and not self.window.is_clocked_in + and self.db.get_setting(CLOCK_IN_ON_UNLOCK, 'false').lower() == 'true'): + now = datetime.now() + today_record = self.db.get_today_record() + if not today_record or not today_record.get('clock_in'): + self._auto_clock_in_at(now) + + def _auto_clock_in_at(self, when: datetime) -> None: + w = self.window + w.clock_in_time = when + w.is_clocked_in = True + w.midnight_rollover_handled = False + w.auto_lunch_applied_today = False + + today = when.date().isoformat() + clock_in_str = when.strftime("%H:%M:%S") + existing = self.db.get_today_record() + if existing: + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?", + (clock_in_str, today), + ) + conn.commit() + conn.close() + else: + self.db.add_work_record(today, clock_in_str) + w.update_display() diff --git a/ui/controllers/notification_orchestrator.py b/ui/controllers/notification_orchestrator.py new file mode 100644 index 0000000..c5319b8 --- /dev/null +++ b/ui/controllers/notification_orchestrator.py @@ -0,0 +1,40 @@ +""" +알림 오케스트레이션. + +5분 가드로 건강/주간/누적 임계 알림을 throttle. +notifier.py의 6개 알림 메서드를 적절한 시점에 호출. +""" +from __future__ import annotations +from datetime import datetime + + +class NotificationOrchestrator: + """update_display() 1Hz tick에서 호출.""" + + def __init__(self, window): + self.window = window + self.db = window.db + self.notifier = window.notifier + self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만) + + def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None: + n = self.notifier + # 1초마다 체크: 30분 전, 점심 미등록, 연장 적립 + n.check_clock_out_soon(expected_clock_out, now) + n.check_lunch_reminder(self.window.clock_in_time, + self.window.lunch_break_enabled, now) + if remaining_seconds < 0: + n.check_overtime_earning(abs(int(remaining_seconds / 60))) + + # 5분 간격 throttle: 건강/주간/누적 + if now.minute % 5 == 0 and self._last_5min_bucket != now.minute: + self._last_5min_bucket = now.minute + consecutive = self.db.get_consecutive_overtime_days() + if consecutive >= 3: + n.notify_health_warning(consecutive) + weekly_hours = self.db.get_weekly_stats().get('total_hours', 0) + if weekly_hours > 52: + n.notify_weekly_hours(weekly_hours) + balance_minutes = self.db.get_total_overtime_balance() + if balance_minutes >= 1200: + n.notify_overtime_threshold(balance_minutes / 60.0) diff --git a/ui/help_view.py b/ui/help_view.py new file mode 100644 index 0000000..88842fc --- /dev/null +++ b/ui/help_view.py @@ -0,0 +1,84 @@ +""" +사용 설명 가이드 창. + +i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시. +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QWidget, QTabWidget, QTextBrowser) +from PyQt5.QtCore import Qt + +from core.i18n import tr, tr_html +from ui.styles import apply_dark_titlebar + + +class HelpView(QDialog): + """사용 설명 가이드 다이얼로그""" + + # (사전 키, 탭 라벨 키) + _TABS = [ + ('help.html.intro', 'help.tab_intro'), + ('help.html.work_hours', 'help.tab_work_hours'), + ('help.html.overtime', 'help.tab_overtime'), + ('help.html.leave', 'help.tab_leave'), + ('help.html.break', 'help.tab_break'), + ('help.html.faq', 'help.tab_faq'), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(tr('window.help')) + self.setModal(True) + self.setMinimumSize(680, 720) + + self.init_ui() + apply_dark_titlebar(self) + + def init_ui(self): + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + title = QLabel(tr('window.help')) + title.setObjectName("dialog_title") + title.setAlignment(Qt.AlignCenter) + main_layout.addWidget(title) + + tabs = QTabWidget() + tabs.setDocumentMode(True) + for html_key, tab_label_key in self._TABS: + tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key)) + main_layout.addWidget(tabs) + + button_layout = QHBoxLayout() + button_layout.setContentsMargins(20, 10, 20, 20) + button_layout.addStretch() + close_button = QPushButton(tr('btn.close')) + close_button.setObjectName("btn_primary") + close_button.setMinimumHeight(40) + close_button.setMinimumWidth(120) + close_button.clicked.connect(self.close) + button_layout.addWidget(close_button) + button_layout.addStretch() + main_layout.addLayout(button_layout) + + self.setLayout(main_layout) + + def _make_tab(self, html: str) -> QWidget: + container = QWidget() + layout = QVBoxLayout() + layout.setContentsMargins(16, 12, 16, 12) + browser = QTextBrowser() + browser.setOpenExternalLinks(False) + browser.setHtml(html) + layout.addWidget(browser) + container.setLayout(layout) + return container + + +# 단독 실행 테스트 +if __name__ == "__main__": + import sys + from PyQt5.QtWidgets import QApplication + app = QApplication(sys.argv) + dialog = HelpView() + dialog.exec_() diff --git a/ui/leave_view.py b/ui/leave_view.py new file mode 100644 index 0000000..339b4e5 --- /dev/null +++ b/ui/leave_view.py @@ -0,0 +1,371 @@ +""" +연차 상세 내역 뷰 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QHeaderView, QGroupBox, QMessageBox, QInputDialog, + QLineEdit, QDateEdit, QComboBox, QDoubleSpinBox, + QMenu, QAction) +from PyQt5.QtCore import Qt, QDate +from PyQt5.QtGui import QColor +from datetime import datetime +import sys +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 apply_dark_titlebar + + +class LeaveView(QDialog): + """연차 상세 내역 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db if db else Database() + self.init_ui() + self.load_data() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle(tr('window.leave_view')) + self.setModal(True) + self.setMinimumSize(700, 450) + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + 잔액 + 설정 한 줄 + header_layout = QHBoxLayout() + title = QLabel("연차 관리") + title.setObjectName("dialog_title") + header_layout.addWidget(title) + header_layout.addStretch() + self.balance_label = QLabel("잔여: 0일") + self.balance_label.setObjectName("badge_leave") + header_layout.addWidget(self.balance_label) + set_balance_button = QPushButton("잔여 설정") + set_balance_button.clicked.connect(self.set_balance) + header_layout.addWidget(set_balance_button) + layout.addLayout(header_layout) + + # 사용 내역 + used_group = QGroupBox("📤 사용 내역") + used_layout = QVBoxLayout() + used_layout.setSpacing(4) + used_layout.setContentsMargins(8, 20, 8, 6) + + self.used_table = QTableWidget() + self.used_table.setColumnCount(4) + self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"]) + self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.used_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) + self.used_table.setAlternatingRowColors(True) + self.used_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.used_table.setSelectionBehavior(QTableWidget.SelectRows) + self.used_table.setContextMenuPolicy(Qt.CustomContextMenu) + self.used_table.customContextMenuRequested.connect(self.show_context_menu) + used_layout.addWidget(self.used_table) + + used_group.setLayout(used_layout) + layout.addWidget(used_group) + + # 버튼들 + button_layout = QHBoxLayout() + add_leave_button = QPushButton("➕ 연차 사용 추가") + add_leave_button.clicked.connect(self.add_leave_record) + button_layout.addWidget(add_leave_button) + close_button = QPushButton("닫기") + close_button.clicked.connect(self.close) + button_layout.addWidget(close_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def load_data(self): + """데이터 로드""" + # 잔액 업데이트 + balance = self.db.get_leave_balance() + hours = balance * 8 + self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)") + + # 사용 내역 로드 (잔액 조정 제외) + records = self.db.get_leave_records(exclude_bulk=True) + + self.used_table.setRowCount(len(records)) + for i, record in enumerate(records): + date_item = QTableWidgetItem(record['date']) + date_item.setTextAlignment(Qt.AlignCenter) + date_item.setData(Qt.UserRole, record['id']) + + type_item = QTableWidgetItem(record['leave_type']) + type_item.setTextAlignment(Qt.AlignCenter) + + days = record['days'] + hours = days * 8 + if days == 1.0: + days_str = "1일" + elif days == 0.5: + days_str = "0.5일 (4시간)" + elif hours < 8: + days_str = f"{days}일 ({hours}시간)" + else: + days_str = f"{days}일" + days_item = QTableWidgetItem(days_str) + days_item.setTextAlignment(Qt.AlignCenter) + days_item.setForeground(QColor(231, 76, 60)) # 빨간색 + + memo_item = QTableWidgetItem(record['memo'] or "") + + self.used_table.setItem(i, 0, date_item) + self.used_table.setItem(i, 1, type_item) + self.used_table.setItem(i, 2, days_item) + self.used_table.setItem(i, 3, memo_item) + + def show_context_menu(self, position): + """사용 내역 우클릭 메뉴""" + selected_rows = self.used_table.selectionModel().selectedRows() + if not selected_rows: + return + + menu = QMenu(self) + delete_action = QAction("삭제", self) + delete_action.triggered.connect(self.delete_leave_record) + menu.addAction(delete_action) + menu.exec_(self.used_table.viewport().mapToGlobal(position)) + + def delete_leave_record(self): + """연차 사용 기록 삭제""" + selected_rows = self.used_table.selectionModel().selectedRows() + if not selected_rows: + return + + row = selected_rows[0].row() + date_item = self.used_table.item(row, 0) + type_item = self.used_table.item(row, 1) + days_item = self.used_table.item(row, 2) + + leave_id = date_item.data(Qt.UserRole) + + reply = QMessageBox.question( + self, + "삭제 확인", + f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n" + f"날짜: {date_item.text()}\n" + f"구분: {type_item.text()}\n" + f"사용: {days_item.text()}", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.db.delete_leave_record(leave_id) + self.load_data() + + def set_balance(self): + """연차 개수 설정 (시간 단위)""" + current_balance = self.db.get_leave_balance() + current_hours = current_balance * 8 + + hours, ok = QInputDialog.getDouble( + self, + "연차 시간 설정", + "연차 잔여 시간을 입력하세요 (0.5시간 단위):\n" + "예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분", + current_hours, + 0.0, + 999.0, + 1 # 소수점 첫째자리까지 (0.5 단위) + ) + + if ok: + # 0.5시간 단위로 반올림 + hours = round(hours * 2) / 2 + # 시간을 일수로 변환 + days = hours / 8.0 + self.db.set_leave_balance(days) + QMessageBox.information( + self, + "설정 완료", + f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다." + ) + self.load_data() + + def add_leave_record(self): + """연차 사용 기록 추가 다이얼로그""" + dialog = AddLeaveDialog(self, self.db) + if dialog.exec_() == QDialog.Accepted: + self.load_data() + + +class AddLeaveDialog(QDialog): + """연차 사용 기록 추가 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db + self.init_ui() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle("연차 사용 기록 추가") + self.setModal(True) + self.setMinimumWidth(360) + + layout = QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + title = QLabel("연차 사용 기록 추가") + title.setObjectName("dialog_subtitle") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # 날짜 + 구분 한 줄 + row1 = QHBoxLayout() + date_label = QLabel("날짜:") + date_label.setObjectName("field_label") + date_label.setFixedWidth(40) + self.date_edit = QDateEdit() + self.date_edit.setDate(QDate.currentDate()) + self.date_edit.setCalendarPopup(True) + row1.addWidget(date_label) + row1.addWidget(self.date_edit) + row1.addSpacing(8) + type_label = QLabel("구분:") + type_label.setObjectName("field_label") + type_label.setFixedWidth(40) + self.type_combo = QComboBox() + self.type_combo.addItem("연차", "annual") + self.type_combo.addItem("반차", "half") + self.type_combo.addItem("반반차", "quarter") + self.type_combo.addItem("시간", "hourly") + self.type_combo.currentIndexChanged.connect(self.on_type_changed) + row1.addWidget(type_label) + row1.addWidget(self.type_combo) + layout.addLayout(row1) + + # 사용 시간 (시간 연차용) + hours_layout = QHBoxLayout() + hours_label = QLabel("시간:") + hours_label.setObjectName("field_label") + hours_label.setFixedWidth(40) + self.hours_spin = QDoubleSpinBox() + self.hours_spin.setRange(0.5, 8.0) + self.hours_spin.setSingleStep(0.5) + self.hours_spin.setValue(1.0) + self.hours_spin.setSuffix(" 시간") + self.hours_spin.setEnabled(False) + hours_layout.addWidget(hours_label) + hours_layout.addWidget(self.hours_spin) + layout.addLayout(hours_layout) + + # 사유 + memo_layout = QHBoxLayout() + memo_label = QLabel("사유:") + memo_label.setObjectName("field_label") + memo_label.setFixedWidth(40) + self.memo_input = QLineEdit() + self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등") + memo_layout.addWidget(memo_label) + memo_layout.addWidget(self.memo_input) + layout.addLayout(memo_layout) + + # 안내 + info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.") + info_label.setObjectName("note_text") + layout.addWidget(info_label) + + # 버튼 + button_layout = QHBoxLayout() + save_button = QPushButton("저장") + save_button.setObjectName("btn_primary") + save_button.clicked.connect(self.save_record) + button_layout.addWidget(save_button) + cancel_button = QPushButton("취소") + cancel_button.clicked.connect(self.reject) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def on_type_changed(self, index): + """연차 유형 변경 시""" + leave_type = self.type_combo.currentData() + # 시간 연차일 때만 시간 입력 활성화 + self.hours_spin.setEnabled(leave_type == "hourly") + + def save_record(self): + """기록 저장""" + date = self.date_edit.date().toString("yyyy-MM-dd") + leave_type = self.type_combo.currentData() + leave_type_name = self.type_combo.currentText() + memo = self.memo_input.text().strip() + + # 사용 일수 계산 + if leave_type == "annual": + days = 1.0 + elif leave_type == "half": + days = 0.5 + elif leave_type == "quarter": + days = 0.25 + elif leave_type == "hourly": + hours = self.hours_spin.value() + days = hours / 8.0 + else: + days = 1.0 + + # 잔여 연차 확인 + current_balance = self.db.get_leave_balance() + if current_balance < days: + QMessageBox.warning( + self, + "잔여 연차 부족", + f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일" + ) + return + + # 확인 메시지 + hours = days * 8 + reply = QMessageBox.question( + self, + "연차 사용 기록 추가", + f"날짜: {date}\n" + f"구분: {leave_type_name}\n" + f"사용: {days}일 ({hours}시간)\n" + f"사유: {memo if memo else '(없음)'}\n\n" + f"이 기록을 추가하시겠습니까?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + try: + # 연차 사용 기록 추가 (파라미터 순서: days, date, leave_type, memo) + self.db.use_leave(days, date, leave_type_name, memo) + QMessageBox.information( + self, + "추가 완료", + f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다." + ) + self.accept() + except Exception as e: + QMessageBox.critical( + self, + "오류", + f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}" + ) + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = LeaveView() + dialog.exec_() diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..30930b7 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,2049 @@ +""" +메인 GUI 윈도우 +PyQt5를 사용한 메인 애플리케이션 인터페이스 +""" +import sys +from datetime import datetime, timedelta +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QPushButton, QProgressBar, + QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon, + QShortcut) +from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir +from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence + +from core.settings_keys import ( + DB_PATH_OVERRIDE, LANGUAGE, TIME_FORMAT, THEME, + WORKDAY_BOUNDARY_HOUR, WORK_MINUTES, WORK_HOURS, + LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, +) +from core.i18n import tr + +import os +import sys +# core 모듈을 import하기 위한 경로 추가 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import Database +from core.event_monitor import EventMonitor +from core.time_calculator import TimeCalculator +from ui.clock_in_dialog import ClockInDialog +from ui.calendar_view import CalendarView +from ui.stats_view import StatsView +from ui.leave_view import LeaveView +from ui.settings_view import SettingsView +from ui.break_view import BreakView +from core.notifier import Notifier +from utils.system_tray import SystemTrayIcon +from ui.styles import get_theme, ThemeColors, apply_dark_titlebar + + +class MainWindow(QMainWindow): + """메인 윈도우 클래스""" + + def __init__(self): + super().__init__() + + # 테마 적용 + self.current_theme = 'light' # 설정에서 로드 후 덮어씀 + + # 데이터베이스 — db_path_override 설정 시 그 경로 사용 (클라우드 동기화 폴더 등) + bootstrap = Database() + override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or '' + if override_path and os.path.exists(os.path.dirname(override_path) or '.'): + self.db = Database(override_path) + else: + self.db = bootstrap + self.event_monitor = EventMonitor() + + # 언어 초기화 (설정값 반영) + from core.i18n import set_language + set_language(self.db.get_setting(LANGUAGE, 'ko') or 'ko') + + # TimeCalculator 초기화 (설정값 반영) + settings = self.db.get_settings() + + # 시간 형식 설정 캐시 (매 초 DB 조회 방지) + self.cached_time_format = str(settings.get(TIME_FORMAT, '24')) + + # 테마 설정 + self.current_theme = str(settings.get(THEME, 'light')) + self.apply_theme(self.current_theme) + self.time_calc = self._build_time_calc(settings) + + # 알림 시스템 (db 전달 — 설정 키로 알림 가드) + self.notifier = Notifier(self, db=self.db) + self.notifier.notification_signal.connect(self.show_notification) + + # 책임 분리된 컨트롤러들 (1Hz hot path) + from ui.controllers.lock_monitor import LockMonitor + from ui.controllers.auto_lunch import AutoLunchManager + from ui.controllers.notification_orchestrator import NotificationOrchestrator + self._lock_monitor = LockMonitor(self) + self._auto_lunch = AutoLunchManager(self) + self._notif_orch = NotificationOrchestrator(self) + + # 시스템 트레이 + self.tray_icon = SystemTrayIcon(self) + self.tray_icon.show() + + # 윈도우 아이콘 설정 (시계 아이콘) + from PyQt5.QtGui import QIcon + # PyInstaller로 패키징된 경우 _MEIPASS 경로 사용 + if getattr(sys, 'frozen', False): + # PyInstaller로 실행 중 + base_path = sys._MEIPASS + else: + # 일반 Python 실행 + base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + icon_path = os.path.join(base_path, "3d-alarm.png") + if os.path.exists(icon_path): + window_icon = QIcon(icon_path) + else: + window_icon = self.tray_icon.create_icon("⏰") + self.setWindowIcon(window_icon) + + # 상태 변수 + self.clock_in_time = None + self.lunch_break_enabled = False + self.dinner_break_enabled = False + self.is_clocked_in = False + self.is_on_break = False # 외출 중 여부 + self.midnight_rollover_handled = False # 자정 넘김 처리 여부 + self.auto_lunch_applied_today = False # auto_lunch 중복 적용 방지 + # 컨트롤러는 init_ui() 이후 알림 시스템 생성 시점에 함께 초기화 + + # UI 초기화 + self.init_ui() + + # 타이머 시작 (1초마다 업데이트) + self.timer = QTimer() + self.timer.timeout.connect(self.update_display) + self.timer.start(1000) + + # 화면 잠금 감지 (5초 간격, auto_break_on_lock 설정 시 활성) + self._last_lock_state = False + self._lock_timer = QTimer() + self._lock_timer.timeout.connect(self._check_screen_lock) + self._lock_timer.start(5000) + + # 초기 데이터 로드 + self.load_today_data() + + # 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시) + QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True)) + + def _check_screen_lock(self): + """LockMonitor 컨트롤러로 위임 (5초 polling).""" + self._lock_monitor.tick() + + def _set_text_if_changed(self, widget, text: str) -> None: + """직전 값과 다를 때만 setText (1Hz hot path 무의미한 repaint 방지).""" + if widget.text() != text: + widget.setText(text) + + def format_time(self, dt: datetime, include_seconds: bool = False) -> str: + """ + 시간을 설정에 따라 형식화 + Args: + dt: datetime 객체 + include_seconds: 초 포함 여부 + Returns: + 형식화된 시간 문자열 + """ + # 캐시된 시간 형식 사용 (매 초 DB 조회 방지) + time_format = getattr(self, 'cached_time_format', '24') + + if time_format == '12': + # 12시간 형식 (오전/오후) + hour = dt.hour + minute = dt.minute + second = dt.second + period = "오전" if hour < 12 else "오후" + display_hour = hour % 12 + if display_hour == 0: + display_hour = 12 + if include_seconds: + return f"{period} {display_hour}:{minute:02d}:{second:02d}" + else: + return f"{period} {display_hour}:{minute:02d}" + else: + # 24시간 형식 + if include_seconds: + return dt.strftime('%H:%M:%S') + else: + return dt.strftime('%H:%M') + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle("⏰ " + tr('window.main_title')) + self.setGeometry(100, 100, 500, 620) + self.setMinimumSize(480, 520) + + # 외부 컨테이너 (스크롤 + 고정 하단) + from PyQt5.QtWidgets import QScrollArea + + outer_widget = QWidget() + outer_layout = QVBoxLayout() + outer_layout.setSpacing(0) + outer_layout.setContentsMargins(0, 0, 0, 0) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # 중앙 위젯 (스크롤 내부) + central_widget = QWidget() + central_widget.setObjectName("central_widget") + scroll_area.setWidget(central_widget) + + outer_layout.addWidget(scroll_area, 1) + outer_widget.setLayout(outer_layout) + self.setCentralWidget(outer_widget) + + # 메인 레이아웃 + main_layout = QVBoxLayout() + main_layout.setSpacing(8) + main_layout.setContentsMargins(12, 10, 12, 10) + + # 1. 헤더 - 앱 타이틀 + title_label = QLabel("퇴근시간 계산기") + title_label.setObjectName("app_title") + title_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(title_label) + + # 2. 날짜 표시 + self.date_label = QLabel() + self.date_label.setObjectName("date_label") + self.date_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(self.date_label) + + # 2. 출근 정보 그룹 + clock_in_group = self.create_clock_in_group() + main_layout.addWidget(clock_in_group) + + # 3. 남은 시간 표시 그룹 + remaining_group = self.create_remaining_time_group() + main_layout.addWidget(remaining_group) + + # 4. 예상 퇴근시간 + self.expected_time_label = QLabel() + self.expected_time_label.setObjectName("expected_time") + self.expected_time_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(self.expected_time_label) + + # 5. 점심/저녁 토글 (가로 배치) + meal_button_layout = QHBoxLayout() + meal_button_layout.setSpacing(8) + + self.lunch_button = QPushButton(tr('btn.lunch_add')) + self.lunch_button.setCheckable(True) + self.lunch_button.clicked.connect(self.toggle_lunch_break) + + self.dinner_button = QPushButton(tr('btn.dinner_add')) + self.dinner_button.setCheckable(True) + self.dinner_button.clicked.connect(self.toggle_dinner_break) + + meal_button_layout.addWidget(self.lunch_button) + meal_button_layout.addWidget(self.dinner_button) + main_layout.addLayout(meal_button_layout) + + # 5-1. 외출 버튼 + break_button_layout = QHBoxLayout() + break_button_layout.setSpacing(8) + + self.break_out_button = QPushButton("외출") + self.break_out_button.clicked.connect(self.break_out) + + self.break_in_button = QPushButton("복귀") + self.break_in_button.clicked.connect(self.break_in) + self.break_in_button.setEnabled(False) + + self.break_manage_button = QPushButton("외출 관리") + self.break_manage_button.clicked.connect(self.show_break_management) + + break_button_layout.addWidget(self.break_out_button) + break_button_layout.addWidget(self.break_in_button) + break_button_layout.addWidget(self.break_manage_button) + + main_layout.addLayout(break_button_layout) + + # 외출 상태 라벨 + self.break_status_label = QLabel("") + self.break_status_label.setObjectName("field_label") + self.break_status_label.setAlignment(Qt.AlignCenter) + main_layout.addWidget(self.break_status_label) + + # 6. 연장근무 적립 현황 + overtime_group = self.create_overtime_group() + main_layout.addWidget(overtime_group) + + central_widget.setLayout(main_layout) + + # 7. 퇴근 버튼 - 강조 스타일 (고정 하단) + fixed_bottom = QWidget() + fixed_bottom.setObjectName("fixed_bottom") + fixed_bottom_layout = QVBoxLayout() + fixed_bottom_layout.setSpacing(8) + fixed_bottom_layout.setContentsMargins(12, 8, 12, 10) + + self.clock_out_button = QPushButton(tr('btn.clock_out')) + self.clock_out_button.setObjectName("clock_out_button") + self.clock_out_button.setCursor(Qt.PointingHandCursor) + self.clock_out_button.clicked.connect(self.handle_clock_out_button) + fixed_bottom_layout.addWidget(self.clock_out_button) + + # 8. 하단 버튼 + bottom_layout = QHBoxLayout() + bottom_layout.setSpacing(8) + + stats_button = QPushButton(tr('menu.stats')) + calendar_button = QPushButton(tr('menu.calendar')) + report_button = QPushButton(tr('menu.daily_report')) + help_button = QPushButton(tr('menu.help')) + settings_button = QPushButton(tr('menu.settings')) + + for btn in [stats_button, calendar_button, report_button, help_button, settings_button]: + bottom_layout.addWidget(btn) + + # 버튼 연결 + stats_button.clicked.connect(self.show_stats) + calendar_button.clicked.connect(self.show_calendar) + report_button.clicked.connect(self.generate_daily_report) + help_button.clicked.connect(self.show_help) + settings_button.clicked.connect(self.show_settings) + + fixed_bottom_layout.addLayout(bottom_layout) + fixed_bottom.setLayout(fixed_bottom_layout) + outer_layout.addWidget(fixed_bottom, 0) + + # 초기 날짜 업데이트 + self.update_date_label() + + # 앱 내 단축키 + self._setup_shortcuts() + + def _setup_shortcuts(self): + """앱 내 단축키 — 메인 창 포커스 시만 동작""" + bindings = [ + ("Ctrl+O", self.handle_clock_out_button), # 출/퇴근 토글 + ("Ctrl+L", lambda: self.lunch_button.click()), # 점심 + ("Ctrl+D", lambda: self.dinner_button.click()), # 저녁 + ("Ctrl+B", self.show_break_management), # 외출 관리 + ("Ctrl+,", self.show_settings), # 설정 + ("F1", self.show_help), # 도움말 + ("F5", lambda: self.check_for_updates(silent=False)), # 업데이트 확인 + ("Ctrl+R", self.generate_daily_report), # 일일보고 + ] + for keyseq, handler in bindings: + sc = QShortcut(QKeySequence(keyseq), self) + sc.activated.connect(handler) + + def create_clock_in_group(self) -> QGroupBox: + """출근 정보 그룹 생성""" + group = QGroupBox("오늘의 근무") + + layout = QVBoxLayout() + layout.setSpacing(4) + layout.setContentsMargins(12, 20, 12, 8) + + # 출근 시간 레이아웃 + clock_in_layout = QHBoxLayout() + clock_in_label = QLabel("출근:") + clock_in_label.setObjectName("field_label") + clock_in_label.setFixedWidth(50) + self.clock_in_value = QLabel("--:--:--") + self.clock_in_value.setObjectName("time_value") + self.clock_in_value.setMinimumWidth(90) + self.edit_clock_in_button = QPushButton("수정") + self.edit_clock_in_button.setObjectName("btn_small") + self.edit_clock_in_button.setFixedWidth(70) + self.edit_clock_in_button.clicked.connect(self.manual_clock_in) + + clock_in_layout.addWidget(clock_in_label) + clock_in_layout.addWidget(self.clock_in_value) + clock_in_layout.addStretch() + clock_in_layout.addWidget(self.edit_clock_in_button) + + # 현재 시간 레이아웃 + current_layout = QHBoxLayout() + current_label = QLabel("현재:") + current_label.setObjectName("field_label") + current_label.setFixedWidth(50) + self.current_time_value = QLabel("--:--:--") + self.current_time_value.setObjectName("time_value") + self.current_time_value.setMinimumWidth(90) + + current_layout.addWidget(current_label) + current_layout.addWidget(self.current_time_value) + current_layout.addStretch() + + layout.addLayout(clock_in_layout) + layout.addLayout(current_layout) + + group.setLayout(layout) + return group + + def create_remaining_time_group(self) -> QGroupBox: + """남은 시간 표시 그룹 생성""" + self.remaining_time_group = QGroupBox("남은 시간") + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 20, 12, 8) + + # 남은 시간 라벨 + self.remaining_time_label = QLabel("--:--:--") + self.remaining_time_label.setObjectName("time_display") + self.remaining_time_label.setAlignment(Qt.AlignCenter) + + # 프로그레스 바 + self.progress_bar = QProgressBar() + self.progress_bar.setTextVisible(False) + + layout.addWidget(self.remaining_time_label) + layout.addWidget(self.progress_bar) + + self.remaining_time_group.setLayout(layout) + return self.remaining_time_group + + def create_overtime_group(self) -> QGroupBox: + """연장근무 및 연차 현황 그룹 생성""" + group = QGroupBox("연장근무 및 연차 현황") + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 20, 12, 8) + + # 연장근무 섹션 + overtime_header = QHBoxLayout() + overtime_title = QLabel("연장근무 적립") + overtime_title.setObjectName("section_title") + overtime_header.addWidget(overtime_title) + overtime_header.addStretch() + + self.overtime_balance_label = QLabel("0분 (×0)") + self.overtime_balance_label.setObjectName("badge_overtime") + overtime_header.addWidget(self.overtime_balance_label) + layout.addLayout(overtime_header) + + # 연장근무 사용 버튼 (1줄) + overtime_button_layout = QHBoxLayout() + overtime_button_layout.setSpacing(4) + use_30min_button = QPushButton("30분") + use_1hour_button = QPushButton("1시간") + use_2hour_button = QPushButton("2시간") + use_custom_overtime_button = QPushButton("직접입력") + overtime_detail_button = QPushButton("상세") + + for btn in [use_30min_button, use_1hour_button, use_2hour_button, use_custom_overtime_button, overtime_detail_button]: + btn.setObjectName("btn_small") + overtime_button_layout.addWidget(btn) + + use_30min_button.clicked.connect(lambda: self.use_overtime(30)) + use_1hour_button.clicked.connect(lambda: self.use_overtime(60)) + use_2hour_button.clicked.connect(lambda: self.use_overtime(120)) + use_custom_overtime_button.clicked.connect(self.use_custom_overtime) + overtime_detail_button.clicked.connect(self.show_overtime_detail) + + layout.addLayout(overtime_button_layout) + + # 구분선 + separator = QLabel() + separator.setObjectName("separator") + layout.addWidget(separator) + + # 연차 섹션 + leave_header = QHBoxLayout() + leave_title = QLabel("연차") + leave_title.setObjectName("section_title") + leave_header.addWidget(leave_title) + leave_header.addStretch() + + self.leave_balance_label = QLabel("잔여: 0일") + self.leave_balance_label.setObjectName("badge_leave") + leave_header.addWidget(self.leave_balance_label) + layout.addLayout(leave_header) + + # 연차 사용 버튼 (1줄) + leave_button_layout = QHBoxLayout() + leave_button_layout.setSpacing(4) + use_30min_leave_button = QPushButton("30분") + use_1hour_leave_button = QPushButton("1시간") + use_half_leave_button = QPushButton("반차") + use_full_leave_button = QPushButton("연차") + leave_detail_button = QPushButton("상세") + + for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button, use_full_leave_button, leave_detail_button]: + btn.setObjectName("btn_small") + leave_button_layout.addWidget(btn) + + use_30min_leave_button.clicked.connect(lambda: self.use_leave(0.5/8)) # 0.0625일 + use_1hour_leave_button.clicked.connect(lambda: self.use_leave(1.0/8)) # 0.125일 + use_half_leave_button.clicked.connect(lambda: self.use_leave(0.5)) + use_full_leave_button.clicked.connect(lambda: self.use_leave(1.0)) + leave_detail_button.clicked.connect(self.show_leave_detail) + + layout.addLayout(leave_button_layout) + + # 구분선 + separator2 = QLabel() + separator2.setObjectName("separator") + layout.addWidget(separator2) + + # 총합 시간 표시 + total_header = QHBoxLayout() + total_title = QLabel("총 보유 시간") + total_title.setObjectName("section_title") + total_header.addWidget(total_title) + total_header.addStretch() + + self.total_time_label = QLabel("0시간 0분") + self.total_time_label.setObjectName("badge_total") + total_header.addWidget(self.total_time_label) + layout.addLayout(total_header) + + group.setLayout(layout) + return group + + def load_today_data(self): + """오늘 데이터 로드""" + # 먼저 이전 퇴근 기록들 자동 처리 + self.auto_clock_out_previous_days() + + today_record = self.db.get_today_record() + + if today_record and today_record.get('clock_in'): + # 이미 출근 기록이 있음 + clock_in_str = today_record['clock_in'] + self.clock_in_time = datetime.strptime( + f"{datetime.now().date()} {clock_in_str}", + "%Y-%m-%d %H:%M:%S" + ) + self.lunch_break_enabled = bool(today_record.get('lunch_break', False)) + self.dinner_break_enabled = bool(today_record.get('dinner_break', False)) + self.is_clocked_in = True + self.midnight_rollover_handled = False # 새로운 근무일 시작 시 플래그 리셋 + # 점심이 이미 적용되어 있으면 auto_lunch가 다시 트리거되지 않도록 + self.auto_lunch_applied_today = self.lunch_break_enabled + + # 퇴근했는지 확인 + if today_record.get('clock_out'): + self.is_clocked_in = False + self.clock_out_button.setEnabled(True) + self.clock_out_button.setText("🔄 퇴근 취소") + + # 퇴근 완료 상태에서도 출퇴근 시간은 표시 + self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True)) + else: + # 출근 중이면 퇴근하기 버튼 + self.clock_out_button.setEnabled(True) + self.clock_out_button.setText("✅ 퇴근하기") + + else: + # 출근 기록 없음 - 자동 감지 시도 + auto_clock_in = self.event_monitor.get_work_start_time() + + if auto_clock_in: + # 자동 감지 성공 + self.clock_in_time = auto_clock_in + self.is_clocked_in = True + self.midnight_rollover_handled = False # 새로운 근무일 시작 시 플래그 리셋 + + # DB에 저장 + today = datetime.now().date().isoformat() + clock_in_str = auto_clock_in.strftime("%H:%M:%S") + self.db.add_work_record(today, clock_in_str, is_manual=False) + + QMessageBox.information( + self, + "자동 출근 감지", + f"출근 시간이 자동으로 감지되었습니다.\n" + f"출근: {clock_in_str}\n\n" + f"잘못된 경우 수정할 수 있습니다." + ) + else: + # 자동 감지 실패 - 수동 입력 요청 + reply = QMessageBox.question( + self, + "출근 시간 입력", + "출근 시간을 자동으로 감지하지 못했습니다.\n\n" + "수동으로 입력하시겠습니까?\n" + "(관리자 권한으로 실행하면 자동 감지됩니다)", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.manual_clock_in() + else: + self.is_clocked_in = False + self.clock_out_button.setEnabled(False) + + # 점심 버튼 상태 업데이트 + self.lunch_button.setChecked(self.lunch_break_enabled) + self.update_lunch_status() + + # 저녁 버튼 상태 업데이트 + self.dinner_button.setChecked(self.dinner_break_enabled) + self.update_dinner_status() + + # 외출 상태 업데이트 + self.update_break_status() + + # 연장근무 및 연차 잔액 업데이트 + self.update_overtime_balance() + self.update_leave_balance() + + def update_display(self): + """디스플레이 업데이트 (1초마다)""" + now = datetime.now() + + # 현재 시간은 항상 업데이트 (출근 전에도 표시) + self._set_text_if_changed(self.current_time_value, self.format_time(now, include_seconds=True)) + + # 근무일 경계 시간 확인 + workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) + + # 새 근무일 체크: 퇴근 완료 상태에서 날짜가 바뀌고 경계 시간 이후면 새 출근 유도 + if not self.is_clocked_in and self.clock_in_time: + # 이전 출근 기록이 있고, 날짜가 바뀌었고, 경계 시간 이후면 + if self.clock_in_time.date() != now.date() and now.hour >= workday_boundary_hour: + self.start_new_workday(now) + return + + # 출근하지 않았으면 여기서 종료 + if not self.is_clocked_in or not self.clock_in_time: + return + + # 근무일 경계 체크: 출근일과 현재 날짜가 다르고, 경계 시간(기본 6시) 이후면 롤오버 + # 예: 야근으로 새벽 2시까지 일해도 6시 전까지는 전날 근무로 인정 + if self.clock_in_time.date() != now.date() and now.hour >= workday_boundary_hour: + self.handle_workday_rollover(now) + + # 출근 시간 업데이트 (설정 변경 시에도 갱신됨) + self._set_text_if_changed(self.clock_in_value, self.format_time(self.clock_in_time, include_seconds=True)) + + # 자동 점심시간 적용 (설정 + 출근 후 4시간 경과 + 미적용 + 1회만) + self._auto_lunch.maybe_apply(now) + + # 외출 시간 계산 + 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( + self.clock_in_time, + include_lunch=self.lunch_break_enabled, + include_dinner=self.dinner_break_enabled, + current_time=now, + break_minutes=break_minutes + ) + # 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능) + remaining -= timedelta(minutes=total_time_off) + + # 남은 시간 표시 및 추가 근무 처리 + if remaining.total_seconds() < 0: + # 추가 근무 중 + self.remaining_time_group.setTitle("추가 근무 중") + # + 기호로 표시 + total_seconds = int(abs(remaining.total_seconds())) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + remaining_str = f"+{hours:02d}:{minutes:02d}:{seconds:02d}" + self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_overtime')};") + + else: + # 정상 근무 중 + self.remaining_time_group.setTitle("남은 시간") + remaining_str = self.time_calc.format_time_delta(remaining) + + if remaining.total_seconds() < 1800: # 30분 이내 + self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_warning')};") + else: + self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};") + + + self._set_text_if_changed(self.remaining_time_label, remaining_str) + + # 진행률 업데이트 + # - 외출 시간: 필요 근무시간 증가 (일을 안 한 시간이므로 더 일해야 함) + # - 추가근무 사용: 필요 근무시간 감소 (미리 일한 것을 사용하므로 덜 일해도 됨) + progress = self.time_calc.calculate_work_progress( + self.clock_in_time, + include_lunch=self.lunch_break_enabled, + include_dinner=self.dinner_break_enabled, + current_time=now, + break_minutes=break_minutes, + overtime_used_minutes=total_time_off + ) + self.progress_bar.setValue(int(progress * 100)) + + # 예상 퇴근 시간 (외출 시간 포함) + # 추가근무 사용 시간만큼 일찍 퇴근 가능하므로 실제 퇴근 시간에서 차감 + expected_clock_out = self.time_calc.calculate_clock_out_time( + self.clock_in_time, + include_lunch=self.lunch_break_enabled, + include_dinner=self.dinner_break_enabled, + break_minutes=break_minutes + ) + # 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김 + expected_clock_out -= timedelta(minutes=total_time_off) + self._set_text_if_changed( + self.expected_time_label, + f"예상 퇴근: {self.format_time(expected_clock_out)}" + ) + + # 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함) + self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds()) + + # 트레이 / 미니 위젯 갱신 + if remaining.total_seconds() < 0: + display_str = f"+{abs(int(remaining.total_seconds() // 3600)):02d}:{abs(int((remaining.total_seconds() % 3600) // 60)):02d}" + else: + display_str = self.time_calc.format_time_delta(remaining) + self.tray_icon.update_time_display(display_str) + if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible(): + self._mini_widget.update_remaining(remaining_str) + + def update_date_label(self): + """날짜 라벨 업데이트""" + now = datetime.now() + weekday_kr = ['월', '화', '수', '목', '금', '토', '일'] + weekday = weekday_kr[now.weekday()] + date_str = f"{now.year}년 {now.month}월 {now.day}일 {weekday}요일" + self.date_label.setText(date_str) + + def toggle_lunch_break(self): + """점심시간 토글""" + self.lunch_break_enabled = self.lunch_button.isChecked() + self.update_lunch_status() + + # 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지) + if self.lunch_break_enabled: + self.auto_lunch_applied_today = True + + # DB 업데이트 + if self.is_clocked_in: + today = datetime.now().date().isoformat() + self.db.update_lunch_break(today, self.lunch_break_enabled) + + def toggle_dinner_break(self): + """저녁시간 토글""" + self.dinner_break_enabled = self.dinner_button.isChecked() + self.update_dinner_status() + + # DB 업데이트 + if self.is_clocked_in: + today = datetime.now().date().isoformat() + self.db.update_dinner_break(today, self.dinner_break_enabled) + + def update_lunch_status(self): + """점심시간 상태 업데이트""" + self.lunch_button.setText( + tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add') + ) + + def update_dinner_status(self): + """저녁시간 상태 업데이트""" + self.dinner_button.setText( + tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add') + ) + + def update_overtime_balance(self): + """연장근무 잔액 업데이트""" + balance_minutes = self.db.get_total_overtime_balance() + tokens, time_str = self.time_calc.format_overtime_tokens(balance_minutes) + + hours = balance_minutes // 60 + mins = balance_minutes % 60 + self.overtime_balance_label.setText(f"{hours}시간 {mins}분") + self.update_total_time() + + def update_leave_balance(self): + """연차 잔액 업데이트""" + balance = self.db.get_leave_balance() + balance_hours = int(balance * 8) + balance_mins = int((balance * 8 % 1) * 60) + self.leave_balance_label.setText(f"{balance_hours}시간 {balance_mins}분") + self.update_total_time() + + def update_total_time(self): + """연차 + 연장근무 총합 시간 업데이트""" + # 연장근무 시간 (분) + overtime_minutes = self.db.get_total_overtime_balance() + + # 연차 시간 (일 -> 분으로 변환, 1일 = 8시간 = 480분) + leave_balance = self.db.get_leave_balance() + leave_minutes = int(leave_balance * 480) + + # 총합 (분) + total_minutes = overtime_minutes + leave_minutes + total_hours = total_minutes // 60 + total_mins = total_minutes % 60 + + self.total_time_label.setText(f"{total_hours}시간 {total_mins}분") + + def use_overtime(self, minutes: int): + """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" + balance = self.db.get_total_overtime_balance() + new_balance = balance - minutes + + # 음수가 되는 경우 추가 경고 + if new_balance < 0: + reply = QMessageBox.warning( + self, + "연장근무 사용 (마이너스 전환)", + f"{minutes}분의 연장근무를 사용하시겠습니까?\n\n" + f"현재 잔액: {balance}분\n" + f"사용 후 잔액: {new_balance}분 (마이너스)\n\n" + f"⚠️ 잔액이 마이너스가 됩니다.\n" + f"나중에 초과근무로 갚아야 합니다.", + QMessageBox.Yes | QMessageBox.No + ) + else: + reply = QMessageBox.question( + self, + "연장근무 사용", + f"{minutes}분의 연장근무를 사용하시겠습니까?\n\n" + f"현재 잔액: {balance}분\n" + f"사용 후 잔액: {new_balance}분", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + try: + # 오늘 날짜 + from datetime import date + today = date.today().isoformat() + + # 추가근무 사용 기록 추가 (work_record_id는 NULL로 - 직접 사용) + self.db.add_overtime_usage( + work_record_id=None, + used_minutes=minutes, + date=today, + reason="직접 사용" + ) + + QMessageBox.information( + self, + "사용 완료", + f"{minutes}분이 사용되었습니다." + ) + self.update_overtime_balance() + except Exception as e: + QMessageBox.warning( + self, + "사용 실패", + str(e) + ) + self.update_overtime_balance() + + def show_overtime_detail(self): + """연장근무 상세 내역 보기""" + from ui.overtime_view import OvertimeView + dialog = OvertimeView(self, self.db) + dialog.exec_() + # 다이얼로그 종료 후 잔액 업데이트 + self.update_overtime_balance() + + def use_leave(self, days: float): + """연차 사용""" + balance = self.db.get_leave_balance() + + if balance < days: + QMessageBox.warning( + self, + "잔액 부족", + f"사용 가능한 연차가 부족합니다.\n" + f"현재 잔액: {balance}일\n" + f"요청: {days}일" + ) + return + + # 사용 날짜 입력 + from PyQt5.QtWidgets import QInputDialog, QLineEdit + from datetime import date + + today = date.today().isoformat() + date_str, ok = QInputDialog.getText( + self, + "연차 사용 날짜", + "사용 날짜를 입력하세요 (YYYY-MM-DD):", + QLineEdit.Normal, + today + ) + + if not ok or not date_str: + return + + # 날짜 형식 검증 + try: + datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + QMessageBox.warning( + self, + "입력 오류", + "날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-15)" + ) + return + + # 메모 입력 + memo, ok = QInputDialog.getText( + self, + "연차 사유", + "사유를 입력하세요 (선택):", + QLineEdit.Normal, + "" + ) + + if not ok: + return + + # 사용 확인 + if days == 1.0: + leave_type = "연차" + days_str = "1일" + elif days == 0.5: + leave_type = "반차" + days_str = "0.5일 (4시간)" + elif days == 0.125: + leave_type = "시간연차" + days_str = "0.125일 (1시간)" + elif days == 0.0625: + leave_type = "시간연차" + days_str = "0.0625일 (30분)" + else: + leave_type = "연차" + hours = days * 8 + days_str = f"{days}일 ({hours}시간)" + + reply = QMessageBox.question( + self, + "연차 사용", + f"{date_str}에 {leave_type} {days_str}를 사용하시겠습니까?\n\n" + f"사용 후 잔액: {balance - days}일", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + try: + self.db.use_leave(days, date_str, leave_type, memo or None) + QMessageBox.information( + self, + "사용 완료", + f"{leave_type}가 사용되었습니다." + ) + self.update_leave_balance() + except ValueError as e: + # 잔액 부족 등 검증 오류 + QMessageBox.warning( + self, + "사용 불가", + str(e) + ) + self.update_leave_balance() # 최신 잔액으로 새로고침 + except Exception as e: + # 기타 데이터베이스 오류 + QMessageBox.critical( + self, + "오류", + f"연차 사용 중 오류가 발생했습니다:\n{str(e)}" + ) + + def use_custom_overtime(self): + """사용자 정의 추가근무 사용""" + from PyQt5.QtWidgets import QInputDialog + + balance = self.db.get_total_overtime_balance() + + # 사용할 시간 입력 (30분 단위) + hours, ok = QInputDialog.getDouble( + self, + "시간 입력", + "사용할 시간을 입력하세요 (0.5시간 단위):\n예) 0.5, 1, 1.5, 2, 3, 4", + 0.5, + 0.5, + 24.0, + 1 + ) + + if not ok: + return + + # 시간을 분으로 변환 + minutes = int(hours * 60) + + # 30분 단위 검증 + if minutes % 30 != 0: + QMessageBox.warning( + self, + "입력 오류", + "30분 단위로만 사용 가능합니다.\n예) 0.5시간, 1시간, 1.5시간" + ) + return + + # use_overtime 메서드 호출 (내부에서 잔액 검증 수행) + self.use_overtime(minutes) + + def use_custom_leave(self): + """사용자 정의 연차 사용""" + from PyQt5.QtWidgets import QInputDialog + + balance = self.db.get_leave_balance() + + # 사용할 시간 입력 (시간 단위) + hours, ok = QInputDialog.getDouble( + self, + "시간 입력", + "사용할 시간을 입력하세요 (0.5시간 단위):\n예) 0.5, 1, 1.5, 2, 4, 8", + 0.5, + 0.5, + 80.0, + 1 + ) + + if not ok: + return + + # 시간을 일수로 변환 (8시간 = 1일) + days = hours / 8.0 + + if days > balance: + QMessageBox.warning( + self, + "잔액 부족", + f"사용 가능한 연차가 부족합니다.\n" + f"현재 잔액: {balance}일 ({balance * 8}시간)\n" + f"요청: {days}일 ({hours}시간)" + ) + return + + # use_leave 메서드 호출 + self.use_leave(days) + + def show_leave_detail(self): + """연차 상세 내역 보기""" + from ui.leave_view import LeaveView + dialog = LeaveView(self, self.db) + dialog.exec_() + # 다이얼로그 종료 후 잔액 업데이트 + self.update_leave_balance() + + def handle_clock_out_button(self): + """퇴근 버튼 클릭 핸들러 - 상태에 따라 퇴근 또는 취소""" + if self.is_clocked_in: + # 출근 중 -> 퇴근 처리 + self.clock_out() + else: + # 퇴근 완료 -> 퇴근 취소 + self.cancel_clock_out() + + def clock_out(self): + """퇴근 처리""" + if not self.is_clocked_in: + return + + now = datetime.now() + + # 확인 메시지 + reply = QMessageBox.question( + self, + "퇴근 확인", + f"퇴근 처리하시겠습니까?\n\n" + f"퇴근 시간: {now.strftime('%H:%M:%S')}", + QMessageBox.Yes | QMessageBox.No + ) + + if reply != QMessageBox.Yes: + # 취소 시 버튼 다시 활성화 + self.clock_out_button.setEnabled(True) + return + + # 총 근무시간 계산 + total_hours = self.time_calc.calculate_total_work_time( + self.clock_in_time, now + ) + + # 주말/공휴일 체크 + is_non_working_day = self.time_calc.is_non_working_day(now, self.db) + day_type = self.time_calc.get_day_type(now, self.db) + + # 오늘의 외출 시간 가져오기 + break_minutes = self.db.get_total_break_minutes_today() + + if is_non_working_day: + # 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외) + work_minutes = int(total_hours * 60) + if self.lunch_break_enabled: + work_minutes -= self.time_calc.lunch_duration_minutes # 점심시간 제외 + if self.dinner_break_enabled: + work_minutes -= self.time_calc.dinner_duration_minutes # 저녁시간 제외 + work_minutes -= break_minutes # 외출시간 제외 + # 음수 방지 + work_minutes = max(0, work_minutes) + # 30분 단위로 절삭 + overtime_earned = (work_minutes // 30) * 30 + overtime_actual = work_minutes + else: + # 평일: 정상 연장근무 계산 (외출 시간 포함) + overtime_actual, overtime_earned = self.time_calc.calculate_overtime( + self.clock_in_time, now, + include_lunch=self.lunch_break_enabled, + include_dinner=self.dinner_break_enabled, + break_minutes=break_minutes + ) + + # DB 업데이트 + today = datetime.now().date().isoformat() + clock_out_str = now.strftime("%H:%M:%S") + + self.db.update_clock_out( + today, clock_out_str, total_hours, + overtime_actual, overtime_earned + ) + + # 연장근무 적립 기록 + if overtime_earned > 0: + today_record = self.db.get_today_record() + if today_record: + self.db.add_overtime_earned( + today_record['id'], overtime_earned, today + ) + + # 상태 업데이트 + self.is_clocked_in = False + self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋 + self.clock_out_button.setEnabled(True) + self.clock_out_button.setText("🔄 퇴근 취소") + + # 결과 메시지 + msg = f"퇴근 처리되었습니다!\n\n" + if day_type == 'weekend': + msg += f"[주말 근무]\n" + elif day_type == 'holiday': + holiday_info = self.db.get_holiday(today) + holiday_name = holiday_info['name'] if holiday_info else "공휴일" + msg += f"[공휴일 근무 - {holiday_name}]\n" + msg += f"총 근무시간: {total_hours:.1f}시간\n" + + if overtime_earned > 0: + tokens, time_str = self.time_calc.format_overtime_tokens(overtime_earned) + if is_non_working_day: + msg += f"전체 적립: {time_str} (🕐×{tokens})" + else: + msg += f"연장근무 적립: {time_str} (🕐×{tokens})" + + QMessageBox.information(self, "퇴근 완료", msg) + + # 잔액 업데이트 + self.update_overtime_balance() + + def cancel_clock_out(self): + """퇴근 취소""" + # 확인 대화상자 + reply = QMessageBox.question( + self, + "퇴근 취소", + "퇴근을 취소하시겠습니까?\n\n" + "퇴근 시간과 연장근무 적립 내역이 삭제됩니다.", + QMessageBox.Yes | QMessageBox.No + ) + + if reply != QMessageBox.Yes: + return + + try: + # DB에서 퇴근 취소 + today = datetime.now().date().isoformat() + success = self.db.cancel_clock_out(today) + + if success: + # 상태 복원 + self.is_clocked_in = True + self.clock_out_button.setEnabled(True) + self.clock_out_button.setText("✅ 퇴근하기") + + # 잔액 업데이트 + self.update_overtime_balance() + + QMessageBox.information( + self, + "퇴근 취소 완료", + "퇴근이 취소되었습니다.\n다시 근무 중 상태로 전환되었습니다." + ) + else: + QMessageBox.warning( + self, + "취소 실패", + "퇴근 기록을 찾을 수 없습니다." + ) + + except Exception as e: + QMessageBox.critical( + self, + "오류", + f"퇴근 취소 중 오류가 발생했습니다:\n{str(e)}" + ) + + def handle_workday_rollover(self, now: datetime): + """근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근 + + 예: 경계시간이 6시인 경우 + - 전날 근무 → 05:59:59 퇴근 처리 (자정~6시 전까지 초과근무로 인정) + - 당일 근무 → 06:00:00 출근 처리 + """ + if not self.is_clocked_in or not self.clock_in_time: + return + + # 이미 처리되었으면 중복 실행 방지 + if self.midnight_rollover_handled: + return + + # 근무일 경계 시간 가져오기 + workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) + boundary_time_str = f"{workday_boundary_hour:02d}:00:00" + before_boundary_str = f"{workday_boundary_hour - 1:02d}:59:59" if workday_boundary_hour > 0 else "23:59:59" + + # 전날 기록은 출근일 날짜로 저장 + workday_str = self.clock_in_time.date().isoformat() + + # 퇴근 시간: 오늘 경계시간 직전 (예: 05:59:59) + workday_end = datetime.combine( + now.date(), + datetime.strptime(before_boundary_str, "%H:%M:%S").time() + ) + + # 외출 중이라면 자동으로 복귀 처리 (출근일 날짜로 조회) + active_break = self.db.get_active_break_record(target_date=workday_str) + if active_break: + self.db.update_break_return(active_break['id'], before_boundary_str) + + # 총 근무시간 계산 (출근 ~ 경계시간 직전) + total_hours = self.time_calc.calculate_total_work_time( + self.clock_in_time, workday_end + ) + + # 주말/공휴일 체크 + is_non_working_day = self.time_calc.is_non_working_day(self.clock_in_time, self.db) + + # 외출 시간 가져오기 + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT SUM(total_minutes) + FROM break_records + WHERE date = ? + ''', (workday_str,)) + break_minutes = cursor.fetchone()[0] or 0 + conn.close() + + # 추가근무 계산 + if is_non_working_day: + work_minutes = int(total_hours * 60) + if self.lunch_break_enabled: + work_minutes -= self.time_calc.lunch_duration_minutes + if self.dinner_break_enabled: + work_minutes -= self.time_calc.dinner_duration_minutes + work_minutes -= break_minutes + # 음수 방지 + work_minutes = max(0, work_minutes) + overtime_earned = (work_minutes // 30) * 30 + overtime_actual = work_minutes + else: + overtime_actual, overtime_earned = self.time_calc.calculate_overtime( + self.clock_in_time, workday_end, + include_lunch=self.lunch_break_enabled, + include_dinner=self.dinner_break_enabled, + break_minutes=break_minutes + ) + + # DB 업데이트 (출근일 날짜에 퇴근 시간 기록) + self.db.update_clock_out( + workday_str, before_boundary_str, total_hours, + overtime_actual, overtime_earned + ) + + # 연장근무 적립 + if overtime_earned > 0: + workday_record = self.db.get_work_record(workday_str) + if workday_record: + self.db.add_overtime_earned( + workday_record['id'], overtime_earned, workday_str + ) + + # 오늘 경계시간에 출근 처리 (예: 06:00:00) + today_str = now.date().isoformat() + self.db.add_work_record(today_str, boundary_time_str, lunch_break=False, is_manual=False) + + # 상태 업데이트 + self.clock_in_time = datetime.combine( + now.date(), + datetime.strptime(boundary_time_str, "%H:%M:%S").time() + ) + + # 외출 중이었다면 오늘도 외출 시작 + if self.is_on_break: + today_record = self.db.get_today_record() + if today_record: + self.db.add_break_record( + today_record['id'], today_str, boundary_time_str, None + ) + + # 근무일 경계 처리 완료 플래그 설정 + self.midnight_rollover_handled = True + + QMessageBox.information( + self, + "근무일 경계 경과", + f"근무일 경계 시간({workday_boundary_hour}시)이 지나 자동으로 처리되었습니다.\n\n" + f"전날 근무: {before_boundary_str} 퇴근 처리\n" + f"금일 근무: {boundary_time_str} 출근 처리\n\n" + f"자정~{workday_boundary_hour}시 전까지의 야근은 전날 초과근무로 인정됩니다." + ) + + # 화면 업데이트 + self.load_today_data() + self.update_overtime_balance() + + def start_new_workday(self, now: datetime): + """새 근무일 시작 (퇴근 완료 상태에서 날짜가 바뀌고 경계 시간 이후)""" + workday_boundary_hour = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) + + # 오늘 이미 출근 기록이 있는지 확인 + today_str = now.date().isoformat() + today_record = self.db.get_work_record(today_str) + + if today_record: + # 이미 오늘 기록이 있으면 그것을 로드 + self.load_today_data() + return + + # 새 근무일 알림 및 출근 처리 + reply = QMessageBox.question( + self, + "새 근무일", + f"새로운 근무일입니다. ({today_str})\n\n" + f"출근 처리를 하시겠습니까?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # 상태 초기화 + self.clock_in_time = None + self.is_clocked_in = False + self.lunch_break_enabled = False + self.dinner_break_enabled = False + self.midnight_rollover_handled = False + self.auto_lunch_applied_today = False + + # 새 출근 처리 (load_today_data가 자동 감지 또는 수동 입력 처리) + self.load_today_data() + else: + # 거부하면 상태만 초기화하고 대기 + self.clock_in_time = None + self.is_clocked_in = False + self.clock_out_button.setEnabled(False) + self.clock_out_button.setText("✅ 퇴근하기") + + def auto_clock_out_previous_days(self): + """이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록""" + from datetime import timedelta + + # 최근 30일간의 기록 중 퇴근하지 않은 모든 기록 처리 + today = datetime.now().date() + + for days_ago in range(1, 31): # 1일 전부터 30일 전까지 확인 + check_date = (today - timedelta(days=days_ago)).isoformat() + record = self.db.get_work_record(check_date) + + # 출근은 했지만 퇴근을 안 한 기록 발견 + if record and record.get('clock_in') and not record.get('clock_out'): + # 해당 날짜의 종료 시간 감지 + check_date_obj = today - timedelta(days=days_ago) + shutdown_time = self.event_monitor.get_shutdown_time_by_date(check_date_obj) + + if shutdown_time: + # 출근 시간 파싱 + clock_in_str = record['clock_in'] + clock_in_time = datetime.strptime( + f"{check_date} {clock_in_str}", + "%Y-%m-%d %H:%M:%S" + ) + + # 주말/공휴일 체크 + is_non_working_day = self.time_calc.is_non_working_day(clock_in_time, self.db) + day_type = self.time_calc.get_day_type(clock_in_time, self.db) + + # 외출 시간 가져오기 + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT SUM(total_minutes) + FROM break_records + WHERE date = ? + ''', (check_date,)) + break_minutes = cursor.fetchone()[0] or 0 + conn.close() + + # 총 근무시간 계산 (원본 시간) + work_duration = shutdown_time - clock_in_time + total_hours = work_duration.total_seconds() / 3600 + + # 점심시간/저녁시간 차감 여부 + lunch_enabled = bool(record.get('lunch_break', False)) + dinner_enabled = bool(record.get('dinner_break', False)) + + if is_non_working_day: + # 주말/공휴일: 모든 시간을 연장근무로 처리 (점심/저녁/외출 제외) + work_minutes = int(total_hours * 60) + if lunch_enabled: + work_minutes -= self.time_calc.lunch_duration_minutes + if dinner_enabled: + work_minutes -= self.time_calc.dinner_duration_minutes + work_minutes -= break_minutes + # 음수 방지 + work_minutes = max(0, work_minutes) + # 30분 단위로 절삭 + overtime_earned = (work_minutes // 30) * 30 + overtime_actual = work_minutes + else: + # 평일: 정상 연장근무 계산 (외출 시간 포함) + overtime_actual, overtime_earned = self.time_calc.calculate_overtime( + clock_in_time, shutdown_time, + include_lunch=lunch_enabled, + include_dinner=dinner_enabled, + break_minutes=break_minutes + ) + + # DB 업데이트 (total_hours는 원본 시간 그대로 저장) + clock_out_str = shutdown_time.strftime("%H:%M:%S") + self.db.update_clock_out( + check_date, clock_out_str, total_hours, + overtime_actual, overtime_earned + ) + + # 연장근무 적립 + if overtime_earned > 0: + self.db.add_overtime_earned( + record['id'], overtime_earned, check_date + ) + + day_tag = " (주말)" if day_type == 'weekend' else (" (공휴일)" if day_type == 'holiday' else "") + print(f"{check_date}{day_tag} 퇴근 자동 등록: {clock_out_str} (총 {total_hours:.1f}시간, 적립 {overtime_earned}분)") + else: + # 종료 시간을 찾을 수 없는 경우: 해당 날짜 23:59:59로 처리 + clock_in_str = record['clock_in'] + clock_in_time = datetime.strptime( + f"{check_date} {clock_in_str}", + "%Y-%m-%d %H:%M:%S" + ) + fallback_time = datetime.strptime( + f"{check_date} 23:59:59", + "%Y-%m-%d %H:%M:%S" + ) + + # 주말/공휴일 체크 + is_non_working_day = self.time_calc.is_non_working_day(clock_in_time, self.db) + day_type = self.time_calc.get_day_type(clock_in_time, self.db) + + # 외출 시간 가져오기 + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT SUM(total_minutes) + FROM break_records + WHERE date = ? + ''', (check_date,)) + break_minutes = cursor.fetchone()[0] or 0 + conn.close() + + # 총 근무시간 계산 + work_duration = fallback_time - clock_in_time + total_hours = work_duration.total_seconds() / 3600 + + lunch_enabled = bool(record.get('lunch_break', False)) + dinner_enabled = bool(record.get('dinner_break', False)) + + if is_non_working_day: + work_minutes = int(total_hours * 60) + if lunch_enabled: + work_minutes -= self.time_calc.lunch_duration_minutes + if dinner_enabled: + work_minutes -= self.time_calc.dinner_duration_minutes + work_minutes -= break_minutes + # 음수 방지 + work_minutes = max(0, work_minutes) + overtime_earned = (work_minutes // 30) * 30 + overtime_actual = work_minutes + else: + overtime_actual, overtime_earned = self.time_calc.calculate_overtime( + clock_in_time, fallback_time, + include_lunch=lunch_enabled, + include_dinner=dinner_enabled, + break_minutes=break_minutes + ) + + # DB 업데이트 + self.db.update_clock_out( + check_date, "23:59:59", total_hours, + overtime_actual, overtime_earned + ) + + # 연장근무 적립 + if overtime_earned > 0: + self.db.add_overtime_earned( + record['id'], overtime_earned, check_date + ) + + day_tag = " (주말)" if day_type == 'weekend' else (" (공휴일)" if day_type == 'holiday' else "") + print(f"{check_date}{day_tag} 퇴근 자동 등록 (fallback): 23:59:59 (총 {total_hours:.1f}시간, 적립 {overtime_earned}분)") + + def manual_clock_in(self): + """수동 출근 시간 입력""" + # 기본값: 기존 출근시간이 있으면 그것을, 없으면 None + default_time = self.clock_in_time if self.clock_in_time else None + + # 다이얼로그 표시 + dialog = ClockInDialog(self, default_time) + + if dialog.exec_() == dialog.Accepted: + selected_time = dialog.get_time() + + if selected_time: + # 출근 시간 설정 + self.clock_in_time = selected_time + self.is_clocked_in = True + self.midnight_rollover_handled = False # 새로운 근무일 시작 시 플래그 리셋 + + # DB 저장 + today = datetime.now().date().isoformat() + clock_in_str = selected_time.strftime("%H:%M:%S") + + # 기존 기록이 있는지 확인 + existing_record = self.db.get_today_record() + + if existing_record: + # 기존 기록 업데이트 (출근시간만) + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute(''' + UPDATE work_records + SET clock_in = ?, is_manual = 1 + WHERE date = ? + ''', (clock_in_str, today)) + conn.commit() + conn.close() + else: + # 새 기록 추가 + self.db.add_work_record(today, clock_in_str, is_manual=True) + + # UI 업데이트 + self.clock_in_value.setText(clock_in_str) + self.clock_out_button.setEnabled(True) + self.clock_out_button.setText("✅ 퇴근하기") + + QMessageBox.information( + self, + "출근 시간 설정", + f"출근 시간이 설정되었습니다.\n\n출근: {clock_in_str}" + ) + + def show_stats(self): + """통계 창 표시""" + dialog = StatsView(self, self.db) + dialog.exec_() + + def show_calendar(self): + """캘린더 창 표시""" + dialog = CalendarView(self, self.db) + dialog.exec_() + + def show_leave_management(self): + """휴가 관리 창 표시""" + dialog = LeaveView(self, self.db) + dialog.exec_() + + def apply_theme(self, theme_name: str): + """테마 적용""" + self.current_theme = theme_name + self.setStyleSheet(get_theme(theme_name)) + apply_dark_titlebar(self, theme_name == 'dark') + # 타이틀바 갱신을 위해 크기 미세 조정 + size = self.size() + self.resize(size.width() + 1, size.height()) + self.resize(size) + + def show_settings(self): + """설정 창 표시""" + dialog = SettingsView(self, self.db) + dialog.exec_() + # 설정 변경 후 테마 재적용 + new_theme = str(self.db.get_setting(THEME, 'light')) + if new_theme != self.current_theme: + self.apply_theme(new_theme) + + def show_help(self): + """사용 설명 가이드 창 표시""" + from ui.help_view import HelpView + dialog = HelpView(self) + dialog.exec_() + + def check_for_updates(self, silent: bool = False): + """업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용).""" + from core.version import __version__ + from utils.updater_client import check_for_update, download_update, apply_update + + info = check_for_update(__version__) + if info is None: + if not silent: + QMessageBox.information( + self, + "업데이트 확인", + f"현재 최신 버전입니다 (v{__version__})." + ) + return + + # 빌드된 환경이 아니면 (개발 .py) 실제 적용 불가 — 알림만 + if not getattr(sys, 'frozen', False): + QMessageBox.information( + self, + "새 버전 발견", + f"새 버전 {info.version}이 있습니다.\n" + "(개발 환경에서는 자동 적용 불가 — git pull 또는 빌드 후 사용)" + ) + return + + reply = QMessageBox.question( + self, + "새 버전 발견", + f"현재: v{__version__}\n새 버전: {info.version}\n\n" + f"릴리스 노트:\n{info.notes[:500]}\n\n지금 다운로드 후 업데이트할까요?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + # 다운로드 (모달 진행 다이얼로그) + from PyQt5.QtWidgets import QProgressDialog + progress = QProgressDialog("다운로드 중...", "취소", 0, 100, self) + progress.setWindowTitle("업데이트 다운로드") + progress.setWindowModality(Qt.WindowModal) + progress.setMinimumDuration(0) + + def cb(downloaded, total): + if total > 0: + progress.setValue(int(downloaded * 100 / total)) + QApplication.processEvents() + + new_exe = download_update(info.asset_url, progress_cb=cb) + progress.close() + + if new_exe is None: + QMessageBox.critical(self, "다운로드 실패", "새 버전 다운로드 중 오류가 발생했습니다.") + return + + if not apply_update(new_exe): + QMessageBox.critical( + self, "업데이트 실패", + "updater.exe를 찾을 수 없거나 실행에 실패했습니다." + ) + return + + # updater.exe가 메인 종료를 기다리고 있음 → 즉시 종료 + QMessageBox.information(self, "재시작", "업데이트 적용을 위해 프로그램이 종료됩니다.") + QApplication.quit() + + def show_mini_widget(self): + """미니 위젯 표시 (Always-on-top)""" + if not hasattr(self, '_mini_widget') or self._mini_widget is None: + from ui.mini_widget import MiniWidget + self._mini_widget = MiniWidget(self) + self._mini_widget.show() + self._mini_widget.raise_() + + def show_break_management(self): + """외출 관리 창 표시""" + dialog = BreakView(self, self.db) + dialog.exec_() + + def break_out(self, silent: bool = False): + """외출 처리. silent=True면 다이얼로그 없이 (잠금 자동 외출용).""" + if not self.is_clocked_in: + if not silent: + QMessageBox.warning(self, "외출 불가", "출근하지 않은 상태입니다.") + return + + if self.is_on_break: + if not silent: + QMessageBox.warning(self, "외출 불가", "이미 외출 중입니다.") + return + + now = datetime.now() + today = now.date().isoformat() + break_out_str = now.strftime("%H:%M:%S") + + today_record = self.db.get_today_record() + if not today_record: + if not silent: + QMessageBox.warning(self, "외출 불가", "출근 기록을 찾을 수 없습니다.") + return + + work_record_id = today_record['id'] + reason = "화면 잠금" if silent else None + self.db.add_break_record(work_record_id, today, break_out_str, reason) + + self.is_on_break = True + self.break_out_button.setEnabled(False) + self.break_in_button.setEnabled(True) + + self.update_break_status() + + if not silent: + QMessageBox.information(self, "외출", f"외출 시간: {break_out_str}") + + def break_in(self, silent: bool = False): + """복귀 처리. silent=True면 다이얼로그 없이.""" + if not self.is_on_break: + return + + now = datetime.now() + + active_break = self.db.get_active_break_record() + + if not active_break: + if not silent: + QMessageBox.warning(self, "복귀 불가", "진행 중인 외출 기록을 찾을 수 없습니다.") + return + + if not active_break.get('break_out'): + if not silent: + QMessageBox.warning(self, "복귀 불가", "외출 시간 기록이 손상되었습니다.") + return + + # 복귀 시간 업데이트 + break_in_str = now.strftime("%H:%M:%S") + self.db.update_break_return(active_break['id'], break_in_str) + + self.is_on_break = False + self.break_out_button.setEnabled(True) + self.break_in_button.setEnabled(False) + + self.update_break_status() + + # 외출 시간 계산 (자정 경계 처리) + # break_record에 저장된 날짜를 사용하여 자정 경계 문제 해결 + break_date = active_break['date'] + break_date_obj = datetime.strptime(break_date, "%Y-%m-%d").date() + + break_out_time = datetime.combine( + break_date_obj, + datetime.strptime(active_break['break_out'], "%H:%M:%S").time() + ) + break_in_time = datetime.combine( + break_date_obj, + datetime.strptime(break_in_str, "%H:%M:%S").time() + ) + + # 복귀 시간이 외출 시간보다 이전이면 자정을 넘긴 것으로 판단 + if break_in_time < break_out_time: + from datetime import timedelta + break_in_time += timedelta(days=1) # 복귀는 다음 날로 처리 + + duration_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + + if not silent: + QMessageBox.information( + self, + "복귀", + f"복귀 시간: {break_in_str}\n외출 시간: {duration_minutes}분" + ) + + def update_break_status(self): + """외출 상태 업데이트""" + active_break = self.db.get_active_break_record() + + if active_break: + break_out = active_break['break_out'] + self.break_status_label.setText(f"외출 중 ({break_out}부터)") + self.break_status_label.setStyleSheet(f"color: {ThemeColors.get('status_break_active')}; font-weight: bold;") + self.is_on_break = True + self.break_out_button.setEnabled(False) + self.break_in_button.setEnabled(True) + else: + total_minutes = self.db.get_total_break_minutes_today() + if total_minutes > 0: + hours = total_minutes // 60 + minutes = total_minutes % 60 + if hours > 0: + self.break_status_label.setText(f"오늘 총 외출: {hours}시간 {minutes}분") + else: + self.break_status_label.setText(f"오늘 총 외출: {minutes}분") + self.break_status_label.setStyleSheet(f"color: {ThemeColors.get('status_break_idle')};") + else: + self.break_status_label.setText("") + + self.is_on_break = False + self.break_out_button.setEnabled(True) + self.break_in_button.setEnabled(False) + + def _build_time_calc(self, settings: dict): + """settings dict로부터 TimeCalculator 생성. + + Database.get_settings()가 이미 숫자 문자열을 int로 자동 변환하므로 + 추가 캐스팅은 불필요. work_minutes 우선, 없으면 work_hours*60 폴백. + """ + work_minutes = settings.get(WORK_MINUTES) + if work_minutes is None: + try: + work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60)) + except (ValueError, TypeError): + work_minutes = 480 + return TimeCalculator( + work_minutes=int(work_minutes), + lunch_duration_minutes=int(settings.get(LUNCH_DURATION_MINUTES, 60)), + dinner_duration_minutes=int(settings.get(DINNER_DURATION_MINUTES, 60)), + ) + + def reload_settings(self): + """설정 다시 불러오기 (설정 변경 후 호출)""" + settings = self.db.get_settings() + self.time_calc = self._build_time_calc(settings) + + # 시간 형식 캐시 갱신 + self.cached_time_format = str(settings.get(TIME_FORMAT, '24')) + + # auto_lunch 캐시 무효화 (설정에서 토글 가능하므로) + self._auto_lunch.invalidate() + + # UI 업데이트 + self.update_overtime_balance() + self.update_leave_balance() + + # 시간 표시 형식이 변경되었을 경우 디스플레이 즉시 업데이트 + if self.is_clocked_in and self.clock_in_time: + self.update_display() + + def generate_daily_report(self): + """오늘 하루 근무 내역 보고서 생성 및 클립보드 복사""" + from datetime import date + + today = date.today().isoformat() + + # 오늘의 근무 기록 조회 + work_record = self.db.get_today_record() + + if not work_record: + QMessageBox.warning( + self, + "기록 없음", + "오늘 출근 기록이 없습니다." + ) + return + + # 보고서 작성 + report_lines = [] + report_lines.append("=" * 40) + report_lines.append(f"📋 일일 근무 보고서 - {today}") + report_lines.append("=" * 40) + report_lines.append("") + + # 출근/퇴근 시간 + clock_in_dt = datetime.fromisoformat(f"{today} {work_record['clock_in']}") + report_lines.append(f"🕐 출근 시간: {self.format_time(clock_in_dt, include_seconds=True)}") + + if work_record['clock_out']: + clock_out_dt = datetime.fromisoformat(f"{today} {work_record['clock_out']}") + report_lines.append(f"🕐 퇴근 시간: {self.format_time(clock_out_dt, include_seconds=True)}") + + # 총 근무 시간 + total_work_hours = work_record.get('total_hours') or work_record.get('work_hours', 0) + if total_work_hours: + hours = int(total_work_hours) + minutes = int((total_work_hours - hours) * 60) + report_lines.append(f"⏱️ 총 근무: {hours}시간 {minutes}분") + else: + report_lines.append(f"🕐 퇴근 시간: 미퇴근") + + report_lines.append("") + + # 외출 시간 + break_minutes = self.db.get_total_break_minutes_today() + if break_minutes > 0: + break_hours = break_minutes // 60 + break_mins = break_minutes % 60 + report_lines.append(f"🚶 외출 시간: {break_hours}시간 {break_mins}분") + + # 외출 상세 내역 + break_records = self.db.get_today_break_records() + for br in break_records: + break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}") + if br['break_in']: + break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}") + # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) + duration = int((break_in_time - break_out_time).total_seconds() / 60) + reason = f" ({br['reason']})" if br['reason'] else "" + report_lines.append(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}분){reason}") + else: + reason = f" ({br['reason']})" if br['reason'] else "" + report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{reason}") + report_lines.append("") + + # 점심시간 + lunch_break = work_record.get('lunch_break', False) + if lunch_break: + report_lines.append(f"🍱 점심시간: 포함 (1시간)") + report_lines.append("") + + # 추가 근무 적립 + if work_record['overtime_minutes'] and work_record['overtime_minutes'] > 0: + ot_hours = work_record['overtime_minutes'] // 60 + ot_mins = work_record['overtime_minutes'] % 60 + + # 적립된 추가근무 (30분 단위 절삭) + overtime_earned = work_record.get('overtime_earned', 0) + earned_hours = overtime_earned // 60 + earned_mins = overtime_earned % 60 + + report_lines.append(f"⏰ 추가 근무 발생: {ot_hours}시간 {ot_mins}분") + report_lines.append(f" 💰 적립: {earned_hours}시간 {earned_mins}분 (30분 단위 절삭)") + report_lines.append("") + + # 오늘 사용한 추가근무 + overtime_used_today = self.db.get_today_overtime_usage() + if overtime_used_today > 0: + used_hours = overtime_used_today // 60 + used_mins = overtime_used_today % 60 + report_lines.append(f"🕐 추가 근무 사용: {used_hours}시간 {used_mins}분") + + # 사용 상세 내역 + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute(''' + SELECT used_minutes, reason, created_at + FROM overtime_usage + WHERE date = ? + ORDER BY created_at ASC + ''', (today,)) + usage_records = cursor.fetchall() + conn.close() + + for record in usage_records: + used_min = record[0] + reason = record[1] + used_h = used_min // 60 + used_m = used_min % 60 + reason_text = f" - {reason}" if reason else "" + report_lines.append(f" - {used_h}시간 {used_m}분{reason_text}") + report_lines.append("") + + # 오늘 사용한 연차 (일괄 추가 및 수동 조정 제외) + leave_records = self.db.get_leave_records(start_date=today, end_date=today, exclude_bulk=False) + + # manual 타입이거나 메모에 "일괄 추가"가 포함된 것은 제외 + filtered_leave_records = [ + r for r in leave_records + if r.get('leave_type') != 'manual' + and not (r.get('memo') and '일괄 추가' in r['memo']) + ] + + if filtered_leave_records: + total_leave_days = sum(r['days'] for r in filtered_leave_records) + + if total_leave_days >= 1: + days = int(total_leave_days) + hours = int((total_leave_days - days) * 8) + if hours > 0: + report_lines.append(f"🌴 연차 사용: {days}일 {hours}시간") + else: + report_lines.append(f"🌴 연차 사용: {days}일") + else: + hours = int(total_leave_days * 8) + report_lines.append(f"🌴 연차 사용: {hours}시간") + + for lr in filtered_leave_records: + # leave_type을 한글 이름으로 변환 + leave_type_name = { + 'annual': '연차', + 'sick': '병가', + 'half_am': '오전 반차', + 'half_pm': '오후 반차', + 'time_off': '시간 연차', + '연차': '연차', + '반차': '반차', + '반반차': '반반차' + }.get(lr.get('leave_type', ''), lr.get('leave_type', '연차')) + + days_used = lr['days'] + + # 일수를 시간으로 표시 + if days_used >= 1: + d = int(days_used) + h = int((days_used - d) * 8) + if h > 0: + time_str = f"{d}일 {h}시간" + else: + time_str = f"{d}일" + else: + h = int(days_used * 8) + time_str = f"{h}시간" + + memo = f" - {lr['memo']}" if lr.get('memo') else "" + report_lines.append(f" - {leave_type_name}: {time_str}{memo}") + report_lines.append("") + + # 메모 + if work_record['memo']: + report_lines.append(f"📝 메모: {work_record['memo']}") + report_lines.append("") + + report_lines.append("=" * 40) + + # 클립보드에 복사 + report_text = "\n".join(report_lines) + clipboard = QApplication.clipboard() + clipboard.setText(report_text) + + # 미리보기 메시지 박스 + QMessageBox.information( + self, + "보고서 복사 완료", + "일일 근무 보고서가 클립보드에 복사되었습니다.\n\n" + report_text + ) + + def show_notification(self, title: str, message: str): + """알림 표시""" + # 시스템 트레이 알림 + if self.tray_icon.supportsMessages(): + self.tray_icon.showMessage( + title, + message, + QSystemTrayIcon.Information, + 5000 # 5초간 표시 + ) + else: + # 대체: 메시지 박스 + QMessageBox.information(self, title, message) + + def closeEvent(self, event): + """창 닫기 이벤트""" + # 최소화로 변경 (트레이로) + event.ignore() + self.hide() + if self.tray_icon.supportsMessages(): + self.tray_icon.showMessage( + "Clock-out Time Calculator", + "프로그램이 트레이에서 실행 중입니다.", + QSystemTrayIcon.Information, + 2000 + ) diff --git a/ui/mini_widget.py b/ui/mini_widget.py new file mode 100644 index 0000000..189a0c8 --- /dev/null +++ b/ui/mini_widget.py @@ -0,0 +1,101 @@ +""" +미니 위젯 — Always-on-top 컴팩트 디스플레이. + +남은 시간만 큰 글씨로 보여주는 작은 창. 메인 창과 동일한 1Hz 갱신을 +부모 윈도우의 시그널/직접 호출로 받음. +""" +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication +from PyQt5.QtCore import Qt, QPoint +from PyQt5.QtGui import QFont, QMouseEvent + +from core.i18n import tr +from ui.styles import apply_dark_titlebar + + +class MiniWidget(QWidget): + """Always-on-top 미니 위젯. + + 제공 기능: + - 남은 시간 큰 글씨 + - 마우스 드래그로 이동 + - 우클릭 메뉴: 메인 창 열기 / 닫기 + """ + + def __init__(self, parent_window=None): + super().__init__() + self.parent_window = parent_window + self._drag_pos: QPoint = None + + self.setWindowTitle(tr('window.mini_widget')) + self.setWindowFlags( + Qt.WindowStaysOnTopHint + | Qt.FramelessWindowHint + | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground, False) + self.setFixedSize(220, 80) + + layout = QVBoxLayout() + layout.setContentsMargins(12, 8, 12, 8) + layout.setSpacing(2) + + self.title_label = QLabel(tr('label.remaining')) + self.title_label.setAlignment(Qt.AlignCenter) + self.title_label.setStyleSheet("color: #888; font-size: 11px;") + + self.time_label = QLabel("--:--:--") + self.time_label.setAlignment(Qt.AlignCenter) + self.time_label.setFont(QFont("Consolas", 22, QFont.Bold)) + + layout.addWidget(self.title_label) + layout.addWidget(self.time_label) + self.setLayout(layout) + + # 기본 스타일 (테마 무관 가독성 유지) + self.setStyleSheet(""" + QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; } + QLabel { color: #fff; } + """) + + apply_dark_titlebar(self) + + def update_remaining(self, remaining_str: str): + """메인 윈도우에서 호출 — 남은 시간 동기화.""" + self.time_label.setText(remaining_str) + if remaining_str.startswith('+'): + self.title_label.setText(tr('label.overtime_progress')) + self.time_label.setStyleSheet("color: #ff6b6b;") + else: + self.title_label.setText(tr('label.remaining')) + self.time_label.setStyleSheet("color: #fff;") + + # 드래그 이동 + def mousePressEvent(self, event: QMouseEvent): + if event.button() == Qt.LeftButton: + self._drag_pos = event.globalPos() - self.frameGeometry().topLeft() + event.accept() + + def mouseMoveEvent(self, event: QMouseEvent): + if self._drag_pos and event.buttons() & Qt.LeftButton: + self.move(event.globalPos() - self._drag_pos) + event.accept() + + def mouseDoubleClickEvent(self, event: QMouseEvent): + """더블클릭 시 메인 창 열기.""" + if self.parent_window: + self.parent_window.show() + self.parent_window.raise_() + self.parent_window.activateWindow() + + def contextMenuEvent(self, event): + from PyQt5.QtWidgets import QMenu + menu = QMenu(self) + open_main = menu.addAction("메인 창 열기") + close_mini = menu.addAction("미니 위젯 닫기") + action = menu.exec_(event.globalPos()) + if action == open_main and self.parent_window: + self.parent_window.show() + self.parent_window.raise_() + self.parent_window.activateWindow() + elif action == close_mini: + self.close() diff --git a/ui/overtime_view.py b/ui/overtime_view.py new file mode 100644 index 0000000..51b8b86 --- /dev/null +++ b/ui/overtime_view.py @@ -0,0 +1,507 @@ +""" +연장근무 상세 내역 뷰 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTableWidget, QTableWidgetItem, + QHeaderView, QGroupBox, QMessageBox, QMenu, QAction, + QDateEdit, QSpinBox, QLineEdit, QComboBox) +from PyQt5.QtCore import Qt, QDate +from PyQt5.QtGui import QColor +import sys +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 get_theme, ThemeColors, apply_dark_titlebar + + +class OvertimeView(QDialog): + """연장근무 상세 내역 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db if db else Database() + self.init_ui() + self.load_data() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle(tr('window.overtime_view')) + self.setModal(True) + self.setMinimumSize(800, 500) + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + 잔액 한 줄 + header_layout = QHBoxLayout() + title = QLabel("연장근무 내역") + title.setObjectName("dialog_title") + header_layout.addWidget(title) + header_layout.addStretch() + self.balance_label = QLabel("잔액: 0분") + self.balance_label.setObjectName("badge_balance") + header_layout.addWidget(self.balance_label) + layout.addLayout(header_layout) + + # 적립 내역 + earned_group = QGroupBox("💰 적립 내역") + earned_layout = QVBoxLayout() + earned_layout.setSpacing(4) + earned_layout.setContentsMargins(8, 20, 8, 6) + + self.earned_table = QTableWidget() + self.earned_table.setColumnCount(3) + self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"]) + self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.earned_table.setAlternatingRowColors(True) + self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) + earned_layout.addWidget(self.earned_table) + + add_earned_button = QPushButton("➕ 수동 적립") + add_earned_button.clicked.connect(self.add_earned_record) + earned_layout.addWidget(add_earned_button) + + earned_group.setLayout(earned_layout) + layout.addWidget(earned_group) + + # 사용 내역 + used_group = QGroupBox("📤 사용 내역") + used_layout = QVBoxLayout() + used_layout.setSpacing(4) + used_layout.setContentsMargins(8, 20, 8, 6) + + self.used_table = QTableWidget() + self.used_table.setColumnCount(3) + self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"]) + self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.used_table.setAlternatingRowColors(True) + self.used_table.setEditTriggers(QTableWidget.NoEditTriggers) + self.used_table.setSelectionBehavior(QTableWidget.SelectRows) + self.used_table.setContextMenuPolicy(Qt.CustomContextMenu) + self.used_table.customContextMenuRequested.connect(self.show_used_context_menu) + used_layout.addWidget(self.used_table) + + add_used_button = QPushButton("➕ 수동 사용") + add_used_button.clicked.connect(self.add_used_record) + used_layout.addWidget(add_used_button) + + used_group.setLayout(used_layout) + layout.addWidget(used_group) + + # 닫기 버튼 + close_button = QPushButton("닫기") + close_button.clicked.connect(self.close) + layout.addWidget(close_button) + + self.setLayout(layout) + + def load_data(self): + """데이터 로드""" + # 잔액 업데이트 + balance = self.db.get_total_overtime_balance() + hours = balance // 60 + minutes = balance % 60 + self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)") + + # 적립 내역 로드 + conn = self.db.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT ob.date, ob.earned_minutes, + CASE + WHEN ob.work_record_id IS NULL THEN '수동 추가' + ELSE COALESCE(wr.memo, '') + END as memo + FROM overtime_bank ob + LEFT JOIN work_records wr ON ob.work_record_id = wr.id + ORDER BY ob.date DESC + ''') + earned_records = cursor.fetchall() + + self.earned_table.setRowCount(len(earned_records)) + for i, record in enumerate(earned_records): + date_item = QTableWidgetItem(record[0]) + date_item.setTextAlignment(Qt.AlignCenter) + + minutes = record[1] + hours = minutes // 60 + mins = minutes % 60 + time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분" + time_item = QTableWidgetItem(time_str) + time_item.setTextAlignment(Qt.AlignCenter) + time_item.setForeground(QColor(39, 174, 96)) # 초록색 + + memo_item = QTableWidgetItem(record[2] or "") + + self.earned_table.setItem(i, 0, date_item) + self.earned_table.setItem(i, 1, time_item) + self.earned_table.setItem(i, 2, memo_item) + + # 사용 내역 로드 (잔액 조정 제외) + cursor.execute(''' + SELECT id, date, used_minutes, reason + FROM overtime_usage + WHERE COALESCE(reason, '') != '잔액 조정' + ORDER BY date DESC + ''') + used_records = cursor.fetchall() + + self.used_table.setRowCount(len(used_records)) + for i, record in enumerate(used_records): + # ID를 숨겨진 데이터로 저장 + date_item = QTableWidgetItem(record[1]) + date_item.setTextAlignment(Qt.AlignCenter) + date_item.setData(Qt.UserRole, record[0]) # ID 저장 + + minutes = record[2] + hours = minutes // 60 + mins = minutes % 60 + time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분" + time_item = QTableWidgetItem(time_str) + time_item.setTextAlignment(Qt.AlignCenter) + time_item.setForeground(QColor(231, 76, 60)) # 빨간색 + + reason_item = QTableWidgetItem(record[3] or "") + + self.used_table.setItem(i, 0, date_item) + self.used_table.setItem(i, 1, time_item) + self.used_table.setItem(i, 2, reason_item) + + conn.close() + + def show_used_context_menu(self, position): + """사용 내역 우클릭 메뉴""" + # 선택된 행이 있는지 확인 + selected_rows = self.used_table.selectionModel().selectedRows() + if not selected_rows: + return + + # 컨텍스트 메뉴 생성 + menu = QMenu(self) + delete_action = QAction("❌ 삭제", self) + delete_action.triggered.connect(self.delete_used_record) + menu.addAction(delete_action) + + # 메뉴 표시 + menu.exec_(self.used_table.viewport().mapToGlobal(position)) + + def delete_used_record(self): + """사용 기록 삭제""" + # 선택된 행 가져오기 + selected_rows = self.used_table.selectionModel().selectedRows() + if not selected_rows: + return + + row = selected_rows[0].row() + date_item = self.used_table.item(row, 0) + time_item = self.used_table.item(row, 1) + reason_item = self.used_table.item(row, 2) + + # ID 가져오기 + usage_id = date_item.data(Qt.UserRole) + + # 확인 메시지 + reply = QMessageBox.question( + self, + "삭제 확인", + f"다음 사용 기록을 삭제하시겠습니까?\n\n" + f"날짜: {date_item.text()}\n" + f"시간: {time_item.text()}\n" + f"사유: {reason_item.text()}", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # 데이터베이스에서 삭제 + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute('DELETE FROM overtime_usage WHERE id = ?', (usage_id,)) + conn.commit() + conn.close() + + # 화면 새로고침 + 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) + if dialog.exec_() == QDialog.Accepted: + self.load_data() + # 부모 윈도우 업데이트 + if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): + self.parent().update_overtime_balance() + + def add_used_record(self): + """수동 사용 추가""" + dialog = AddOvertimeUsedDialog(self, self.db) + if dialog.exec_() == QDialog.Accepted: + self.load_data() + # 부모 윈도우 업데이트 + if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): + self.parent().update_overtime_balance() + + +class AddOvertimeEarnedDialog(QDialog): + """추가근무 수동 적립 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db + self.init_ui() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle("추가근무 수동 적립") + self.setModal(True) + self.setMinimumWidth(360) + + layout = QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + title = QLabel("추가근무 수동 적립") + title.setObjectName("dialog_subtitle") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # 날짜 + date_layout = QHBoxLayout() + date_label = QLabel("날짜:") + date_label.setObjectName("field_label") + date_label.setFixedWidth(60) + self.date_edit = QDateEdit() + self.date_edit.setDate(QDate.currentDate()) + self.date_edit.setCalendarPopup(True) + date_layout.addWidget(date_label) + date_layout.addWidget(self.date_edit) + layout.addLayout(date_layout) + + # 시간 (30분 단위) + time_layout = QHBoxLayout() + time_label = QLabel("시간:") + time_label.setObjectName("field_label") + time_label.setFixedWidth(60) + self.hour_spin = QSpinBox() + self.hour_spin.setRange(0, 23) + self.hour_spin.setSuffix("시간") + self.minute_combo = QComboBox() + self.minute_combo.addItems(["0분", "30분"]) + time_layout.addWidget(time_label) + time_layout.addWidget(self.hour_spin) + time_layout.addWidget(self.minute_combo) + layout.addLayout(time_layout) + + # 메모 + memo_layout = QHBoxLayout() + memo_label = QLabel("메모:") + memo_label.setObjectName("field_label") + memo_label.setFixedWidth(60) + self.memo_edit = QLineEdit() + self.memo_edit.setPlaceholderText("선택사항") + memo_layout.addWidget(memo_label) + memo_layout.addWidget(self.memo_edit) + layout.addLayout(memo_layout) + + # 버튼 + button_layout = QHBoxLayout() + save_button = QPushButton("저장") + save_button.setObjectName("btn_primary") + save_button.clicked.connect(self.save) + cancel_button = QPushButton("취소") + cancel_button.clicked.connect(self.reject) + button_layout.addWidget(save_button) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def save(self): + """저장""" + # 시간 계산 (30분 단위) + hours = self.hour_spin.value() + minutes = 0 if self.minute_combo.currentText() == "0분" else 30 + total_minutes = hours * 60 + minutes + + if total_minutes == 0: + QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.") + return + + date = self.date_edit.date().toString("yyyy-MM-dd") + memo = self.memo_edit.text().strip() + + # DB에 저장 (work_record_id는 NULL) + self.db.add_overtime_earned(None, total_minutes, date) + + # 메모가 있으면 work_records 업데이트 (기존 메모 보존) + if memo: + record = self.db.get_work_record(date) + if record: + existing_memo = record.get('memo', '') or '' + # 기존 메모가 있으면 줄바꿈 후 추가 + if existing_memo: + new_memo = f"{existing_memo}\n[수동 적립] {memo}" + else: + new_memo = f"[수동 적립] {memo}" + + conn = self.db.get_connection() + cursor = conn.cursor() + cursor.execute(''' + UPDATE work_records + SET memo = ? + WHERE date = ? + ''', (new_memo, date)) + conn.commit() + conn.close() + + QMessageBox.information( + self, + "저장 완료", + f"{hours}시간 {minutes}분이 적립되었습니다." + ) + self.accept() + + +class AddOvertimeUsedDialog(QDialog): + """추가근무 수동 사용 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db + self.init_ui() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle("추가근무 수동 사용") + self.setModal(True) + self.setMinimumWidth(360) + + layout = QVBoxLayout() + layout.setSpacing(8) + layout.setContentsMargins(12, 10, 12, 10) + + # 제목 + 잔액 한 줄 + header_layout = QHBoxLayout() + title = QLabel("추가근무 수동 사용") + title.setObjectName("dialog_subtitle") + header_layout.addWidget(title) + header_layout.addStretch() + balance = self.db.get_total_overtime_balance() + hours = balance // 60 + minutes = balance % 60 + balance_label = QLabel(f"잔액: {hours}시간 {minutes}분") + balance_label.setObjectName("badge_balance") + header_layout.addWidget(balance_label) + layout.addLayout(header_layout) + + # 날짜 + date_layout = QHBoxLayout() + date_label = QLabel("날짜:") + date_label.setObjectName("field_label") + date_label.setFixedWidth(60) + self.date_edit = QDateEdit() + self.date_edit.setDate(QDate.currentDate()) + self.date_edit.setCalendarPopup(True) + date_layout.addWidget(date_label) + date_layout.addWidget(self.date_edit) + layout.addLayout(date_layout) + + # 시간 (30분 단위) + time_layout = QHBoxLayout() + time_label = QLabel("시간:") + time_label.setObjectName("field_label") + time_label.setFixedWidth(60) + self.hour_spin = QSpinBox() + self.hour_spin.setRange(0, 23) + self.hour_spin.setSuffix("시간") + self.minute_combo = QComboBox() + self.minute_combo.addItems(["0분", "30분"]) + time_layout.addWidget(time_label) + time_layout.addWidget(self.hour_spin) + time_layout.addWidget(self.minute_combo) + layout.addLayout(time_layout) + + # 사유 + reason_layout = QHBoxLayout() + reason_label = QLabel("사유:") + reason_label.setObjectName("field_label") + reason_label.setFixedWidth(60) + self.reason_edit = QLineEdit() + self.reason_edit.setPlaceholderText("예: 개인 사유") + reason_layout.addWidget(reason_label) + reason_layout.addWidget(self.reason_edit) + layout.addLayout(reason_layout) + + # 버튼 + button_layout = QHBoxLayout() + save_button = QPushButton("저장") + save_button.setObjectName("btn_primary") + save_button.clicked.connect(self.save) + cancel_button = QPushButton("취소") + cancel_button.clicked.connect(self.reject) + button_layout.addWidget(save_button) + button_layout.addWidget(cancel_button) + layout.addLayout(button_layout) + + self.setLayout(layout) + + def save(self): + """저장""" + # 시간 계산 (30분 단위) + hours = self.hour_spin.value() + minutes = 0 if self.minute_combo.currentText() == "0분" else 30 + total_minutes = hours * 60 + minutes + + if total_minutes == 0: + QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.") + return + + # 잔액 확인 + balance = self.db.get_total_overtime_balance() + if total_minutes > balance: + QMessageBox.warning( + self, + "잔액 부족", + f"사용 가능한 시간이 부족합니다.\n\n" + f"요청: {hours}시간 {minutes}분\n" + f"잔액: {balance // 60}시간 {balance % 60}분" + ) + return + + date = self.date_edit.date().toString("yyyy-MM-dd") + reason = self.reason_edit.text().strip() or "수동 사용" + + # DB에 저장 + self.db.add_overtime_usage(None, total_minutes, date, reason) + + QMessageBox.information( + self, + "저장 완료", + f"{hours}시간 {minutes}분이 사용 처리되었습니다." + ) + self.accept() + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = OvertimeView() + dialog.exec_() diff --git a/ui/settings_view.py b/ui/settings_view.py new file mode 100644 index 0000000..099d709 --- /dev/null +++ b/ui/settings_view.py @@ -0,0 +1,1149 @@ +""" +설정 뷰 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QSpinBox, QCheckBox, QGroupBox, + QComboBox, QTimeEdit, QMessageBox, QFileDialog, + QScrollArea, QWidget, QLineEdit) +from PyQt5.QtCore import Qt, QTime +from PyQt5.QtWidgets import QApplication +from datetime import datetime +import sys +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 core.settings_keys import ( + WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES, + AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME, + NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH, + THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS, + INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS, + DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK, +) +from utils.csv_exporter import CSVExporter +from ui.leave_view import AddLeaveDialog +from ui.styles import apply_dark_titlebar + + +class SettingsView(QDialog): + """설정 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db if db else Database() + self.parent_window = parent + self.init_ui() + self.load_settings() + self._settings_loaded = True + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle(tr('window.settings')) + self.setModal(True) + self.setMinimumSize(600, 700) + + # 메인 레이아웃 + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # 제목 + title = QLabel("설정") + title.setObjectName("dialog_title") + title.setAlignment(Qt.AlignCenter) + main_layout.addWidget(title) + + # 스크롤 영역 + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + # 스크롤 내용 + scroll_content = QWidget() + scroll_content.setObjectName("scroll_content") + layout = QVBoxLayout() + layout.setSpacing(16) + layout.setContentsMargins(20, 10, 20, 10) + + # 근무 시간 설정 + work_time_group = self.create_work_time_group() + layout.addWidget(work_time_group) + + # 알림 설정 + notification_group = self.create_notification_group() + layout.addWidget(notification_group) + + # 연장근무 설정 + overtime_group = self.create_overtime_group() + layout.addWidget(overtime_group) + self.update_overtime_balance_display() + + # 휴가 설정 + leave_group = self.create_leave_group() + layout.addWidget(leave_group) + + # 공휴일 설정 + holiday_group = self.create_holiday_group() + layout.addWidget(holiday_group) + + # 데이터 관리 + data_group = self.create_data_group() + layout.addWidget(data_group) + + layout.addStretch() + + scroll_content.setLayout(layout) + scroll_area.setWidget(scroll_content) + main_layout.addWidget(scroll_area) + + # 버튼들 (스크롤 영역 밖에) + button_layout = QHBoxLayout() + button_layout.setContentsMargins(20, 10, 20, 20) + + save_button = QPushButton(tr('btn.save')) + save_button.setObjectName("btn_primary") + save_button.setMinimumHeight(40) + save_button.clicked.connect(self.save_settings) + button_layout.addWidget(save_button) + + close_button = QPushButton(tr('btn.close')) + close_button.setMinimumHeight(40) + close_button.clicked.connect(self.close) + button_layout.addWidget(close_button) + + main_layout.addLayout(button_layout) + + self.setLayout(main_layout) + + def create_work_time_group(self) -> QGroupBox: + """근무 시간 설정 그룹""" + group = QGroupBox(tr('group.work_time')) + layout = QVBoxLayout() + layout.setSpacing(8) + + # 근무 패턴 프리셋 + preset_layout = QHBoxLayout() + preset_label = QLabel("근무 패턴:") + preset_label.setFixedWidth(130) + self.work_preset_combo = QComboBox() + # (label, work_minutes, lunch_minutes) + self.work_preset_combo.addItem("표준 8시간 (점심 60분)", (480, 60)) + self.work_preset_combo.addItem("단축근무 7시간 30분 (점심 30분)", (450, 30)) + self.work_preset_combo.addItem("단축근무 7시간 (점심 60분)", (420, 60)) + self.work_preset_combo.addItem("단축근무 6시간 (점심 30분)", (360, 30)) + self.work_preset_combo.addItem("반일 4시간 (점심 0분)", (240, 0)) + self.work_preset_combo.addItem("사용자 정의", None) + self.work_preset_combo.setFixedWidth(260) + self.work_preset_combo.currentIndexChanged.connect(self.on_preset_changed) + preset_layout.addWidget(preset_label) + preset_layout.addWidget(self.work_preset_combo) + preset_layout.addStretch() + layout.addLayout(preset_layout) + + # 하루 기본 근무 시간 (시 + 분 분리 입력) + work_hours_layout = QHBoxLayout() + work_hours_label = QLabel("하루 기본 근무:") + work_hours_label.setFixedWidth(130) + self.work_hours_spin = QSpinBox() + self.work_hours_spin.setRange(0, 12) + self.work_hours_spin.setValue(8) + self.work_hours_spin.setSuffix(" 시간") + self.work_hours_spin.setFixedWidth(100) + self.work_minutes_spin = QSpinBox() + self.work_minutes_spin.setRange(0, 59) + self.work_minutes_spin.setValue(0) + self.work_minutes_spin.setSingleStep(15) + self.work_minutes_spin.setSuffix(" 분") + self.work_minutes_spin.setFixedWidth(100) + # 사용자가 시간/분 직접 변경 시 프리셋을 "사용자 정의"로 + self.work_hours_spin.valueChanged.connect(self._on_work_time_user_edit) + self.work_minutes_spin.valueChanged.connect(self._on_work_time_user_edit) + work_hours_layout.addWidget(work_hours_label) + work_hours_layout.addWidget(self.work_hours_spin) + work_hours_layout.addWidget(self.work_minutes_spin) + work_hours_layout.addStretch() + layout.addLayout(work_hours_layout) + + # 점심시간 기본값 + lunch_layout = QHBoxLayout() + lunch_label = QLabel("점심시간 기본:") + lunch_label.setFixedWidth(130) + self.lunch_spin = QSpinBox() + self.lunch_spin.setRange(0, 120) + self.lunch_spin.setValue(60) + self.lunch_spin.setSingleStep(5) + self.lunch_spin.setSuffix(" 분") + self.lunch_spin.setFixedWidth(110) + self.lunch_spin.valueChanged.connect(self._on_work_time_user_edit) + lunch_layout.addWidget(lunch_label) + lunch_layout.addWidget(self.lunch_spin) + + # 점심시간 자동 적용 + self.auto_lunch_check = QCheckBox("자동 적용") + self.auto_lunch_check.setToolTip("출근 후 4시간 경과 시 자동 적용") + lunch_layout.addWidget(self.auto_lunch_check) + lunch_layout.addStretch() + layout.addLayout(lunch_layout) + + # 저녁시간 기본값 + dinner_layout = QHBoxLayout() + dinner_label = QLabel("저녁시간 기본:") + dinner_label.setFixedWidth(130) + self.dinner_spin = QSpinBox() + self.dinner_spin.setRange(0, 120) + self.dinner_spin.setValue(60) + self.dinner_spin.setSingleStep(5) + self.dinner_spin.setSuffix(" 분") + self.dinner_spin.setFixedWidth(110) + dinner_layout.addWidget(dinner_label) + dinner_layout.addWidget(self.dinner_spin) + dinner_layout.addStretch() + layout.addLayout(dinner_layout) + + group.setLayout(layout) + return group + + def on_preset_changed(self, index): + """근무 패턴 프리셋 변경 시 시간/분/점심 자동 입력""" + data = self.work_preset_combo.itemData(index) + if data is None: + # 사용자 정의: 입력값 유지 + return + work_minutes, lunch_minutes = data + # 시그널 차단으로 _on_work_time_user_edit가 다시 프리셋을 바꾸지 않도록 + self.work_hours_spin.blockSignals(True) + self.work_minutes_spin.blockSignals(True) + self.lunch_spin.blockSignals(True) + try: + self.work_hours_spin.setValue(work_minutes // 60) + self.work_minutes_spin.setValue(work_minutes % 60) + self.lunch_spin.setValue(lunch_minutes) + finally: + self.work_hours_spin.blockSignals(False) + self.work_minutes_spin.blockSignals(False) + self.lunch_spin.blockSignals(False) + + def _on_work_time_user_edit(self, *_): + """사용자가 시간/분/점심을 직접 수정하면 프리셋 콤보를 '사용자 정의'로 전환""" + if not hasattr(self, '_settings_loaded'): + return + # 현재 값과 일치하는 프리셋이 있는지 확인 + current = ( + self.work_hours_spin.value() * 60 + self.work_minutes_spin.value(), + self.lunch_spin.value() + ) + for i in range(self.work_preset_combo.count()): + data = self.work_preset_combo.itemData(i) + if data == current: + if self.work_preset_combo.currentIndex() != i: + self.work_preset_combo.blockSignals(True) + self.work_preset_combo.setCurrentIndex(i) + self.work_preset_combo.blockSignals(False) + return + # 일치하는 프리셋 없음 → 사용자 정의 + custom_index = self.work_preset_combo.count() - 1 + if self.work_preset_combo.currentIndex() != custom_index: + self.work_preset_combo.blockSignals(True) + self.work_preset_combo.setCurrentIndex(custom_index) + self.work_preset_combo.blockSignals(False) + + def create_notification_group(self) -> QGroupBox: + """알림 설정 그룹""" + group = QGroupBox(tr('group.notification')) + layout = QVBoxLayout() + layout.setSpacing(6) + + # 알림 체크박스들을 2열로 배치 + check_row1 = QHBoxLayout() + self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림") + self.clock_out_notification_check.setChecked(True) + self.lunch_notification_check = QCheckBox("점심시간 등록 알림") + self.lunch_notification_check.setChecked(True) + check_row1.addWidget(self.clock_out_notification_check) + check_row1.addWidget(self.lunch_notification_check) + layout.addLayout(check_row1) + + check_row2 = QHBoxLayout() + self.overtime_notification_check = QCheckBox("연장근무 적립 알림") + self.overtime_notification_check.setChecked(True) + self.health_notification_check = QCheckBox("건강 경고 알림") + self.health_notification_check.setChecked(True) + check_row2.addWidget(self.overtime_notification_check) + check_row2.addWidget(self.health_notification_check) + layout.addLayout(check_row2) + + # 시간 형식 + 테마 한 줄에 + format_row = QHBoxLayout() + time_format_label = QLabel("시간 형식:") + time_format_label.setFixedWidth(70) + self.time_format_combo = QComboBox() + self.time_format_combo.addItem("24시간 (17:30)", "24") + self.time_format_combo.addItem("오전/오후 (오후 5:30)", "12") + self.time_format_combo.setFixedWidth(180) + + theme_label = QLabel("테마:") + theme_label.setFixedWidth(40) + self.theme_combo = QComboBox() + self.theme_combo.addItem("라이트", "light") + self.theme_combo.addItem("다크", "dark") + self.theme_combo.setFixedWidth(90) + self.theme_combo.currentIndexChanged.connect(self.on_theme_changed) + + format_row.addWidget(time_format_label) + format_row.addWidget(self.time_format_combo) + format_row.addSpacing(16) + format_row.addWidget(theme_label) + format_row.addWidget(self.theme_combo) + format_row.addStretch() + layout.addLayout(format_row) + + # 언어 선택 + from core.i18n import available_languages, language_label + lang_row = QHBoxLayout() + lang_label = QLabel("언어 / Language:") + lang_label.setFixedWidth(120) + self.language_combo = QComboBox() + for code in available_languages(): + self.language_combo.addItem(language_label(code), code) + self.language_combo.setFixedWidth(140) + self.language_combo.setToolTip("언어 변경은 재시작 후 완전히 적용됩니다.") + lang_row.addWidget(lang_label) + lang_row.addWidget(self.language_combo) + lang_row.addStretch() + layout.addLayout(lang_row) + + group.setLayout(layout) + return group + + def create_overtime_group(self) -> QGroupBox: + """연장근무 설정 그룹""" + group = QGroupBox(tr('group.overtime')) + layout = QVBoxLayout() + layout.setSpacing(6) + + # 잔액 + 계산 단위 한 줄 + top_row = QHBoxLayout() + self.current_overtime_label = QLabel("현재 잔액: 계산 중...") + self.current_overtime_label.setObjectName("badge_success") + top_row.addWidget(self.current_overtime_label) + top_row.addStretch() + + unit_label = QLabel("계산 단위:") + self.overtime_unit_combo = QComboBox() + self.overtime_unit_combo.addItem("30분", 30) + self.overtime_unit_combo.addItem("1시간", 60) + self.overtime_unit_combo.addItem("15분", 15) + self.overtime_unit_combo.setFixedWidth(100) + top_row.addWidget(unit_label) + top_row.addWidget(self.overtime_unit_combo) + layout.addLayout(top_row) + + # 초기 연장근무 설정 + initial_overtime_layout = QHBoxLayout() + initial_overtime_label = QLabel("기존 연장근무:") + initial_overtime_label.setFixedWidth(100) + self.initial_overtime_hours = QSpinBox() + self.initial_overtime_hours.setRange(0, 200) + self.initial_overtime_hours.setValue(0) + self.initial_overtime_hours.setSuffix(" 시간") + self.initial_overtime_hours.setFixedWidth(110) + + self.initial_overtime_mins = QSpinBox() + self.initial_overtime_mins.setRange(0, 59) + self.initial_overtime_mins.setValue(0) + self.initial_overtime_mins.setSuffix(" 분") + self.initial_overtime_mins.setFixedWidth(100) + + apply_overtime_btn = QPushButton("적용") + apply_overtime_btn.setObjectName("btn_small") + apply_overtime_btn.setFixedWidth(50) + apply_overtime_btn.clicked.connect(self.apply_initial_overtime) + + self.auto_overtime_check = QCheckBox("자동 적립") + self.auto_overtime_check.setChecked(True) + self.auto_overtime_check.setToolTip("퇴근 시 연장근무 자동 적립") + + initial_overtime_layout.addWidget(initial_overtime_label) + initial_overtime_layout.addWidget(self.initial_overtime_hours) + initial_overtime_layout.addWidget(self.initial_overtime_mins) + initial_overtime_layout.addWidget(apply_overtime_btn) + initial_overtime_layout.addStretch() + initial_overtime_layout.addWidget(self.auto_overtime_check) + layout.addLayout(initial_overtime_layout) + + initial_overtime_note = QLabel("※ 프로그램 사용 전 쌓인 연장근무 시간 (절대값)") + initial_overtime_note.setObjectName("note_text") + layout.addWidget(initial_overtime_note) + + group.setLayout(layout) + return group + + def create_leave_group(self) -> QGroupBox: + """휴가 설정 그룹""" + group = QGroupBox(tr('group.leave')) + layout = QVBoxLayout() + layout.setSpacing(6) + + # 연차 개수 + 남은 연차 한 줄 + top_row = QHBoxLayout() + annual_leave_label = QLabel("연간 연차:") + annual_leave_label.setFixedWidth(70) + self.annual_leave_days = QSpinBox() + self.annual_leave_days.setRange(0, 30) + self.annual_leave_days.setValue(15) + self.annual_leave_days.setSuffix(" 일") + self.annual_leave_days.setFixedWidth(100) + top_row.addWidget(annual_leave_label) + top_row.addWidget(self.annual_leave_days) + top_row.addStretch() + + self.remaining_leave_label = QLabel("남은 연차: 계산 중...") + self.remaining_leave_label.setObjectName("badge_leave") + top_row.addWidget(self.remaining_leave_label) + layout.addLayout(top_row) + + # 기존 사용 연차 설정 + used_leave_layout = QHBoxLayout() + used_leave_label = QLabel("기존 사용:") + used_leave_label.setFixedWidth(70) + self.used_leave_hours = QSpinBox() + self.used_leave_hours.setRange(0, 200) + self.used_leave_hours.setValue(0) + self.used_leave_hours.setSuffix(" 시간") + self.used_leave_hours.setFixedWidth(110) + + self.used_leave_mins = QSpinBox() + self.used_leave_mins.setRange(0, 59) + self.used_leave_mins.setValue(0) + self.used_leave_mins.setSuffix(" 분") + self.used_leave_mins.setSingleStep(30) + self.used_leave_mins.setFixedWidth(100) + + apply_used_leave_btn = QPushButton("적용") + apply_used_leave_btn.setObjectName("btn_small") + apply_used_leave_btn.setFixedWidth(50) + apply_used_leave_btn.clicked.connect(self.apply_used_leave) + + used_leave_layout.addWidget(used_leave_label) + used_leave_layout.addWidget(self.used_leave_hours) + used_leave_layout.addWidget(self.used_leave_mins) + used_leave_layout.addWidget(apply_used_leave_btn) + used_leave_layout.addStretch() + layout.addLayout(used_leave_layout) + + used_leave_note = QLabel("※ 프로그램 사용 전 이미 사용한 연차 (1일=8시간)") + used_leave_note.setObjectName("note_text") + layout.addWidget(used_leave_note) + + group.setLayout(layout) + return group + + def create_holiday_group(self) -> QGroupBox: + """공휴일 설정 그룹""" + group = QGroupBox(tr('group.holiday')) + layout = QVBoxLayout() + layout.setSpacing(6) + + # 공휴일 목록 + 버튼 한 줄 + button_layout = QHBoxLayout() + holiday_list_label = QLabel("등록:") + button_layout.addWidget(holiday_list_label) + + self.holiday_count_label = QLabel("0개") + self.holiday_count_label.setObjectName("info_text") + button_layout.addWidget(self.holiday_count_label) + button_layout.addStretch() + + add_korean_btn = QPushButton("한국 공휴일 (자동)") + add_korean_btn.setObjectName("btn_small") + add_korean_btn.setToolTip("음력 명절(설/추석) + 임시공휴일 포함 자동 등록") + add_korean_btn.clicked.connect(self.add_korean_holidays_auto) + button_layout.addWidget(add_korean_btn) + + add_custom_btn = QPushButton("추가") + add_custom_btn.setObjectName("btn_small") + add_custom_btn.clicked.connect(self.add_custom_holiday) + button_layout.addWidget(add_custom_btn) + + view_holidays_btn = QPushButton("목록") + view_holidays_btn.setObjectName("btn_small") + view_holidays_btn.clicked.connect(self.view_holidays) + button_layout.addWidget(view_holidays_btn) + + layout.addLayout(button_layout) + + holiday_note = QLabel("※ 공휴일 근무 시 모든 시간이 연장근무로 적립됩니다") + holiday_note.setObjectName("note_text") + layout.addWidget(holiday_note) + + group.setLayout(layout) + + # 공휴일 개수 업데이트 + self.update_holiday_count() + + return group + + def update_holiday_count(self): + """공휴일 개수 표시 업데이트""" + current_year = datetime.now().year + holidays = self.db.get_holidays_by_year(current_year) + self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)") + + def add_korean_holidays_auto(self): + """holidays 패키지로 음력/임시 공휴일 포함 자동 추가""" + current_year = datetime.now().year + + reply = QMessageBox.question( + self, + "한국 공휴일 자동 추가", + f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n" + "포함:\n" + "• 양력 공휴일 (신정/삼일절/어린이날 등)\n" + "• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n" + "• 정부 지정 대체·임시공휴일\n\n" + "※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)", + QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + + added = self.db.add_korean_holidays_auto(current_year) + if added < 0: + # 패키지 미설치 시 고정 공휴일로 폴백 + self.db.add_korean_holidays(current_year) + self.update_holiday_count() + QMessageBox.warning( + self, + "패키지 미설치", + "'holidays' 패키지가 설치되지 않아 고정 공휴일만 추가했습니다.\n\n" + "음력/임시공휴일 자동 등록을 원하시면:\n" + " pip install holidays" + ) + return + + self.update_holiday_count() + QMessageBox.information( + self, + "추가 완료", + f"{current_year}년 한국 공휴일 {added}개가 추가되었습니다." + ) + + def add_custom_holiday(self): + """사용자 정의 공휴일 추가""" + from PyQt5.QtWidgets import QInputDialog, QLineEdit + + # 날짜 입력 + today = datetime.now().date().isoformat() + date_str, ok = QInputDialog.getText( + self, + "공휴일 추가", + "공휴일 날짜를 입력하세요 (YYYY-MM-DD):", + QLineEdit.Normal, + today + ) + + if not ok or not date_str: + return + + # 날짜 형식 검증 + try: + datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + QMessageBox.warning( + self, + "입력 오류", + "날짜 형식이 잘못되었습니다.\n올바른 형식: YYYY-MM-DD (예: 2024-01-01)" + ) + return + + # 공휴일 이름 입력 + name, ok = QInputDialog.getText( + self, + "공휴일 추가", + "공휴일 이름을 입력하세요:", + QLineEdit.Normal, + "" + ) + + if not ok or not name: + return + + # 공휴일 추가 + self.db.add_holiday(date_str, name, is_recurring=False) + self.update_holiday_count() + + QMessageBox.information( + self, + "추가 완료", + f"공휴일이 추가되었습니다.\n{date_str}: {name}" + ) + + def view_holidays(self): + """공휴일 목록 보기""" + current_year = datetime.now().year + holidays = self.db.get_holidays_by_year(current_year) + + if not holidays: + QMessageBox.information( + self, + "공휴일 목록", + f"{current_year}년에 등록된 공휴일이 없습니다." + ) + return + + # 목록 생성 + holiday_list = f"=== {current_year}년 공휴일 목록 ===\n\n" + for h in holidays: + date_obj = datetime.strptime(h['date'], "%Y-%m-%d") + weekday = ['월', '화', '수', '목', '금', '토', '일'][date_obj.weekday()] + recurring = " (매년)" if h['is_recurring'] else "" + holiday_list += f"• {h['date']} ({weekday}): {h['name']}{recurring}\n" + + holiday_list += f"\n총 {len(holidays)}개" + + # 삭제 옵션 제공 + reply = QMessageBox.question( + self, + "공휴일 목록", + holiday_list + "\n\n공휴일을 삭제하시겠습니까?", + QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel + ) + + if reply == QMessageBox.Yes: + self.delete_holiday_dialog() + + def delete_holiday_dialog(self): + """공휴일 삭제 다이얼로그""" + from PyQt5.QtWidgets import QInputDialog + + current_year = datetime.now().year + holidays = self.db.get_holidays_by_year(current_year) + + if not holidays: + return + + # 공휴일 선택 + items = [f"{h['date']}: {h['name']}" for h in holidays] + item, ok = QInputDialog.getItem( + self, + "공휴일 삭제", + "삭제할 공휴일을 선택하세요:", + items, + 0, + False + ) + + if ok and item: + date_str = item.split(":")[0] + self.db.delete_holiday_by_date(date_str) + self.update_holiday_count() + QMessageBox.information(self, "삭제 완료", f"{item}이(가) 삭제되었습니다.") + + def create_data_group(self) -> QGroupBox: + """데이터 관리 그룹""" + group = QGroupBox(tr('group.data')) + layout = QVBoxLayout() + layout.setSpacing(6) + + # CSV 내보내기 버튼들 한 줄 + export_layout = QHBoxLayout() + + export_work_btn = QPushButton("근무기록") + export_work_btn.setObjectName("btn_small") + export_work_btn.clicked.connect(self.export_work_records) + export_layout.addWidget(export_work_btn) + + export_overtime_btn = QPushButton("연장근무") + export_overtime_btn.setObjectName("btn_small") + export_overtime_btn.clicked.connect(self.export_overtime_summary) + export_layout.addWidget(export_overtime_btn) + + monthly_btn = QPushButton("월간 요약") + monthly_btn.setObjectName("btn_small") + monthly_btn.clicked.connect(self.export_monthly_summary) + export_layout.addWidget(monthly_btn) + + export_label = QLabel("CSV 내보내기") + export_label.setObjectName("note_text") + export_layout.addWidget(export_label) + export_layout.addStretch() + + layout.addLayout(export_layout) + + # DB 경로 설정 (클라우드 동기화 가능) + db_path_layout = QHBoxLayout() + db_path_label = QLabel("DB 경로:") + db_path_label.setFixedWidth(60) + self.db_path_edit = QLineEdit() + self.db_path_edit.setReadOnly(True) + self.db_path_edit.setText(self.db.db_path if hasattr(self.db, 'db_path') else 'database.db') + db_path_btn = QPushButton("변경...") + db_path_btn.setObjectName("btn_small") + db_path_btn.setToolTip("클라우드 폴더(OneDrive/Dropbox 등) 경로로 변경 가능. 재시작 필요.") + db_path_btn.clicked.connect(self._change_db_path) + db_path_layout.addWidget(db_path_label) + db_path_layout.addWidget(self.db_path_edit, 1) + db_path_layout.addWidget(db_path_btn) + layout.addLayout(db_path_layout) + + # 자동 외출 (화면 잠금 시) + self.auto_break_check = QCheckBox("화면 잠금 시 자동 외출/복귀") + self.auto_break_check.setToolTip("PC가 잠기면 외출 시작, 풀리면 복귀를 자동 처리합니다.") + layout.addWidget(self.auto_break_check) + + # 첫 잠금 해제 = 출근 (PC를 안 끄는 사용자용) + self.clock_in_unlock_check = QCheckBox("첫 잠금 해제 시각을 출근시간으로 사용") + self.clock_in_unlock_check.setToolTip( + "PC를 끄지 않고 출근하는 경우 — 부팅 이벤트가 없어도 화면 잠금 해제 시점을 출근으로 기록합니다." + ) + layout.addWidget(self.clock_in_unlock_check) + + # 업데이트 확인 + update_layout = QHBoxLayout() + from core.version import __version__ + version_label = QLabel(f"버전: v{__version__}") + version_label.setObjectName("note_text") + update_btn = QPushButton("업데이트 확인 (F5)") + update_btn.setObjectName("btn_small") + update_btn.clicked.connect(self._check_updates) + update_layout.addWidget(version_label) + update_layout.addStretch() + update_layout.addWidget(update_btn) + layout.addLayout(update_layout) + + group.setLayout(layout) + return group + + def _change_db_path(self): + """DB 경로 변경 (재시작 후 적용)""" + current = self.db.db_path if hasattr(self.db, 'db_path') else 'database.db' + new_path, _ = QFileDialog.getSaveFileName( + self, + "데이터베이스 파일 선택", + current, + "SQLite Database (*.db)" + ) + if not new_path: + return + # 파일 미존재 시 빈 파일 생성하지 않고, 경로만 저장 — 다음 실행 시 새 DB로 init + self.db.set_setting(DB_PATH_OVERRIDE, new_path) + self.db_path_edit.setText(new_path) + QMessageBox.information( + self, + "DB 경로 변경", + f"새 경로가 저장되었습니다:\n{new_path}\n\n" + "기존 데이터를 사용하려면 현재 database.db 파일을 새 위치로 복사하고\n" + "프로그램을 재시작하세요." + ) + + def load_settings(self): + """설정 불러오기""" + settings = self.db.get_settings() + + if settings: + # work_minutes 우선, 없으면 work_hours*60 폴백 + work_minutes = settings.get(WORK_MINUTES) + if work_minutes is None: + try: + work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60)) + except (ValueError, TypeError): + work_minutes = 480 + work_minutes = int(work_minutes) + + # blockSignals: load 시 _on_work_time_user_edit가 프리셋을 잘못 전환하지 않도록 + self.work_hours_spin.blockSignals(True) + self.work_minutes_spin.blockSignals(True) + try: + self.work_hours_spin.setValue(work_minutes // 60) + self.work_minutes_spin.setValue(work_minutes % 60) + finally: + self.work_hours_spin.blockSignals(False) + self.work_minutes_spin.blockSignals(False) + + lunch_minutes = int(settings.get(LUNCH_DURATION_MINUTES, 60)) + self.lunch_spin.blockSignals(True) + try: + self.lunch_spin.setValue(lunch_minutes) + finally: + self.lunch_spin.blockSignals(False) + + # 현재 (work_minutes, lunch_minutes)와 일치하는 프리셋 선택 + current = (work_minutes, lunch_minutes) + preset_idx = self.work_preset_combo.count() - 1 # 기본값: 사용자 정의 + for i in range(self.work_preset_combo.count()): + if self.work_preset_combo.itemData(i) == current: + preset_idx = i + break + self.work_preset_combo.blockSignals(True) + self.work_preset_combo.setCurrentIndex(preset_idx) + self.work_preset_combo.blockSignals(False) + + self.auto_lunch_check.setChecked(settings.get(AUTO_LUNCH, False)) + + self.dinner_spin.setValue(int(settings.get(DINNER_DURATION_MINUTES, 60))) + + # 자동 외출 (화면 잠금) + if hasattr(self, 'auto_break_check'): + self.auto_break_check.setChecked(settings.get(AUTO_BREAK_ON_LOCK, False)) + if hasattr(self, 'clock_in_unlock_check'): + self.clock_in_unlock_check.setChecked(settings.get(CLOCK_IN_ON_UNLOCK, False)) + + # 알림 + self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True)) + self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True)) + self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True)) + self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True)) + + # 시간 형식 (콤보박스는 문자열로 저장하므로 변환) + time_format = settings.get(TIME_FORMAT, '24') + if isinstance(time_format, int): + time_format = str(time_format) + index = self.time_format_combo.findData(time_format) + if index >= 0: + self.time_format_combo.setCurrentIndex(index) + + # 테마 + self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1) + + # 언어 선택 적용 + if hasattr(self, 'language_combo'): + lang = settings.get(LANGUAGE, 'ko') or 'ko' + idx = self.language_combo.findData(lang) + if idx >= 0: + self.language_combo.setCurrentIndex(idx) + + # 연장근무 (콤보박스는 정수로 저장) + overtime_unit = settings.get(OVERTIME_UNIT, 30) + if isinstance(overtime_unit, str): + overtime_unit = int(overtime_unit) + index = self.overtime_unit_combo.findData(overtime_unit) + if index >= 0: + self.overtime_unit_combo.setCurrentIndex(index) + self.auto_overtime_check.setChecked(settings.get(AUTO_OVERTIME, True)) + + # 휴가 + self.annual_leave_days.setValue(settings.get(ANNUAL_LEAVE_DAYS, 15)) + + # 기존 연장근무 초기값 로드 (settings에서) + initial_overtime = int(self.db.get_setting(INITIAL_OVERTIME_MINUTES, '0')) + self.initial_overtime_hours.setValue(initial_overtime // 60) + self.initial_overtime_mins.setValue(initial_overtime % 60) + + # 기존 사용 연차 초기값 로드 (settings에서) + initial_leave_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) + self.used_leave_hours.setValue(int(initial_leave_hours)) + self.used_leave_mins.setValue(int((initial_leave_hours % 1) * 60)) + + # 남은 연차 계산 + self.update_remaining_leave() + + def _check_updates(self): + """설정 창에서 업데이트 확인 트리거 → 부모 윈도우로 위임.""" + if self.parent_window and hasattr(self.parent_window, 'check_for_updates'): + self.parent_window.check_for_updates(silent=False) + + def _restart_app(self): + """언어 변경 후 자동 재시작 (사용자 명시 동의 시).""" + from PyQt5.QtWidgets import QApplication + import sys, os + QApplication.quit() + # 빌드된 exe / 개발 환경 모두 처리 + if getattr(sys, 'frozen', False): + os.execv(sys.executable, [sys.executable]) + else: + os.execv(sys.executable, [sys.executable, *sys.argv]) + + def save_settings(self): + """설정 저장""" + # 점심시간은 분 단위 그대로 저장 + lunch_minutes = self.lunch_spin.value() + # 저녁시간은 분 단위 그대로 저장 + dinner_minutes = self.dinner_spin.value() + + work_minutes_total = self.work_hours_spin.value() * 60 + self.work_minutes_spin.value() + if work_minutes_total < 30: + QMessageBox.warning(self, tr('msg.input_error.title'), + tr('msg.work_min_too_small')) + return + + # work_hours는 db.save_settings()가 자동 동기화하므로 보내지 않음 (단일 진실 소스) + settings = { + WORK_MINUTES: work_minutes_total, + LUNCH_DURATION_MINUTES: lunch_minutes, + DINNER_DURATION_MINUTES: dinner_minutes, + AUTO_LUNCH: self.auto_lunch_check.isChecked(), + NOTIF_CLOCK_OUT: self.clock_out_notification_check.isChecked(), + NOTIF_LUNCH: self.lunch_notification_check.isChecked(), + NOTIF_OVERTIME: self.overtime_notification_check.isChecked(), + NOTIF_HEALTH: self.health_notification_check.isChecked(), + TIME_FORMAT: self.time_format_combo.currentData(), + OVERTIME_UNIT: self.overtime_unit_combo.currentData(), + AUTO_OVERTIME: self.auto_overtime_check.isChecked(), + ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(), + } + if hasattr(self, 'auto_break_check'): + settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked() + if hasattr(self, 'clock_in_unlock_check'): + settings[CLOCK_IN_ON_UNLOCK] = self.clock_in_unlock_check.isChecked() + if hasattr(self, 'language_combo'): + settings[LANGUAGE] = self.language_combo.currentData() + + self.db.save_settings(settings) + + # 테마 저장 + self.db.set_setting(THEME, self.theme_combo.currentData()) + + # 연차 잔액 재계산 (총 연차 - 올해 사용한 연차) + current_year = datetime.now().year + all_leaves = self.db.get_all_leave_records(limit=365) + year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] + used_annual_days = sum(r['days'] for r in year_leaves) + new_balance = settings[ANNUAL_LEAVE_DAYS] - used_annual_days + self.db.set_leave_balance(new_balance) + + QMessageBox.information( + self, + "저장 완료", + "설정이 저장되었습니다." + ) + + # 부모 윈도우에 설정 변경 알림 + if self.parent_window and hasattr(self.parent_window, 'reload_settings'): + self.parent_window.reload_settings() + + # 언어 변경 감지 → 재시작 제안 + if hasattr(self, 'language_combo'): + from core.i18n import get_language + new_lang = self.language_combo.currentData() + if new_lang and new_lang != get_language(): + reply = QMessageBox.question( + self, + "재시작 필요 / Restart required", + "언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n" + "Restart now to fully apply the language change?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self._restart_app() + + def on_theme_changed(self, index): + """테마 콤보박스 변경 시 즉시 적용""" + if not hasattr(self, '_settings_loaded'): + return + theme_name = self.theme_combo.currentData() + if theme_name: + # DB에 저장 + self.db.set_setting(THEME, theme_name) + # 부모 윈도우를 통해 테마 적용 (setStyleSheet이 메인 윈도우에 설정되므로) + if self.parent_window and hasattr(self.parent_window, 'apply_theme'): + self.parent_window.apply_theme(theme_name) + + def apply_initial_overtime(self): + """기존 연장근무 설정 (프로그램 사용 전 쌓인 시간 - 절대값)""" + try: + hours = self.initial_overtime_hours.value() + mins = self.initial_overtime_mins.value() + new_initial_minutes = hours * 60 + mins + + # 기존 초기값 조회 + old_initial_minutes = int(self.db.get_setting(INITIAL_OVERTIME_MINUTES, '0')) + old_hours = old_initial_minutes // 60 + old_mins = old_initial_minutes % 60 + + reply = QMessageBox.question( + self, + "기존 연장근무 설정", + f"현재 설정: {old_hours}시간 {old_mins}분\n" + f"변경할 값: {hours}시간 {mins}분\n\n" + f"기존 연장근무 시간을 변경하시겠습니까?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # settings에 초기값 저장 + self.db.set_setting(INITIAL_OVERTIME_MINUTES,str(new_initial_minutes)) + + QMessageBox.information( + self, + "설정 완료", + f"기존 연장근무가 {hours}시간 {mins}분으로 설정되었습니다." + ) + + # 부모 윈도우 잔액 업데이트 + if self.parent_window and hasattr(self.parent_window, 'update_overtime_balance'): + self.parent_window.update_overtime_balance() + + # 현재 창의 잔액 표시도 업데이트 + self.update_overtime_balance_display() + except Exception as e: + QMessageBox.critical( + self, + "오류", + f"기존 연장근무 설정 중 오류가 발생했습니다:\n{str(e)}" + ) + + def update_overtime_balance_display(self): + """연장근무 잔액 표시 업데이트""" + balance_minutes = self.db.get_total_overtime_balance() + hours = balance_minutes // 60 + minutes = balance_minutes % 60 + self.current_overtime_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance_minutes}분)") + + def update_remaining_leave(self): + """남은 연차 계산 및 표시""" + # 기존 사용 연차 초기값 (프로그램 사용 전) + initial_leave_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) + initial_leave_days = initial_leave_hours / 8.0 + + # 올해 사용한 연차 조회 (프로그램에서 기록된 것) + current_year = datetime.now().year + all_leaves = self.db.get_all_leave_records(limit=365) + year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] + + # 모든 연차 타입 합산 (days 필드 사용) + used_annual_days = sum(r['days'] for r in year_leaves) + + # 총 사용량 = 초기값 + 프로그램에서 기록된 것 + total_used = initial_leave_days + used_annual_days + + # 총 연차 + total_annual = self.annual_leave_days.value() + + # 남은 연차 + remaining = total_annual - total_used + + self.remaining_leave_label.setText( + f"남은 연차: {remaining:.1f}일 (총 {total_annual}일 중 {total_used:.1f}일 사용)" + ) + + def export_work_records(self): + """근무 기록 내보내기""" + # 내보낼 기록 가져오기 + now = datetime.now() + stats = self.db.get_monthly_stats(now.year, now.month) + records = stats.get('records', []) + + if not records: + QMessageBox.warning(self, "내보내기 실패", "내보낼 기록이 없습니다.") + return + + # 파일 경로 선택 + default_filename = f"work_records_{now.year}{now.month:02d}.csv" + filename, _ = QFileDialog.getSaveFileName( + self, + "근무 기록 저장", + default_filename, + "CSV Files (*.csv)" + ) + + if filename: + try: + saved_path = CSVExporter.export_work_records(records, filename) + QMessageBox.information( + self, + "내보내기 완료", + f"근무 기록이 저장되었습니다.\n{saved_path}" + ) + except Exception as e: + QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") + + def export_overtime_summary(self): + """연장근무 내역 내보내기""" + filename, _ = QFileDialog.getSaveFileName( + self, + "연장근무 내역 저장", + f"overtime_summary_{datetime.now().strftime('%Y%m%d')}.csv", + "CSV Files (*.csv)" + ) + + if filename: + try: + saved_path = CSVExporter.export_overtime_summary(self.db, filename) + QMessageBox.information( + self, + "내보내기 완료", + f"연장근무 내역이 저장되었습니다.\n{saved_path}" + ) + except Exception as e: + QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") + + def export_monthly_summary(self): + """월간 요약 내보내기""" + now = datetime.now() + filename, _ = QFileDialog.getSaveFileName( + self, + "월간 요약 저장", + f"monthly_summary_{now.year}{now.month:02d}.csv", + "CSV Files (*.csv)" + ) + + if filename: + try: + saved_path = CSVExporter.export_monthly_summary(self.db, now.year, now.month, filename) + QMessageBox.information( + self, + "내보내기 완료", + f"월간 요약이 저장되었습니다.\n{saved_path}" + ) + except Exception as e: + QMessageBox.critical(self, "내보내기 실패", f"오류: {str(e)}") + + def apply_used_leave(self): + """기존 사용 연차 설정 (프로그램 사용 전 이미 사용한 연차 - 절대값)""" + hours = self.used_leave_hours.value() + mins = self.used_leave_mins.value() + new_initial_hours = hours + (mins / 60.0) + + # 기존 초기값 조회 + old_initial_hours = float(self.db.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) + old_hours = int(old_initial_hours) + old_mins = int((old_initial_hours % 1) * 60) + + reply = QMessageBox.question( + self, + "기존 사용 연차 설정", + f"현재 설정: {old_hours}시간 {old_mins}분\n" + f"변경할 값: {hours}시간 {mins}분\n\n" + f"기존 사용 연차를 변경하시겠습니까?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # settings에 초기값 저장 + self.db.set_setting(INITIAL_LEAVE_USED_HOURS,str(new_initial_hours)) + + QMessageBox.information( + self, + "설정 완료", + f"기존 사용 연차가 {hours}시간 {mins}분으로 설정되었습니다." + ) + + # 남은 연차 재계산 + self.update_remaining_leave() + + # 부모 윈도우의 연차 잔액도 업데이트 + if self.parent_window and hasattr(self.parent_window, 'update_leave_balance'): + self.parent_window.update_leave_balance() + + def add_past_leave_usage(self): + """이전 사용 연차 기록하기""" + dialog = AddLeaveDialog(self, self.db) + if dialog.exec_() == QDialog.Accepted: + # 남은 연차 재계산 + self.update_remaining_leave() + + # 부모 윈도우의 연차 잔액도 업데이트 + if self.parent_window and hasattr(self.parent_window, 'update_leave_balance'): + self.parent_window.update_leave_balance() + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = SettingsView() + dialog.exec_() diff --git a/ui/stats_view.py b/ui/stats_view.py new file mode 100644 index 0000000..43814fa --- /dev/null +++ b/ui/stats_view.py @@ -0,0 +1,283 @@ +""" +통계 대시보드 - 주간/월간 통계 +""" +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget) +from PyQt5.QtCore import Qt +from datetime import datetime, timedelta +import sys +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 apply_dark_titlebar + + +class StatsView(QDialog): + """통계 뷰 다이얼로그""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db if db else Database() + self.init_ui() + self.load_stats() + apply_dark_titlebar(self) + + def init_ui(self): + """UI 초기화""" + self.setWindowTitle(tr('window.stats')) + self.setModal(True) + self.setMinimumSize(420, 350) + + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(12, 10, 12, 10) + + title = QLabel(tr('stats.title')) + title.setObjectName("dialog_title") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + tabs = QTabWidget() + 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) + + close_button = QPushButton(tr('btn.close')) + close_button.clicked.connect(self.close) + layout.addWidget(close_button) + + self.setLayout(layout) + + def create_weekly_tab(self) -> QWidget: + """주간 통계 탭 생성""" + widget = QWidget() + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(4, 4, 4, 4) + + summary_group = QGroupBox(tr('stats.weekly_summary')) + summary_layout = QGridLayout() + summary_layout.setSpacing(4) + summary_layout.setContentsMargins(8, 20, 8, 6) + + self.weekly_total_hours = QLabel("0") + self.weekly_total_hours.setObjectName("stat_value") + self.weekly_work_days = QLabel("0") + self.weekly_work_days.setObjectName("stat_value") + self.weekly_avg_hours = QLabel("0") + self.weekly_avg_hours.setObjectName("stat_value") + self.weekly_overtime = QLabel("0") + self.weekly_overtime.setObjectName("stat_value") + + summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0) + summary_layout.addWidget(self.weekly_total_hours, 0, 1) + summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0) + summary_layout.addWidget(self.weekly_work_days, 1, 1) + summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0) + summary_layout.addWidget(self.weekly_avg_hours, 2, 1) + summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0) + summary_layout.addWidget(self.weekly_overtime, 3, 1) + + summary_group.setLayout(summary_layout) + layout.addWidget(summary_group) + + # 주간 차트 (일별 근무시간) + from ui.chart_widget import make_chart_widget + self.weekly_chart = make_chart_widget(widget) + layout.addWidget(self.weekly_chart, 1) + + widget.setLayout(layout) + return widget + + def create_monthly_tab(self) -> QWidget: + """월간 통계 탭 생성""" + widget = QWidget() + layout = QVBoxLayout() + layout.setSpacing(6) + layout.setContentsMargins(4, 4, 4, 4) + + 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) + + # 월간 차트 + from ui.chart_widget import make_chart_widget + self.monthly_chart = make_chart_widget(widget) + layout.addWidget(self.monthly_chart, 1) + + widget.setLayout(layout) + return widget + + def create_pattern_tab(self) -> QWidget: + """패턴 분석 탭 생성""" + widget = QWidget() + 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) + + 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) + + layout.addStretch() + widget.setLayout(layout) + return widget + + 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)}일") + avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0 + self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간") + + overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0 + overtime_hours = overtime_minutes // 60 + overtime_mins = overtime_minutes % 60 + self.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분") + + # 주간 차트 + from ui.chart_widget import draw_daily_hours, draw_weekday_avg + from datetime import timedelta as _td + today = datetime.now().date() + week_records = self.db.get_work_records_by_range( + (today - _td(days=6)).isoformat(), today.isoformat() + ) + if hasattr(self, 'weekly_chart'): + 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}시간") + work_days = monthly_stats.get('work_days', 0) or 0 + self.monthly_work_days.setText(f"{work_days}일") + + if work_days > 0: + avg = total_hours / work_days + self.monthly_avg_hours.setText(f"{avg:.1f}시간") + else: + self.monthly_avg_hours.setText("0시간") + + overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0 + overtime_hours = overtime_minutes // 60 + overtime_mins = overtime_minutes % 60 + self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분") + + # 월간 차트 (요일별 평균) + if hasattr(self, 'monthly_chart'): + draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', [])) + + # 패턴 분석 + self.analyze_patterns(monthly_stats.get('records', [])) + + def analyze_patterns(self, records): + """패턴 분석""" + if not records: + self.pattern_text.setText(tr('stats.no_data')) + return + + insights = [] + + # 평균 출근 시간 + clock_in_times = [] + for record in records: + if record.get('clock_in'): + try: + time_parts = record['clock_in'].split(':') + hour = int(time_parts[0]) + minute = int(time_parts[1]) + clock_in_times.append(hour * 60 + minute) + except (ValueError, IndexError): + continue + + if clock_in_times: + 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}") + + # 연장근무 빈도 + overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0]) + total_days = len([r for r in records if r.get('clock_out')]) + + if total_days > 0: + overtime_rate = (overtime_days / total_days) * 100 + insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)") + + # 가장 긴 근무일 + records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0] + if records_with_hours: + longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0)) + if longest_work.get('total_hours', 0) > 0: + insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)") + + # 건강 경고 + recent_records = records[-7:] # 최근 7일 + consecutive_overtime = 0 + max_consecutive = 0 + + for record in recent_records: + if (record.get('overtime_earned') or 0) > 0: + consecutive_overtime += 1 + max_consecutive = max(max_consecutive, consecutive_overtime) + else: + consecutive_overtime = 0 + + if max_consecutive >= 3: + insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!") + + # 주 52시간 체크 + if len(recent_records) >= 7: + week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:]) + if week_total > 52: + insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간") + + self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.") + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication + + app = QApplication(sys.argv) + dialog = StatsView() + dialog.exec_() diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..ff6aac3 --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,955 @@ +""" +테마 시스템 - 라이트/다크 테마 지원 +""" +import os +import tempfile + +# ─── 화살표 아이콘 생성 ────────────────────────────────────── + +_arrow_dir = os.path.join(tempfile.gettempdir(), 'clockout_arrows') +os.makedirs(_arrow_dir, exist_ok=True) + + +_light_arrows = {} +_dark_arrows = {} +_checkmark = '' +_icons_initialized = False + + +def _ensure_icons(): + """아이콘 PNG가 필요할 때 생성 (QApplication 존재 필요)""" + global _light_arrows, _dark_arrows, _checkmark, _icons_initialized + if _icons_initialized: + return + _icons_initialized = True + + try: + from PyQt5.QtGui import QPixmap, QPainter, QColor as _QColor, QPolygon, QPen + from PyQt5.QtCore import QPoint + except ImportError: + return + + arrows = {} + 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)]), + ]: + path = os.path.join(_arrow_dir, f'{name}.png') + if not os.path.exists(path): + pm = QPixmap(16, 12) + pm.fill(_QColor(0, 0, 0, 0)) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(_QColor(color_hex)) + p.setBrush(_QColor(color_hex)) + poly = QPolygon([QPoint(x, y) for x, y in points]) + p.drawPolygon(poly) + p.end() + pm.save(path, 'PNG') + arrows[name] = path.replace('\\', '/') + + # 체크마크 아이콘 생성 + checkmark_path = os.path.join(_arrow_dir, 'checkmark.png') + if not os.path.exists(checkmark_path): + pm = QPixmap(14, 14) + pm.fill(_QColor(0, 0, 0, 0)) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + pen = QPen(_QColor('#FFFFFF')) + pen.setWidth(2) + p.setPen(pen) + p.drawLine(QPoint(2, 7), QPoint(5, 11)) + p.drawLine(QPoint(5, 11), QPoint(11, 3)) + p.end() + pm.save(checkmark_path, 'PNG') + + _light_arrows = {k: v for k, v in arrows.items() if 'light' in k} + _dark_arrows = {k: v for k, v in arrows.items() if 'dark' in k} + _checkmark = checkmark_path.replace('\\', '/') + + + +# ─── 색상 정의 ─────────────────────────────────────────────── + +LIGHT_COLORS = { + # 배경 계층 + 'bg_primary': '#F5F5F7', + 'bg_secondary': '#FFFFFF', + 'bg_tertiary': '#EDEDF0', + # 텍스트 계층 + 'text_primary': '#1A1A2E', + 'text_secondary': '#4A4A68', + 'text_tertiary': '#8E8EA0', + 'text_inverse': '#FFFFFF', + # 액센트 + 'accent_primary': '#3B82F6', + 'accent_success': '#10B981', + 'accent_warning': '#F59E0B', + 'accent_danger': '#EF4444', + # 테두리 + 'border_subtle': '#E5E7EB', + 'border_default': '#D1D5DB', + 'border_focus': '#3B82F6', + # 배지 배경 + 'badge_overtime_bg': '#FEF3C7', + 'badge_overtime_text': '#92400E', + 'badge_leave_bg': '#DBEAFE', + 'badge_leave_text': '#1E40AF', + 'badge_total_bg': '#D1FAE5', + 'badge_total_text': '#065F46', + # 프로그레스 + 'progress_bg': '#E5E7EB', + 'progress_start': '#3B82F6', + 'progress_end': '#10B981', + # 상태 색상 (동적) + 'status_overtime': '#EF4444', + 'status_warning': '#F59E0B', + 'status_normal': '#10B981', + 'status_break_active': '#EF4444', + 'status_break_idle': '#8E8EA0', + # 캘린더 날짜 배경 + 'cal_normal': '#D1FAE5', + 'cal_overtime': '#FEE2E2', + 'cal_incomplete': '#FEF9C3', + # 스크롤바 + 'scrollbar_bg': '#F5F5F7', + 'scrollbar_handle': '#C4C4CC', + 'scrollbar_hover': '#A0A0B0', +} + +DARK_COLORS = { + 'bg_primary': '#111118', + 'bg_secondary': '#1C1C2E', + 'bg_tertiary': '#282842', + 'text_primary': '#ECECF4', + 'text_secondary': '#B0B0C8', + 'text_tertiary': '#808098', + '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', +} + + +# ─── 현재 테마 상태 ───────────────────────────────────────── + +class ThemeColors: + """런타임에 현재 테마 색상을 참조하는 헬퍼""" + current = LIGHT_COLORS + + @classmethod + def set_theme(cls, theme_name: str): + cls.current = DARK_COLORS if theme_name == 'dark' else LIGHT_COLORS + + @classmethod + def get(cls, key: str) -> str: + return cls.current.get(key, '#FF00FF') # 누락 시 눈에 띄는 색 + + +# ─── QSS 생성 ──────────────────────────────────────────────── + +def generate_theme(colors: dict, is_dark: bool = False) -> str: + """색상 딕셔너리로부터 전체 QSS 문자열 생성""" + _ensure_icons() + c = colors + arrows = _dark_arrows if is_dark else _light_arrows + up_arrow = arrows.get('up_dark' if is_dark else 'up_light', '') + down_arrow = arrows.get('down_dark' if is_dark else 'down_light', '') + checkmark = _checkmark + return f""" +/* ════════════════════════════════════════ + 기본 위젯 + ════════════════════════════════════════ */ + +QMainWindow, QDialog {{ + background: {c['bg_primary']}; +}} + +QWidget {{ + font-family: "Segoe UI", "맑은 고딕", sans-serif; + font-size: 9.5pt; + color: {c['text_primary']}; +}} + +QWidget#central_widget {{ + background: transparent; +}} + +/* ════════════════════════════════════════ + 타이포그래피 + ════════════════════════════════════════ */ + +QLabel#app_title {{ + font-size: 12pt; + font-weight: bold; + color: {c['text_primary']}; + padding: 2px; +}} + +QLabel#date_label {{ + font-size: 9pt; + color: {c['text_secondary']}; + padding-bottom: 4px; +}} + +QLabel#section_title {{ + font-size: 9.5pt; + font-weight: bold; + color: {c['text_primary']}; +}} + +QLabel#field_label {{ + font-size: 9pt; + color: {c['text_secondary']}; +}} + +QLabel#time_value {{ + font-family: "Consolas", "D2Coding", monospace; + font-size: 11pt; + font-weight: bold; + color: {c['text_primary']}; +}} + +QLabel#time_display {{ + font-family: "Consolas", "D2Coding", monospace; + font-size: 22pt; + font-weight: bold; + color: {c['text_primary']}; + background: {c['bg_secondary']}; + border: 1px solid {c['border_subtle']}; + border-radius: 10px; + padding: 10px; +}} + +QLabel#expected_time {{ + font-size: 10pt; + font-weight: bold; + color: {c['text_primary']}; + padding: 4px; +}} + +QLabel#dialog_title {{ + font-size: 14pt; + font-weight: bold; + color: {c['text_primary']}; + padding: 16px; +}} + +QLabel#dialog_subtitle {{ + font-size: 12pt; + font-weight: bold; + color: {c['text_primary']}; +}} + +QLabel#stat_value {{ + font-size: 10pt; + font-weight: bold; + color: {c['accent_primary']}; +}} + +QLabel#note_text {{ + font-size: 8.5pt; + color: {c['text_tertiary']}; +}} + +QLabel#info_text {{ + font-size: 9pt; + color: {c['accent_danger']}; +}} + +/* ════════════════════════════════════════ + 배지 라벨 (배경색 있는 상태 표시) + ════════════════════════════════════════ */ + +QLabel#badge_overtime {{ + font-size: 9.5pt; + font-weight: bold; + padding: 4px 10px; + min-width: 110px; + qproperty-alignment: AlignCenter; + background: {c['badge_overtime_bg']}; + color: {c['badge_overtime_text']}; + border-radius: 6px; +}} + +QLabel#badge_leave {{ + font-size: 9.5pt; + font-weight: bold; + padding: 4px 10px; + min-width: 110px; + qproperty-alignment: AlignCenter; + background: {c['badge_leave_bg']}; + color: {c['badge_leave_text']}; + border-radius: 6px; +}} + +QLabel#badge_total {{ + font-size: 9.5pt; + font-weight: bold; + padding: 4px 10px; + min-width: 110px; + qproperty-alignment: AlignCenter; + background: {c['badge_total_bg']}; + color: {c['badge_total_text']}; + border-radius: 6px; +}} + +QLabel#badge_balance {{ + font-size: 12pt; + font-weight: bold; + padding: 10px; + background: {c['bg_tertiary']}; + color: {c['text_primary']}; + border-radius: 6px; +}} + +QLabel#badge_success {{ + font-size: 10pt; + font-weight: bold; + padding: 8px; + background: {c['badge_total_bg']}; + color: {c['badge_total_text']}; + border-radius: 6px; +}} + +/* ════════════════════════════════════════ + 구분선 + ════════════════════════════════════════ */ + +QLabel#separator {{ + background: {c['border_subtle']}; + max-height: 1px; + min-height: 1px; +}} + +/* ════════════════════════════════════════ + 그룹 박스 (카드) + ════════════════════════════════════════ */ + +QGroupBox {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_subtle']}; + border-radius: 10px; + margin-top: 10px; + padding: 14px; + padding-top: 28px; + font-size: 9.5pt; + color: {c['text_primary']}; +}} + +QGroupBox::title {{ + subcontrol-origin: margin; + subcontrol-position: top left; + padding: 3px 10px; + margin-left: 8px; + font-weight: bold; + color: {c['text_secondary']}; + background: {c['bg_secondary']}; + border-radius: 4px; +}} + +/* ════════════════════════════════════════ + 버튼 + ════════════════════════════════════════ */ + +QPushButton {{ + background: {c['bg_tertiary']}; + color: {c['text_primary']}; + border: 1px solid {c['border_default']}; + border-radius: 6px; + padding: 7px 14px; + font-size: 9pt; +}} + +QPushButton:hover {{ + background: {c['border_default']}; +}} + +QPushButton:pressed {{ + background: {c['border_subtle']}; +}} + +QPushButton:disabled {{ + background: {c['bg_tertiary']}; + color: {c['text_tertiary']}; + border-color: {c['border_subtle']}; +}} + +QPushButton:checked {{ + background: {c['accent_primary']}; + color: {c['text_inverse']}; + border-color: {c['accent_primary']}; +}} + +/* 퇴근 버튼 (primary action) */ +QPushButton#clock_out_button {{ + background: {c['accent_success']}; + color: {c['text_inverse']}; + font-size: 11pt; + font-weight: bold; + padding: 8px; + border: none; + border-radius: 8px; +}} + +QPushButton#clock_out_button:hover {{ + background: {'#0EA572' if not is_dark else '#2BB885'}; +}} + +QPushButton#clock_out_button:pressed {{ + background: {'#0C8F63' if not is_dark else '#28A87A'}; +}} + +/* 주요 액션 버튼 */ +QPushButton#btn_primary {{ + background: {c['accent_primary']}; + color: {c['text_inverse']}; + border: none; + font-weight: bold; +}} + +QPushButton#btn_primary:hover {{ + background: {c['accent_primary']}DD; +}} + +QPushButton#btn_primary:pressed {{ + background: {c['accent_primary']}BB; +}} + +/* 위험 버튼 */ +QPushButton#btn_danger {{ + background: {c['accent_danger']}; + color: {c['text_inverse']}; + border: none; +}} + +QPushButton#btn_danger:hover {{ + background: {c['accent_danger']}DD; +}} + +QPushButton#btn_danger:pressed {{ + background: {c['accent_danger']}BB; +}} + +/* 성공 버튼 */ +QPushButton#btn_success {{ + background: {c['accent_success']}; + color: {c['text_inverse']}; + border: none; +}} + +QPushButton#btn_success:hover {{ + background: {c['accent_success']}DD; +}} + +QPushButton#btn_success:pressed {{ + background: {c['accent_success']}BB; +}} + +/* 작은 버튼 */ +QPushButton#btn_small {{ + font-size: 8.5pt; + padding: 5px 10px; +}} + +QPushButton#btn_small:hover {{ + background: {c['accent_primary']}20; +}} + +QPushButton#btn_small:pressed {{ + background: {c['accent_primary']}35; +}} + +/* ════════════════════════════════════════ + 입력 필드 + ════════════════════════════════════════ */ + +QLineEdit, QTextEdit, QComboBox {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_default']}; + border-radius: 6px; + padding: 6px 8px; + color: {c['text_primary']}; + font-size: 9.5pt; + min-height: 20px; +}} + +QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_default']}; + border-radius: 6px; + padding: 6px 28px 6px 8px; + color: {c['text_primary']}; + font-size: 9.5pt; + min-height: 20px; +}} + +QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{ + border: 2px solid {c['border_focus']}; + padding: 5px 7px; +}} + +QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{ + border: 2px solid {c['border_focus']}; + padding: 5px 27px 5px 7px; +}} + +/* 비활성 입력 필드 */ +QLineEdit:disabled, QTextEdit:disabled, QComboBox:disabled, +QSpinBox:disabled, QDoubleSpinBox:disabled, QDateEdit:disabled, QTimeEdit:disabled {{ + background: {c['bg_tertiary']}; + color: {c['text_tertiary']}; + border-color: {c['border_subtle']}; +}} + +QComboBox::drop-down {{ + subcontrol-origin: padding; + subcontrol-position: center right; + width: 20px; + border: none; + padding-right: 4px; +}} + +QComboBox::down-arrow {{ + image: url({down_arrow}); + width: 10px; + height: 8px; +}} + +QComboBox QAbstractItemView {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_default']}; + color: {c['text_primary']}; + selection-background-color: {c['accent_primary']}; + selection-color: {c['text_inverse']}; +}} + +QSpinBox::up-button, QSpinBox::down-button, +QDoubleSpinBox::up-button, QDoubleSpinBox::down-button, +QDateEdit::up-button, QDateEdit::down-button, +QTimeEdit::up-button, QTimeEdit::down-button {{ + background: {c['bg_tertiary']}; + border: 1px solid {c['border_subtle']}; + width: 22px; + subcontrol-origin: border; +}} + +QSpinBox::up-button, QDoubleSpinBox::up-button, +QDateEdit::up-button, QTimeEdit::up-button {{ + subcontrol-position: top right; + border-top-right-radius: 4px; +}} + +QSpinBox::down-button, QDoubleSpinBox::down-button, +QDateEdit::down-button, QTimeEdit::down-button {{ + subcontrol-position: bottom right; + border-bottom-right-radius: 4px; +}} + +QSpinBox::up-button:hover, QSpinBox::down-button:hover, +QDoubleSpinBox::up-button:hover, QDoubleSpinBox::down-button:hover, +QDateEdit::up-button:hover, QDateEdit::down-button:hover, +QTimeEdit::up-button:hover, QTimeEdit::down-button:hover {{ + background: {c['border_default']}; +}} + +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow, +QDateEdit::up-arrow, QTimeEdit::up-arrow {{ + image: url({up_arrow}); + width: 10px; + height: 8px; +}} + +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow, +QDateEdit::down-arrow, QTimeEdit::down-arrow {{ + image: url({down_arrow}); + width: 10px; + height: 8px; +}} + +/* ════════════════════════════════════════ + 체크박스 + ════════════════════════════════════════ */ + +QCheckBox {{ + spacing: 8px; + color: {c['text_primary']}; + font-size: 9pt; +}} + +QCheckBox::indicator {{ + width: 18px; + height: 18px; + border: 2px solid {c['border_default']}; + border-radius: 4px; + background: {c['bg_secondary']}; +}} + +QCheckBox::indicator:checked {{ + background: {c['accent_primary']}; + border-color: {c['accent_primary']}; + image: url({checkmark}); +}} + +QCheckBox::indicator:hover {{ + border-color: {c['accent_primary']}; +}} + +/* ════════════════════════════════════════ + 프로그레스 바 + ════════════════════════════════════════ */ + +QProgressBar {{ + border: none; + background: {c['progress_bg']}; + border-radius: 4px; + height: 8px; + 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; +}} + +/* ════════════════════════════════════════ + 테이블 + ════════════════════════════════════════ */ + +QTableWidget {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_subtle']}; + border-radius: 6px; + gridline-color: {c['border_subtle']}; + color: {c['text_primary']}; + font-size: 9pt; +}} + +QTableWidget::item {{ + padding: 6px 8px; +}} + +QTableWidget::item:selected {{ + background: {c['accent_primary']}30; + color: {c['text_primary']}; +}} + +QTableWidget::item:alternate {{ + background: {c['bg_tertiary']}; +}} + +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; +}} + +/* ════════════════════════════════════════ + 탭 위젯 + ════════════════════════════════════════ */ + +QTabWidget::pane {{ + border: 1px solid {c['border_subtle']}; + border-radius: 6px; + background: {c['bg_secondary']}; + top: -1px; +}} + +QTabBar::tab {{ + background: {c['bg_tertiary']}; + color: {c['text_secondary']}; + padding: 8px 20px; + border: 1px solid {c['border_subtle']}; + border-bottom: none; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + margin-right: 2px; + font-size: 10pt; +}} + +QTabBar::tab:selected {{ + background: {c['bg_secondary']}; + color: {c['accent_primary']}; + font-weight: bold; + border-bottom: 2px solid {c['accent_primary']}; +}} + +QTabBar::tab:hover:!selected {{ + background: {c['border_subtle']}; + color: {c['text_primary']}; +}} + +/* ════════════════════════════════════════ + 스크롤바 + ════════════════════════════════════════ */ + +QScrollBar:vertical {{ + background: {c['scrollbar_bg']}; + width: 10px; + border: none; + border-radius: 5px; +}} + +QScrollBar::handle:vertical {{ + background: {c['scrollbar_handle']}; + min-height: 30px; + border-radius: 5px; +}} + +QScrollBar::handle:vertical:hover {{ + background: {c['scrollbar_hover']}; +}} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0px; +}} + +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ + background: none; +}} + +QScrollBar:horizontal {{ + background: {c['scrollbar_bg']}; + height: 10px; + border: none; + border-radius: 5px; +}} + +QScrollBar::handle:horizontal {{ + background: {c['scrollbar_handle']}; + min-width: 30px; + border-radius: 5px; +}} + +QScrollBar::handle:horizontal:hover {{ + background: {c['scrollbar_hover']}; +}} + +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ + width: 0px; +}} + +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ + background: none; +}} + +QScrollArea {{ + border: none; + background: transparent; +}} + +QWidget#fixed_bottom {{ + background: {c['bg_primary']}; + border-top: 1px solid {c['border_subtle']}; +}} + +QScrollArea > QWidget > QWidget#scroll_content {{ + background: transparent; +}} + +/* ════════════════════════════════════════ + 캘린더 + ════════════════════════════════════════ */ + +QCalendarWidget {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_subtle']}; + border-radius: 6px; + font-size: 10pt; +}} + +QCalendarWidget QAbstractItemView {{ + selection-background-color: {c['accent_primary']}; + selection-color: {c['text_inverse']}; + background: {c['bg_secondary']}; + color: {c['text_primary']}; + alternate-background-color: {c['bg_secondary']}; +}} + +/* 요일 헤더 행 */ +QCalendarWidget QAbstractItemView:enabled {{ + color: {c['text_primary']}; +}} + +QCalendarWidget QHeaderView::section {{ + background: {c['bg_tertiary']}; + color: {c['text_secondary']}; + font-weight: bold; + border: none; + border-bottom: 1px solid {c['border_subtle']}; + padding: 4px; +}} + +QCalendarWidget QWidget#qt_calendar_navigationbar {{ + background: {c['bg_tertiary']}; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + padding: 4px; +}} + +QCalendarWidget QToolButton {{ + color: {c['text_primary']}; + background: transparent; + font-size: 11pt; + font-weight: bold; + padding: 6px 12px; + border-radius: 6px; + min-width: 30px; + min-height: 24px; +}} + +QCalendarWidget QToolButton:hover {{ + background: {c['accent_primary']}25; + color: {c['accent_primary']}; +}} + +QCalendarWidget QToolButton#qt_calendar_prevmonth, +QCalendarWidget QToolButton#qt_calendar_nextmonth {{ + qproperty-icon: none; + min-width: 36px; + min-height: 28px; + font-size: 14pt; + font-weight: bold; + color: {c['accent_primary']}; + background: {c['bg_secondary']}; + border: 1px solid {c['border_subtle']}; + border-radius: 6px; + padding: 2px 8px; +}} + +QCalendarWidget QToolButton#qt_calendar_prevmonth {{ + qproperty-text: "<"; +}} + +QCalendarWidget QToolButton#qt_calendar_nextmonth {{ + qproperty-text: ">"; +}} + +QCalendarWidget QToolButton#qt_calendar_prevmonth:hover, +QCalendarWidget QToolButton#qt_calendar_nextmonth:hover {{ + background: {c['accent_primary']}; + color: {c['text_inverse']}; + border-color: {c['accent_primary']}; +}} + +/* ════════════════════════════════════════ + 메시지 박스 + ════════════════════════════════════════ */ + +QMessageBox, QInputDialog {{ + background: {c['bg_primary']}; +}} + +QMessageBox QLabel, QInputDialog QLabel {{ + color: {c['text_primary']}; + font-size: 9.5pt; +}} + +QDialogButtonBox QPushButton {{ + min-width: 70px; +}} + +/* ════════════════════════════════════════ + 툴팁 + ════════════════════════════════════════ */ + +QToolTip {{ + background: {c['bg_secondary']}; + color: {c['text_primary']}; + border: 1px solid {c['border_default']}; + border-radius: 4px; + padding: 4px 8px; + font-size: 9pt; +}} + +/* ════════════════════════════════════════ + 메뉴 + ════════════════════════════════════════ */ + +QMenu {{ + background: {c['bg_secondary']}; + border: 1px solid {c['border_default']}; + border-radius: 6px; + padding: 4px; + color: {c['text_primary']}; +}} + +QMenu::item {{ + padding: 6px 24px; + border-radius: 4px; +}} + +QMenu::item:selected {{ + background: {c['accent_primary']}; + color: {c['text_inverse']}; +}} +""" + + +# ─── 편의 함수 ─────────────────────────────────────────────── + +def get_light_theme() -> str: + return generate_theme(LIGHT_COLORS, is_dark=False) + + +def get_dark_theme() -> str: + return generate_theme(DARK_COLORS, is_dark=True) + + +def get_theme(theme_name: str = 'light') -> str: + ThemeColors.set_theme(theme_name) + if theme_name == 'dark': + return get_dark_theme() + return get_light_theme() + + +def apply_dark_titlebar(widget, dark: bool = None): + """Windows 타이틀바 다크모드 적용 (다이얼로그용)""" + if dark is None: + dark = ThemeColors.current is DARK_COLORS + try: + import ctypes + hwnd = int(widget.winId()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + value = ctypes.c_int(1 if dark else 0) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), ctypes.sizeof(value) + ) + except Exception: + pass + + diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..92e17bc --- /dev/null +++ b/updater.py @@ -0,0 +1,148 @@ +""" +독립 자가 업데이터. + +Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약을 +헬퍼 프로세스로 우회. 메인 앱이 종료된 직후 파일 교체 + 재실행. + +사용법 (메인 앱이 호출): + updater.exe --pid <메인_PID> --new --target + +흐름: +1. 메인 앱 종료 대기 (PID 폴링, 최대 30초) +2. target_exe를 .bak으로 백업 +3. new_exe → target_exe 이동 +4. target_exe 재실행 + 업데이터 자가 종료 +실패 시 .bak 복원 +""" +from __future__ import annotations +import argparse +import os +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +def is_pid_running(pid: int) -> bool: + """Windows에서 PID 실행 중인지 확인.""" + if sys.platform != 'win32': + try: + os.kill(pid, 0) + return True + except OSError: + return False + try: + import ctypes + from ctypes import wintypes + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + kernel32 = ctypes.windll.kernel32 + h = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not h: + return False + try: + exit_code = wintypes.DWORD() + if kernel32.GetExitCodeProcess(h, ctypes.byref(exit_code)): + return exit_code.value == STILL_ACTIVE + return False + finally: + kernel32.CloseHandle(h) + except Exception: + return False + + +def wait_for_exit(pid: int, timeout_sec: int = 30) -> bool: + """PID가 종료될 때까지 폴링. timeout 시 False.""" + deadline = time.time() + timeout_sec + while time.time() < deadline: + if not is_pid_running(pid): + return True + time.sleep(0.5) + return False + + +def replace_file(new_path: Path, target_path: Path) -> Path | None: + """target을 .bak으로 백업하고 new를 target 위치로 이동. + + Returns: + 백업 파일 경로 (롤백용). 실패 시 None. + """ + backup = target_path.with_suffix(target_path.suffix + '.bak') + try: + if backup.exists(): + backup.unlink() + if target_path.exists(): + shutil.move(str(target_path), str(backup)) + shutil.move(str(new_path), str(target_path)) + return backup + except OSError as e: + print(f"[updater] replace failed: {e}", file=sys.stderr) + # 롤백 시도 + if backup.exists() and not target_path.exists(): + try: + shutil.move(str(backup), str(target_path)) + except OSError: + pass + return None + + +def launch(exe_path: Path) -> bool: + """새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기.""" + try: + if sys.platform == 'win32': + DETACHED_PROCESS = 0x00000008 + subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True) + else: + subprocess.Popen([str(exe_path)], close_fds=True) + return True + except OSError as e: + print(f"[updater] launch failed: {e}", file=sys.stderr) + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description="Clock-out Calculator updater") + parser.add_argument('--pid', type=int, required=True, help='메인 앱 PID') + parser.add_argument('--new', required=True, help='새 .exe 경로') + parser.add_argument('--target', required=True, help='교체 대상 .exe 경로') + parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함') + args = parser.parse_args() + + 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) + return 2 + + if not wait_for_exit(args.pid, timeout_sec=30): + print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr) + return 3 + + # Windows 파일 핸들 해제 시간 여유 + time.sleep(0.5) + + backup = replace_file(new_exe, target_exe) + if backup is None: + return 4 + + if args.no_launch: + return 0 + + if not launch(target_exe): + # 시작 실패 시 롤백 + try: + target_exe.unlink() + shutil.move(str(backup), str(target_exe)) + launch(target_exe) + except OSError: + pass + return 5 + + # 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink. + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/updater.spec b/updater.spec new file mode 100644 index 0000000..9984ef7 --- /dev/null +++ b/updater.spec @@ -0,0 +1,42 @@ +# -*- mode: python ; coding: utf-8 -*- +# 작은 자가 업데이터 — 표준 라이브러리만 사용 (의존성 0) + + +a = Analysis( + ['updater.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + 'PyQt5', 'matplotlib', 'numpy', 'pandas', 'plyer', + 'win32evtlog', 'win32evtlogutil', 'holidays', + ], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='updater', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지 + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..0abd32f --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +# utils 모듈 diff --git a/utils/backup.py b/utils/backup.py new file mode 100644 index 0000000..702ed91 --- /dev/null +++ b/utils/backup.py @@ -0,0 +1,75 @@ +""" +DB 자동 백업 유틸리티. + +전략: +- 앱 시작 시 1일 1회만 백업 (last_backup_date 설정 키로 가드) +- 사용자 폴더(`~/.clockout_backups/`)에 회전 보관 (기본 7개) +- SQLite의 안전한 백업 API(sqlite3.Connection.backup) 사용 — 락 안전 +""" +from __future__ import annotations +import os +import sqlite3 +from datetime import date +from pathlib import Path +from typing import Optional + +from core.settings_keys import LAST_BACKUP_DATE + +DEFAULT_BACKUP_DIR = Path.home() / '.clockout_backups' +DEFAULT_KEEP = 7 + + +def backup_db_if_needed(db, source_path: str = "database.db", + backup_dir: Optional[Path] = None, + keep: int = DEFAULT_KEEP) -> Optional[Path]: + """오늘 첫 실행이면 백업 1개 만들고 오래된 것 회전. + + Args: + db: Database 인스턴스 (set_setting/get_setting 사용) + source_path: 원본 DB 파일 경로 + backup_dir: 백업 저장 디렉토리 (기본 ~/.clockout_backups) + keep: 보관할 백업 개수 + + Returns: + 생성된 백업 파일 경로, 또는 이미 오늘 백업했으면 None + """ + today = date.today().isoformat() + if db.get_setting(LAST_BACKUP_DATE, '') == today: + return None + + src = Path(source_path) + if not src.exists(): + return None + + target_dir = backup_dir or DEFAULT_BACKUP_DIR + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / f"database-{today}.db" + + # SQLite 백업 API: WAL/락 환경에서도 안전한 일관성 있는 복사 + src_conn = sqlite3.connect(str(src)) + try: + dest_conn = sqlite3.connect(str(target)) + try: + src_conn.backup(dest_conn) + finally: + dest_conn.close() + finally: + src_conn.close() + + db.set_setting(LAST_BACKUP_DATE, today) + _rotate(target_dir, keep) + return target + + +def _rotate(directory: Path, keep: int) -> None: + """오래된 백업 제거 — 최신 keep개만 유지""" + files = sorted( + (p for p in directory.glob('database-*.db') if p.is_file()), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for old in files[keep:]: + try: + old.unlink() + except OSError: + pass diff --git a/utils/csv_exporter.py b/utils/csv_exporter.py new file mode 100644 index 0000000..07acafa --- /dev/null +++ b/utils/csv_exporter.py @@ -0,0 +1,157 @@ +""" +CSV 내보내기 유틸리티 +""" +import csv +from datetime import datetime +from typing import List, Dict +import os + + +class CSVExporter: + """CSV 내보내기 클래스""" + + @staticmethod + def export_work_records(records: List[Dict], filename: str = None) -> str: + """ + 근무 기록을 CSV로 내보내기 + Args: + records: 근무 기록 리스트 + filename: 저장할 파일명 (None이면 자동 생성) + Returns: + str: 저장된 파일 경로 + """ + if filename is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"work_records_{timestamp}.csv" + + # CSV 파일 작성 + with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile: + fieldnames = [ + '날짜', '출근시간', '퇴근시간', '점심시간', + '총근무시간', '연장근무(분)', '적립(분)', '메모' + ] + + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + + for record in records: + row = { + '날짜': record.get('date', ''), + '출근시간': record.get('clock_in', ''), + '퇴근시간': record.get('clock_out', '미기록'), + '점심시간': '사용' if record.get('lunch_break') else '미사용', + '총근무시간': f"{record.get('total_hours', 0):.1f}시간", + '연장근무(분)': record.get('overtime_minutes', 0), + '적립(분)': record.get('overtime_earned', 0), + '메모': record.get('memo', '') + } + writer.writerow(row) + + return filename + + @staticmethod + def export_overtime_summary(db, filename: str = None) -> str: + """ + 연장근무 요약 내보내기 + Args: + db: Database 인스턴스 + filename: 저장할 파일명 + Returns: + str: 저장된 파일 경로 + """ + if filename is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"overtime_summary_{timestamp}.csv" + + # 연장근무 내역 가져오기 + overtime_history = db.get_overtime_history(limit=100) + + with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile: + fieldnames = ['유형', '날짜', '시간(분)', '출근', '퇴근'] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + + for item in overtime_history: + row = { + '유형': '적립' if item['type'] == 'earned' else '사용', + '날짜': item.get('date', ''), + '시간(분)': item.get('minutes', 0), + '출근': item.get('clock_in', ''), + '퇴근': item.get('clock_out', '') + } + writer.writerow(row) + + return filename + + @staticmethod + def export_monthly_summary(db, year: int, month: int, filename: str = None) -> str: + """ + 월간 요약 내보내기 + Args: + db: Database 인스턴스 + year: 년도 + month: 월 + filename: 저장할 파일명 + Returns: + str: 저장된 파일 경로 + """ + if filename is None: + filename = f"monthly_summary_{year}{month:02d}.csv" + + stats = db.get_monthly_stats(year, month) + + with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile: + writer = csv.writer(csvfile) + + # 요약 정보 + writer.writerow([f"{year}년 {month}월 근무 요약"]) + writer.writerow([]) + writer.writerow(['항목', '값']) + writer.writerow(['총 근무시간', f"{stats['total_hours']:.1f}시간"]) + writer.writerow(['근무일수', f"{stats['work_days']}일"]) + + if stats['work_days'] > 0: + avg = stats['total_hours'] / stats['work_days'] + writer.writerow(['평균 근무시간', f"{avg:.1f}시간"]) + + overtime_hours = stats['total_overtime_minutes'] // 60 + overtime_mins = stats['total_overtime_minutes'] % 60 + writer.writerow(['총 연장근무', f"{overtime_hours}시간 {overtime_mins}분"]) + + writer.writerow([]) + writer.writerow([]) + + # 상세 기록 + writer.writerow(['날짜', '출근', '퇴근', '총근무시간', '연장근무']) + + for record in stats['records']: + overtime_min = record.get('overtime_earned', 0) + overtime_h = overtime_min // 60 + overtime_m = overtime_min % 60 + overtime_str = f"{overtime_h}시간 {overtime_m}분" if overtime_min > 0 else "-" + + writer.writerow([ + record.get('date', ''), + record.get('clock_in', ''), + record.get('clock_out', '미기록'), + f"{record.get('total_hours', 0):.1f}시간", + overtime_str + ]) + + return filename + + +# 테스트 코드 +if __name__ == "__main__": + import sys + import os + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + from core.database import Database + + db = Database() + + # 테스트: 이번 달 기록 내보내기 + now = datetime.now() + filename = CSVExporter.export_monthly_summary(db, now.year, now.month) + print(f"Exported to: {filename}") diff --git a/utils/debug_log.py b/utils/debug_log.py new file mode 100644 index 0000000..2ce04c5 --- /dev/null +++ b/utils/debug_log.py @@ -0,0 +1,33 @@ +""" +환경변수 게이트 디버그 로깅. + +`CLOCKOUT_DEBUG=1` 환경변수가 설정된 경우에만 stderr/파일로 출력. +프로덕션 빌드(PyInstaller)에서 항상 켜두지 않도록 게이트 처리. +""" +import os +import sys +from datetime import datetime +from pathlib import Path + +_DEBUG_ENV = os.environ.get('CLOCKOUT_DEBUG', '').strip() in ('1', 'true', 'yes') +_LOG_DIR = Path(os.environ.get('CLOCKOUT_DEBUG_DIR') or Path.home() / '.clockout_logs') +_LOG_FILE = _LOG_DIR / 'debug.log' + + +def is_debug() -> bool: + return _DEBUG_ENV + + +def dlog(*args, file: str = None) -> None: + """디버그 모드에서만 stderr + 파일로 기록. 비활성화 시 no-op.""" + if not _DEBUG_ENV: + return + msg = f"[{datetime.now().strftime('%H:%M:%S')}] " + ' '.join(str(a) for a in args) + print(msg, file=sys.stderr) + try: + target = Path(file) if file else _LOG_FILE + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, 'a', encoding='utf-8') as f: + f.write(msg + '\n') + except OSError: + pass diff --git a/utils/lock_detector.py b/utils/lock_detector.py new file mode 100644 index 0000000..992163f --- /dev/null +++ b/utils/lock_detector.py @@ -0,0 +1,45 @@ +""" +Windows 화면 잠금 감지. + +`OpenInputDesktop`/`GetUserObjectInformation`을 사용해 현재 활성 데스크톱이 +"Winlogon"(잠금/사용자 전환 화면)인지 확인. 5초 간격 polling으로 충분 — +노이즈 적고 가벼운 방식. +""" +from __future__ import annotations + +try: + import ctypes + from ctypes import wintypes + _WIN_AVAILABLE = True +except ImportError: + _WIN_AVAILABLE = False + + +def is_screen_locked() -> bool: + """현재 화면이 잠금 상태(또는 사용자 전환 중)이면 True. + + Windows 외 플랫폼이거나 권한 부족 시 False (안전한 기본값). + """ + if not _WIN_AVAILABLE: + return False + + user32 = ctypes.windll.user32 + DESKTOP_SWITCHDESKTOP = 0x0100 + UOI_NAME = 2 + + # 현재 입력 받는 데스크탑 핸들 + handle = user32.OpenInputDesktop(0, False, DESKTOP_SWITCHDESKTOP) + if not handle: + return False + try: + buf = ctypes.create_unicode_buffer(256) + needed = wintypes.DWORD(0) + ok = user32.GetUserObjectInformationW( + handle, UOI_NAME, buf, ctypes.sizeof(buf), ctypes.byref(needed) + ) + if not ok: + return False + # 잠금/사용자 전환 시 "Winlogon", 보통은 "Default" + return buf.value.lower() != 'default' + finally: + user32.CloseDesktop(handle) diff --git a/utils/resource_manager.py b/utils/resource_manager.py new file mode 100644 index 0000000..b9f6ac2 --- /dev/null +++ b/utils/resource_manager.py @@ -0,0 +1,161 @@ +""" +리소스 매니저 +아이콘, 사운드 등의 리소스 관리 +""" +import os +from pathlib import Path +from typing import Optional +from PyQt5.QtGui import QIcon, QPixmap +from PyQt5.QtCore import QSize + + +class ResourceManager: + """리소스 관리 클래스""" + + def __init__(self): + # 프로젝트 루트 경로 + self.root_path = Path(__file__).parent.parent + self.resources_path = self.root_path / "resources" + self.icons_path = self.resources_path / "icons" + self.sounds_path = self.resources_path / "sounds" + + # 경로 생성 + self.icons_path.mkdir(parents=True, exist_ok=True) + self.sounds_path.mkdir(parents=True, exist_ok=True) + + def get_icon_path(self, icon_name: str) -> Optional[Path]: + """ + 아이콘 파일 경로 반환 + Args: + icon_name: 아이콘 파일명 (확장자 포함) + Returns: + Path: 아이콘 파일 경로 + """ + icon_path = self.icons_path / icon_name + + if icon_path.exists(): + return icon_path + + return None + + def get_icon(self, icon_name: str, size: int = 64) -> Optional[QIcon]: + """ + QIcon 객체 반환 + Args: + icon_name: 아이콘 파일명 + size: 아이콘 크기 (픽셀) + Returns: + QIcon: 아이콘 객체, 없으면 None + """ + icon_path = self.get_icon_path(icon_name) + + if icon_path: + pixmap = QPixmap(str(icon_path)) + scaled_pixmap = pixmap.scaled( + QSize(size, size), + aspectRatioMode=1, # KeepAspectRatio + transformMode=1 # SmoothTransformation + ) + return QIcon(scaled_pixmap) + + return None + + def get_sound_path(self, sound_name: str) -> Optional[Path]: + """ + 사운드 파일 경로 반환 + Args: + sound_name: 사운드 파일명 (확장자 포함) + Returns: + Path: 사운드 파일 경로 + """ + sound_path = self.sounds_path / sound_name + + if sound_path.exists(): + return sound_path + + return None + + def play_sound(self, sound_name: str): + """ + 사운드 재생 + Args: + sound_name: 사운드 파일명 + """ + sound_path = self.get_sound_path(sound_name) + + if sound_path: + try: + # Windows 기본 사운드 재생 + import winsound + winsound.PlaySound( + str(sound_path), + winsound.SND_FILENAME | winsound.SND_ASYNC + ) + except Exception as e: + print(f"사운드 재생 실패: {e}") + + def has_resources(self) -> bool: + """리소스가 존재하는지 확인""" + icon_count = len(list(self.icons_path.glob("*.png"))) + sound_count = len(list(self.sounds_path.glob("*.wav"))) + + return icon_count > 0 or sound_count > 0 + + def list_resources(self): + """설치된 리소스 목록 출력""" + print("=== 설치된 리소스 ===\n") + + print("아이콘:") + icons = list(self.icons_path.glob("*.png")) + list(self.icons_path.glob("*.ico")) + if icons: + for icon in icons: + print(f" - {icon.name}") + else: + print(" (없음)") + + print("\n사운드:") + sounds = list(self.sounds_path.glob("*.wav")) + list(self.sounds_path.glob("*.mp3")) + if sounds: + for sound in sounds: + print(f" - {sound.name}") + else: + print(" (없음)") + + if not icons and not sounds: + print("\n리소스가 없습니다!") + print("resources/resource_links.md 파일을 참고하여 리소스를 다운로드하세요.") + + +# 기본 이모지 아이콘 생성 (리소스 없을 때 대체용) +def create_emoji_icon(emoji: str, size: int = 64) -> QIcon: + """ + 이모지를 아이콘으로 변환 + Args: + emoji: 이모지 문자 + size: 아이콘 크기 + Returns: + QIcon: 아이콘 객체 + """ + from PyQt5.QtGui import QPixmap, QPainter, QFont + from PyQt5.QtCore import Qt + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + font = QFont("Segoe UI Emoji", int(size * 0.7)) + painter.setFont(font) + painter.drawText(pixmap.rect(), Qt.AlignCenter, emoji) + painter.end() + + return QIcon(pixmap) + + +# 테스트 코드 +if __name__ == "__main__": + manager = ResourceManager() + manager.list_resources() + + print("\n=== 경로 정보 ===") + print(f"아이콘 폴더: {manager.icons_path}") + print(f"사운드 폴더: {manager.sounds_path}") diff --git a/utils/system_tray.py b/utils/system_tray.py new file mode 100644 index 0000000..8451979 --- /dev/null +++ b/utils/system_tray.py @@ -0,0 +1,190 @@ +""" +시스템 트레이 아이콘 +""" +from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction +from PyQt5.QtGui import QIcon, QPixmap, QPainter, QFont, QColor +from PyQt5.QtCore import Qt, QSize + +from core.i18n import tr + + +class SystemTrayIcon(QSystemTrayIcon): + """시스템 트레이 아이콘 클래스""" + + def __init__(self, parent=None): + # 기본 아이콘 생성 + icon = self.create_icon("⏰") + super().__init__(icon, parent) + + self.parent_window = parent + self.setup_menu() + + # 클릭 이벤트 + self.activated.connect(self.on_tray_activated) + + def create_icon(self, text: str, color: QColor = None) -> QIcon: + """ + 텍스트로 아이콘 생성 + Args: + text: 아이콘에 표시할 텍스트 (이모지 또는 시간) + color: 배경색 + Returns: + QIcon: 생성된 아이콘 + """ + pixmap = QPixmap(64, 64) + + if color: + pixmap.fill(color) + else: + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + + # 텍스트 그리기 + if len(text) <= 2: + # 이모지 + font = QFont("Segoe UI Emoji", 40) + else: + # 시간 표시 + font = QFont("Consolas", 12, QFont.Bold) + painter.fillRect(pixmap.rect(), QColor(255, 255, 255)) + + painter.setFont(font) + painter.setPen(QColor(0, 0, 0)) + painter.drawText(pixmap.rect(), Qt.AlignCenter, text) + painter.end() + + 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) + + mini_action = QAction(tr('tray.mini_widget'), self) + mini_action.triggered.connect(self._open_mini_widget) + menu.addAction(mini_action) + + 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) + + 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) + + 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) + + menu.addSeparator() + + quit_action = QAction(tr('tray.quit'), self) + quit_action.triggered.connect(self.quit_app) + menu.addAction(quit_action) + + self.setContextMenu(menu) + + def _call_parent(self, method_name: str): + if self.parent_window and hasattr(self.parent_window, method_name): + getattr(self.parent_window, method_name)() + + def _toggle_lunch(self): + if self.parent_window and hasattr(self.parent_window, 'lunch_button'): + self.parent_window.lunch_button.click() + + def _break_out(self): + if self.parent_window and hasattr(self.parent_window, 'break_out'): + self.parent_window.break_out() + + def _break_in(self): + if self.parent_window and hasattr(self.parent_window, 'break_in'): + self.parent_window.break_in() + + def _open_mini_widget(self): + self._call_parent('show_mini_widget') + + def on_tray_activated(self, reason): + """트레이 아이콘 클릭 시""" + if reason == QSystemTrayIcon.DoubleClick: + self.show_window() + + def show_window(self): + """메인 윈도우 표시""" + if self.parent_window: + self.parent_window.show() + self.parent_window.activateWindow() + + def quick_clock_out(self): + """빠른 퇴근""" + if self.parent_window and hasattr(self.parent_window, 'clock_out'): + self.parent_window.clock_out() + + def quit_app(self): + """앱 종료""" + from PyQt5.QtWidgets import QApplication + QApplication.quit() + + def update_time_display(self, remaining_str: str): + """ + 남은 시간 표시 업데이트 + Args: + remaining_str: "HH:MM" 형식의 시간 + """ + # 간단한 시간 표시로 아이콘 업데이트 + if remaining_str.startswith('+'): + icon = self.create_icon("🔥") + self.setToolTip(tr('tray.tooltip_overtime', time=remaining_str)) + elif remaining_str.startswith('-'): + icon = self.create_icon("⏰") + self.setToolTip(tr('tray.tooltip_remaining', time='--')) + else: + parts = remaining_str.split(':') + display_time = f"{parts[0]}:{parts[1]}" if len(parts) >= 2 else remaining_str + icon = self.create_icon("⏰") + self.setToolTip(tr('tray.tooltip_remaining', time=display_time)) + + self.setIcon(icon) + + +# 테스트 코드 +if __name__ == "__main__": + from PyQt5.QtWidgets import QApplication, QMainWindow + import sys + + app = QApplication(sys.argv) + + window = QMainWindow() + window.setWindowTitle("Main Window") + + tray = SystemTrayIcon(window) + tray.show() + + window.show() + + sys.exit(app.exec_()) diff --git a/utils/time_format.py b/utils/time_format.py new file mode 100644 index 0000000..5748ab1 --- /dev/null +++ b/utils/time_format.py @@ -0,0 +1,33 @@ +""" +시간 표시 헬퍼. + +여러 모듈에서 `f"{h}시간 {m}분"` 패턴을 중복하던 것을 단일 함수로. +""" +from __future__ import annotations + + +def format_hours_minutes(total_minutes: int, *, omit_zero_minutes: bool = False) -> str: + """분을 "X시간 Y분" 형식으로. + + Args: + total_minutes: 총 분 (음수 가능 — 절댓값 표시) + omit_zero_minutes: True면 "X시간"처럼 0분 생략 + + Examples: + >>> format_hours_minutes(90) + '1시간 30분' + >>> format_hours_minutes(60, omit_zero_minutes=True) + '1시간' + >>> format_hours_minutes(45) + '0시간 45분' + >>> format_hours_minutes(45, omit_zero_minutes=True) + '45분' + """ + minutes = abs(int(total_minutes)) + h, m = divmod(minutes, 60) + if omit_zero_minutes: + if h == 0: + return f"{m}분" + if m == 0: + return f"{h}시간" + return f"{h}시간 {m}분" diff --git a/utils/updater_client.py b/utils/updater_client.py new file mode 100644 index 0000000..baaf8bf --- /dev/null +++ b/utils/updater_client.py @@ -0,0 +1,207 @@ +""" +업데이트 클라이언트 — Gitea/GitHub Releases 호환. + +자체 호스팅 Gitea 인스턴스 사용. Gitea API가 GitHub과 호환되어 endpoint URL만 +바꾸면 동일 로직 동작. + +흐름: +1. `check_for_update()`: Releases API → 최신 태그 조회 → 현재 버전과 비교 +2. `download_update(asset_url)`: 새 .exe를 임시 폴더에 다운로드 +3. `apply_update(new_exe)`: updater.exe 실행 + 메인 앱 종료 + +환경변수로 URL 오버라이드 가능 (테스트/마이그레이션 용도): +- CLOCKOUT_RELEASES_API: 전체 API endpoint URL +- CLOCKOUT_ASSET_NAME: 다운로드할 자산 파일명 (기본 main.exe) +""" +from __future__ import annotations +import json +import os +import subprocess +import sys +import urllib.request +import urllib.error +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +# 자체 호스팅 Gitea 인스턴스 (GitHub 호환 API) +GIT_HOST = 'https://kindnick-git.duckdns.org' +GIT_OWNER = 'kindnick' +GIT_REPO = 'Clock_out_Time_Calculator' + +# Gitea API endpoint: /api/v1/repos/{owner}/{repo}/releases/latest +# (GitHub의 경우: https://api.github.com/repos/{owner}/{repo}/releases/latest) +DEFAULT_RELEASES_API = f'{GIT_HOST}/api/v1/repos/{GIT_OWNER}/{GIT_REPO}/releases/latest' +RELEASES_API = os.environ.get('CLOCKOUT_RELEASES_API', DEFAULT_RELEASES_API) + +# 다운로드할 .exe 자산 이름 (Releases 첨부 파일명과 일치해야 함) +ASSET_NAME = os.environ.get('CLOCKOUT_ASSET_NAME', 'main.exe') + +USER_AGENT = 'ClockOutCalculator-Updater/1.0' + + +@dataclass +class ReleaseInfo: + version: str # 'v2.1.0' or '2.1.0' + asset_url: str # main.exe 다운로드 URL + notes: str # 릴리스 노트 (markdown) + published_at: str + + @property + def version_clean(self) -> str: + """'v2.1.0' → '2.1.0'""" + return self.version.lstrip('vV') + + +def _parse_version(s: str) -> tuple: + """semver-ish 파싱. 'v2.1.0' / '2.1' → (2,1,0). 비교용.""" + s = s.lstrip('vV').strip() + parts = s.split('.') + out = [] + for p in parts: + # 'rc1' 등 접미사 제거 + digits = '' + for c in p: + if c.isdigit(): + digits += c + else: + break + out.append(int(digits) if digits else 0) + while len(out) < 3: + out.append(0) + return tuple(out[:3]) + + +def is_newer(remote: str, local: str) -> bool: + """remote가 local보다 최신이면 True.""" + return _parse_version(remote) > _parse_version(local) + + +def check_for_update(current_version: str, timeout: int = 5) -> Optional[ReleaseInfo]: + """GitHub Releases API 조회. 새 버전 있으면 ReleaseInfo, 없으면 None. + + 네트워크 오류 시 None 반환 (앱 시작을 막지 않음). + """ + try: + req = urllib.request.Request(RELEASES_API, headers={'User-Agent': USER_AGENT}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read()) + except (urllib.error.URLError, json.JSONDecodeError, TimeoutError): + return None + + tag = data.get('tag_name', '') + if not tag or not is_newer(tag, current_version): + return None + + asset_url = None + for asset in data.get('assets', []): + if asset.get('name') == ASSET_NAME: + asset_url = asset.get('browser_download_url') + break + + if not asset_url: + return None + + return ReleaseInfo( + version=tag, + asset_url=asset_url, + notes=data.get('body', ''), + published_at=data.get('published_at', ''), + ) + + +def download_update(asset_url: str, dest_dir: Optional[Path] = None, + progress_cb=None) -> Optional[Path]: + """새 .exe를 임시 폴더에 다운로드. + + Args: + asset_url: GitHub Releases asset URL + dest_dir: 저장 위치 (기본: 메인 .exe 옆에 main_new.exe) + progress_cb: callable(downloaded_bytes, total_bytes) — 진행률 콜백 + + Returns: + 다운로드된 파일 경로, 실패 시 None. + """ + if dest_dir is None: + dest_dir = _exe_dir() + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / 'main_new.exe' + if dest.exists(): + try: + dest.unlink() + except OSError: + return None + + try: + req = urllib.request.Request(asset_url, headers={'User-Agent': USER_AGENT}) + with urllib.request.urlopen(req, timeout=30) as resp: + total = int(resp.headers.get('Content-Length', 0)) + downloaded = 0 + chunk_size = 64 * 1024 + with open(dest, 'wb') as f: + while True: + chunk = resp.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if progress_cb: + progress_cb(downloaded, total) + return dest + except (urllib.error.URLError, OSError, TimeoutError): + if dest.exists(): + try: + dest.unlink() + except OSError: + pass + return None + + +def apply_update(new_exe: Path) -> bool: + """updater.exe를 실행하여 파일 교체 + 재시작 트리거. + + 호출 직후 메인 앱은 종료되어야 함 (Qt: QApplication.quit()). + """ + target_exe = _current_exe() + updater_exe = _find_updater() + if not updater_exe or not target_exe: + return False + + pid = os.getpid() + try: + DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0 + creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0 + subprocess.Popen( + [str(updater_exe), + '--pid', str(pid), + '--new', str(new_exe), + '--target', str(target_exe)], + creationflags=creationflags, + close_fds=True, + ) + return True + except OSError: + return False + + +def _exe_dir() -> Path: + """현재 .exe가 있는 폴더 (PyInstaller frozen) 또는 main.py 위치.""" + if getattr(sys, 'frozen', False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +def _current_exe() -> Optional[Path]: + """현재 실행 중인 메인 .exe. 개발 환경(.py)에선 None.""" + if getattr(sys, 'frozen', False): + return Path(sys.executable) + return None + + +def _find_updater() -> Optional[Path]: + """updater.exe 찾기. 메인 .exe와 같은 폴더에 있어야 함.""" + candidate = _exe_dir() / 'updater.exe' + if candidate.exists(): + return candidate + return None