핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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) <noreply@anthropic.com>
This commit is contained in:
commit
bedbb1e9ec
38
.gitea/workflows/ci.yml
Normal file
38
.gitea/workflows/ci.yml
Normal file
@ -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
|
||||
84
.gitea/workflows/release.yml
Normal file
84
.gitea/workflows/release.yml
Normal file
@ -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'
|
||||
# }
|
||||
76
.github/workflows/ci.yml
vendored
Normal file
76
.github/workflows/ci.yml
vendored
Normal file
@ -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
|
||||
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal file
@ -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
|
||||
BIN
3d-alarm.ico
Normal file
BIN
3d-alarm.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
3d-alarm.png
Normal file
BIN
3d-alarm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
67
AGENTS.md
Normal file
67
AGENTS.md
Normal file
@ -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')`.
|
||||
69
CHANGELOG.md
Normal file
69
CHANGELOG.md
Normal file
@ -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분 단위 연장근무 적립 / 캘린더 / 통계
|
||||
120
CLAUDE.md
Normal file
120
CLAUDE.md
Normal file
@ -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.
|
||||
160
INSTALL.md
Normal file
160
INSTALL.md
Normal file
@ -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)를 참고하여 프로그램을 사용하세요!
|
||||
191
README.md
Normal file
191
README.md
Normal file
@ -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
|
||||
- 라이선스: 개인 및 상업적 사용 가능
|
||||
164
_gui_smoke_test.py
Normal file
164
_gui_smoke_test.py
Normal file
@ -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)
|
||||
157
_i18n_gui_test.py
Normal file
157
_i18n_gui_test.py
Normal file
@ -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)
|
||||
541
_integration_test.py
Normal file
541
_integration_test.py
Normal file
@ -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())
|
||||
25
check_db.py
Normal file
25
check_db.py
Normal file
@ -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()
|
||||
BIN
clock_icon.ico
Normal file
BIN
clock_icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
1
core/__init__.py
Normal file
1
core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# core 모듈
|
||||
1603
core/database.py
Normal file
1603
core/database.py
Normal file
File diff suppressed because it is too large
Load Diff
359
core/event_monitor.py
Normal file
359
core/event_monitor.py
Normal file
@ -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)
|
||||
568
core/i18n.py
Normal file
568
core/i18n.py
Normal file
@ -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': """
|
||||
<h2>👋 환영합니다!</h2>
|
||||
<p><b>퇴근시간 계산기</b>는 출근 시간 자동 감지부터 연장근무 적립·사용까지
|
||||
하루 근무를 정리해 주는 데스크톱 앱입니다.</p>
|
||||
|
||||
<h3>한눈에 보는 기본 흐름</h3>
|
||||
<ol>
|
||||
<li><b>출근</b> — 컴퓨터 켜진 시각이 자동으로 출근 시간으로 기록돼요.</li>
|
||||
<li><b>근무 중</b> — 메인 화면에 퇴근까지 남은 시간이 1초마다 갱신됩니다.</li>
|
||||
<li><b>점심/저녁</b> — 식사 시간 버튼을 누르면 그만큼 근무시간이 늘어납니다.</li>
|
||||
<li><b>외출</b> — "외출 시작/복귀" 버튼으로 잠깐 자리 비운 시간을 추적합니다.</li>
|
||||
<li><b>퇴근</b> — 퇴근 버튼을 누르면 연장근무가 30분 단위로 자동 적립됩니다.</li>
|
||||
</ol>
|
||||
|
||||
<h3>처음 켰다면 꼭 확인하세요</h3>
|
||||
<ul>
|
||||
<li><b>설정 → 근무 시간</b>: 본인 근무 패턴(8시간 / 단축 7h30m / 6시간 등)을
|
||||
프리셋에서 선택하거나 직접 입력하세요.</li>
|
||||
<li><b>설정 → 휴가 → 연간 연차</b>: 본인 연차 일수를 맞춰 두면 잔여 연차가
|
||||
자동 계산됩니다.</li>
|
||||
<li><b>관리자 권한</b>이 필요할 수 있어요. 부팅 시간이 자동 감지되지 않으면
|
||||
관리자 권한으로 실행해 보세요.</li>
|
||||
</ul>
|
||||
""",
|
||||
'help.html.work_hours': """
|
||||
<h2>🕘 근무시간 설정</h2>
|
||||
|
||||
<h3>표준 근무 / 단축근무 / 시간제 모두 지원</h3>
|
||||
<ul>
|
||||
<li><b>표준 8시간</b> — 점심 60분 (기본값)</li>
|
||||
<li><b>단축근무 7시간 30분</b> — 점심 30분</li>
|
||||
<li><b>단축근무 7시간</b> — 점심 60분</li>
|
||||
<li><b>단축근무 6시간</b> — 점심 30분</li>
|
||||
<li><b>반일 4시간</b> — 점심 없음</li>
|
||||
<li><b>사용자 정의</b> — 시간/분 직접 입력 (5분 단위)</li>
|
||||
</ul>
|
||||
|
||||
<h3>💡 단축근무 사용자 안내</h3>
|
||||
<p>예) 하루 7시간 30분 근무 + 점심 30분</p>
|
||||
<ol>
|
||||
<li>설정 → 근무 시간 → <b>근무 패턴</b>에서
|
||||
<i>"단축근무 7시간 30분 (점심 30분)"</i> 선택</li>
|
||||
<li>또는 직접 입력: <b>하루 기본 근무</b>에 <code>7 시간 30 분</code>,
|
||||
<b>점심시간 기본</b>에 <code>30 분</code></li>
|
||||
<li><b>저장</b> 클릭하면 즉시 메인 화면 계산이 갱신됩니다.</li>
|
||||
</ol>
|
||||
|
||||
<h3>점심시간 자동 적용</h3>
|
||||
<p>설정에서 <b>"자동 적용"</b>을 체크하면 출근 후 <b>4시간 경과</b> 시
|
||||
점심시간이 자동으로 켜집니다.</p>
|
||||
""",
|
||||
'help.html.overtime': """
|
||||
<h2>🏦 연장근무 30분 단위 적립 시스템</h2>
|
||||
|
||||
<h3>적립 규칙</h3>
|
||||
<p>정규 퇴근시간 이후 일한 시간은 <b>30분 단위로 절삭</b>되어 적립됩니다.</p>
|
||||
<ul>
|
||||
<li>1시간 35분 일했다면 → <b>1시간 30분</b> 적립</li>
|
||||
<li>55분 일했다면 → <b>30분</b> 적립</li>
|
||||
<li>29분 일했다면 → <b>0분</b> 적립</li>
|
||||
</ul>
|
||||
|
||||
<h3>사용 방법</h3>
|
||||
<p>적립된 연장근무는 메인 화면 <b>"30분 사용" / "1시간 사용"</b> 버튼으로
|
||||
쓸 수 있어요. 사용한 만큼 그날 퇴근시간이 앞당겨집니다.</p>
|
||||
|
||||
<h3>주말·공휴일 근무</h3>
|
||||
<p>주말 또는 등록된 공휴일에 일한 시간은
|
||||
<b>모든 시간이 연장근무로 적립</b>됩니다.</p>
|
||||
""",
|
||||
'help.html.leave': """
|
||||
<h2>🌴 연차·반차 관리</h2>
|
||||
|
||||
<h3>연차 잔액 자동 계산</h3>
|
||||
<p>잔액 = <b>연간 연차</b> − (프로그램 외 사용분 + 프로그램에서 기록된 사용분)</p>
|
||||
|
||||
<h3>반차·반반차 지원</h3>
|
||||
<ul>
|
||||
<li><b>1.0일</b> — 종일 연차</li>
|
||||
<li><b>0.5일</b> — 반차 (4시간)</li>
|
||||
<li><b>0.25일</b> — 반반차 (2시간)</li>
|
||||
</ul>
|
||||
|
||||
<h3>단축근무자의 연차 환산</h3>
|
||||
<p>1일 연차의 시간 길이는 설정의 <b>하루 기본 근무</b>를 따릅니다.
|
||||
예: 7시간 30분 근무자는 1일 연차가 7시간 30분(=450분)으로 환산됩니다.</p>
|
||||
""",
|
||||
'help.html.break': """
|
||||
<h2>🚪 외출 / 저녁시간</h2>
|
||||
|
||||
<h3>외출 (잠깐 자리 비움)</h3>
|
||||
<p>병원, 잠깐 외근 등으로 자리를 비울 때 <b>외출 시작 → 복귀</b> 버튼으로
|
||||
시간을 추적하세요.</p>
|
||||
|
||||
<h3>화면 잠금 자동 외출</h3>
|
||||
<p>설정에서 <b>"화면 잠금 시 자동 외출/복귀"</b>를 켜면 PC 잠금 시 자동으로
|
||||
외출이 시작되고, 풀리면 복귀로 처리됩니다.</p>
|
||||
|
||||
<h3>저녁시간</h3>
|
||||
<p>야근하면서 저녁을 먹는다면 <b>저녁시간 추가</b> 버튼을 눌러주세요.</p>
|
||||
""",
|
||||
'help.html.faq': """
|
||||
<h2>❓ 자주 묻는 질문</h2>
|
||||
|
||||
<h3>Q. 출근 시간이 잘못 잡혔어요</h3>
|
||||
<p>메인 화면 출근 시각 옆 <b>편집(연필)</b> 아이콘으로 수정할 수 있어요.</p>
|
||||
|
||||
<h3>Q. 단축근무 7시간 30분으로 설정하고 싶어요</h3>
|
||||
<p>설정 → 근무 시간 → 근무 패턴에서 프리셋을 선택하거나 시·분을 직접 입력하세요.</p>
|
||||
|
||||
<h3>Q. 데이터는 어디에 저장되나요?</h3>
|
||||
<p>실행 폴더의 <code>database.db</code> (SQLite). 자동 백업은
|
||||
<code>~/.clockout_backups/</code>에 1일 1회 회전됩니다.</p>
|
||||
|
||||
""",
|
||||
},
|
||||
'en': {
|
||||
'help.html.intro': """
|
||||
<h2>👋 Welcome!</h2>
|
||||
<p><b>Clock-out Time Calculator</b> is a desktop app that organizes your daily work
|
||||
— from auto-detecting clock-in time to banking and using overtime.</p>
|
||||
|
||||
<h3>Basic Flow</h3>
|
||||
<ol>
|
||||
<li><b>Clock in</b> — System boot time is auto-recorded as your clock-in.</li>
|
||||
<li><b>Working</b> — Remaining time updates every second on the main screen.</li>
|
||||
<li><b>Lunch/Dinner</b> — Press the meal buttons to extend work time.</li>
|
||||
<li><b>Break</b> — Track time away with "Start Break / Return" buttons.</li>
|
||||
<li><b>Clock out</b> — Press the button; overtime is banked in 30-min units.</li>
|
||||
</ol>
|
||||
|
||||
<h3>First-Time Setup</h3>
|
||||
<ul>
|
||||
<li><b>Settings → Work Time</b>: pick your pattern from presets
|
||||
(8h / 7h30m / 6h / 4h half-day) or enter custom values.</li>
|
||||
<li><b>Settings → Leave</b>: set your annual leave days.</li>
|
||||
<li><b>Admin rights</b> may be required for boot-time auto-detection.</li>
|
||||
</ul>
|
||||
""",
|
||||
'help.html.work_hours': """
|
||||
<h2>🕘 Work Time Settings</h2>
|
||||
|
||||
<h3>Supports Standard / Reduced / Part-time</h3>
|
||||
<ul>
|
||||
<li><b>Standard 8h</b> — 60-min lunch (default)</li>
|
||||
<li><b>Reduced 7h30m</b> — 30-min lunch</li>
|
||||
<li><b>Reduced 7h</b> — 60-min lunch</li>
|
||||
<li><b>Reduced 6h</b> — 30-min lunch</li>
|
||||
<li><b>Half-day 4h</b> — no lunch</li>
|
||||
<li><b>Custom</b> — direct input (5-min granularity)</li>
|
||||
</ul>
|
||||
|
||||
<h3>💡 For Reduced-Hours Users</h3>
|
||||
<p>Example: 7h30m daily + 30-min lunch</p>
|
||||
<ol>
|
||||
<li>Settings → Work Time → pick <i>"Reduced 7h30m (30-min lunch)"</i> preset</li>
|
||||
<li>Or enter directly: 7h 30min daily, 30min lunch</li>
|
||||
<li>Click <b>Save</b> — main screen recalculates immediately.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Auto Lunch</h3>
|
||||
<p>Enable "Auto Apply" in settings to automatically turn on lunch
|
||||
after 4 hours from clock-in.</p>
|
||||
""",
|
||||
'help.html.overtime': """
|
||||
<h2>🏦 30-Minute Overtime Banking</h2>
|
||||
|
||||
<h3>Banking Rule</h3>
|
||||
<p>Time worked past your scheduled clock-out is <b>truncated to 30-min units</b>.</p>
|
||||
<ul>
|
||||
<li>1h35m worked → <b>1h30m</b> banked</li>
|
||||
<li>55min worked → <b>30min</b> banked</li>
|
||||
<li>29min worked → <b>0min</b> banked</li>
|
||||
</ul>
|
||||
|
||||
<h3>Using Banked Overtime</h3>
|
||||
<p>Use buttons on the main screen to consume banked overtime.
|
||||
Each unit lets you clock out earlier on a chosen day.</p>
|
||||
|
||||
<h3>Weekend/Holiday Work</h3>
|
||||
<p>All hours worked on weekends or registered holidays are
|
||||
<b>banked entirely as overtime</b>.</p>
|
||||
""",
|
||||
'help.html.leave': """
|
||||
<h2>🌴 Annual Leave Management</h2>
|
||||
|
||||
<h3>Auto Balance Calculation</h3>
|
||||
<p>Balance = annual leave − (pre-program usage + recorded usage)</p>
|
||||
|
||||
<h3>Half / Quarter Day</h3>
|
||||
<ul>
|
||||
<li><b>1.0 day</b> — full leave</li>
|
||||
<li><b>0.5 day</b> — half (4h)</li>
|
||||
<li><b>0.25 day</b> — quarter (2h)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Reduced-Hours Conversion</h3>
|
||||
<p>1 leave day equals your configured daily work time.
|
||||
Example: 7h30m worker → 1 day = 7h30m (450 min).</p>
|
||||
""",
|
||||
'help.html.break': """
|
||||
<h2>🚪 Break / Dinner</h2>
|
||||
|
||||
<h3>Break (Briefly Away)</h3>
|
||||
<p>For short absences (medical, errands), use <b>Start Break / Return</b>.</p>
|
||||
|
||||
<h3>Auto Break on Screen Lock</h3>
|
||||
<p>Enable "Auto break on screen lock" in settings — when the PC locks,
|
||||
a break starts automatically; on unlock, you return.</p>
|
||||
|
||||
<h3>Dinner</h3>
|
||||
<p>For overtime with dinner, press <b>Add Dinner</b>.</p>
|
||||
""",
|
||||
'help.html.faq': """
|
||||
<h2>❓ FAQ</h2>
|
||||
|
||||
<h3>Q. Clock-in time is wrong</h3>
|
||||
<p>Click the pencil icon next to clock-in time on the main screen to edit.</p>
|
||||
|
||||
<h3>Q. How to set 7h30m work day?</h3>
|
||||
<p>Settings → Work Time → pick the preset or enter hours/minutes directly.</p>
|
||||
|
||||
<h3>Q. Where is data stored?</h3>
|
||||
<p><code>database.db</code> in the program folder (SQLite). Daily auto-backups
|
||||
rotate in <code>~/.clockout_backups/</code>.</p>
|
||||
|
||||
""",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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"<p>missing: {key}</p>"
|
||||
|
||||
|
||||
def available_languages() -> list:
|
||||
return list(_DICT.keys())
|
||||
|
||||
|
||||
def language_label(code: str) -> str:
|
||||
return {'ko': '한국어', 'en': 'English'}.get(code, code)
|
||||
187
core/notifier.py
Normal file
187
core/notifier.py
Normal file
@ -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")
|
||||
52
core/settings_keys.py
Normal file
52
core/settings_keys.py
Normal file
@ -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'
|
||||
326
core/time_calculator.py
Normal file
326
core/time_calculator.py
Normal file
@ -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})")
|
||||
7
core/version.py
Normal file
7
core/version.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
앱 버전 상수.
|
||||
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.2.0'
|
||||
138
main.py
Normal file
138
main.py
Normal file
@ -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())
|
||||
47
main.spec
Normal file
47
main.spec
Normal file
@ -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'],
|
||||
)
|
||||
6
pytest.ini
Normal file
6
pytest.ini
Normal file
@ -0,0 +1,6 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@ -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
|
||||
82
resources/resource_links.md
Normal file
82
resources/resource_links.md
Normal file
@ -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의 시스템 알림음
|
||||
5
run_as_admin.bat
Normal file
5
run_as_admin.bat
Normal file
@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo Starting Clock-out Time Calculator with Administrator privileges...
|
||||
cd /d "%~dp0"
|
||||
python main.py
|
||||
pause
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
110
tests/test_database.py
Normal file
110
tests/test_database.py
Normal file
@ -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
|
||||
83
tests/test_i18n.py
Normal file
83
tests/test_i18n.py
Normal file
@ -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 '<p>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'
|
||||
100
tests/test_time_calculator.py
Normal file
100
tests/test_time_calculator.py
Normal file
@ -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'
|
||||
90
tests/test_updater.py
Normal file
90
tests/test_updater.py
Normal file
@ -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() # 이동되었으므로 사라짐
|
||||
1
ui/__init__.py
Normal file
1
ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# ui 모듈
|
||||
293
ui/break_view.py
Normal file
293
ui/break_view.py
Normal file
@ -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()
|
||||
523
ui/calendar_view.py
Normal file
523
ui/calendar_view.py
Normal file
@ -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_()
|
||||
111
ui/chart_widget.py
Normal file
111
ui/chart_widget.py
Normal file
@ -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()
|
||||
141
ui/clock_in_dialog.py
Normal file
141
ui/clock_in_dialog.py
Normal file
@ -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()
|
||||
0
ui/controllers/__init__.py
Normal file
0
ui/controllers/__init__.py
Normal file
55
ui/controllers/auto_lunch.py
Normal file
55
ui/controllers/auto_lunch.py
Normal file
@ -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)
|
||||
69
ui/controllers/lock_monitor.py
Normal file
69
ui/controllers/lock_monitor.py
Normal file
@ -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()
|
||||
40
ui/controllers/notification_orchestrator.py
Normal file
40
ui/controllers/notification_orchestrator.py
Normal file
@ -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)
|
||||
84
ui/help_view.py
Normal file
84
ui/help_view.py
Normal file
@ -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_()
|
||||
371
ui/leave_view.py
Normal file
371
ui/leave_view.py
Normal file
@ -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_()
|
||||
2049
ui/main_window.py
Normal file
2049
ui/main_window.py
Normal file
File diff suppressed because it is too large
Load Diff
101
ui/mini_widget.py
Normal file
101
ui/mini_widget.py
Normal file
@ -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()
|
||||
507
ui/overtime_view.py
Normal file
507
ui/overtime_view.py
Normal file
@ -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_()
|
||||
1149
ui/settings_view.py
Normal file
1149
ui/settings_view.py
Normal file
File diff suppressed because it is too large
Load Diff
283
ui/stats_view.py
Normal file
283
ui/stats_view.py
Normal file
@ -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_()
|
||||
955
ui/styles.py
Normal file
955
ui/styles.py
Normal file
@ -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
|
||||
|
||||
|
||||
148
updater.py
Normal file
148
updater.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""
|
||||
독립 자가 업데이터.
|
||||
|
||||
Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약을
|
||||
헬퍼 프로세스로 우회. 메인 앱이 종료된 직후 파일 교체 + 재실행.
|
||||
|
||||
사용법 (메인 앱이 호출):
|
||||
updater.exe --pid <메인_PID> --new <new_exe_path> --target <target_exe_path>
|
||||
|
||||
흐름:
|
||||
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())
|
||||
42
updater.spec
Normal file
42
updater.spec
Normal file
@ -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,
|
||||
)
|
||||
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# utils 모듈
|
||||
75
utils/backup.py
Normal file
75
utils/backup.py
Normal file
@ -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
|
||||
157
utils/csv_exporter.py
Normal file
157
utils/csv_exporter.py
Normal file
@ -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}")
|
||||
33
utils/debug_log.py
Normal file
33
utils/debug_log.py
Normal file
@ -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
|
||||
45
utils/lock_detector.py
Normal file
45
utils/lock_detector.py
Normal file
@ -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)
|
||||
161
utils/resource_manager.py
Normal file
161
utils/resource_manager.py
Normal file
@ -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}")
|
||||
190
utils/system_tray.py
Normal file
190
utils/system_tray.py
Normal file
@ -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_())
|
||||
33
utils/time_format.py
Normal file
33
utils/time_format.py
Normal file
@ -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}분"
|
||||
207
utils/updater_client.py
Normal file
207
utils/updater_client.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user