Initial release v2.2.0
Some checks failed
CI / test (push) Has been cancelled

핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (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:
KINDNICK 2026-04-30 12:54:40 +09:00
commit bedbb1e9ec
63 changed files with 13353 additions and 0 deletions

38
.gitea/workflows/ci.yml Normal file
View 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

View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
3d-alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

67
AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

1
core/__init__.py Normal file
View File

@ -0,0 +1 @@
# core 모듈

1603
core/database.py Normal file

File diff suppressed because it is too large Load Diff

359
core/event_monitor.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
"""
버전 상수.
릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 .
"""
__version__ = '2.2.0'

138
main.py Normal file
View 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
View 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
View 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
View 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

View 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
View 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
View File

110
tests/test_database.py Normal file
View 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
View 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'

View 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
View 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
View File

@ -0,0 +1 @@
# ui 모듈

293
ui/break_view.py Normal file
View 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
View 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
View 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
View 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()

View File

View 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)

View 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()

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

101
ui/mini_widget.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

283
ui/stats_view.py Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
# utils 모듈

75
utils/backup.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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