From bedbb1e9ec4e3f1a997e55b8f4a6baa048e6514a Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Thu, 30 Apr 2026 12:54:40 +0900 Subject: [PATCH] Initial release v2.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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) --- .gitea/workflows/ci.yml | 38 + .gitea/workflows/release.yml | 84 + .github/workflows/ci.yml | 76 + .gitignore | 67 + 3d-alarm.ico | Bin 0 -> 58116 bytes 3d-alarm.png | Bin 0 -> 127376 bytes AGENTS.md | 67 + CHANGELOG.md | 69 + CLAUDE.md | 120 ++ INSTALL.md | 160 ++ README.md | 191 ++ _gui_smoke_test.py | 164 ++ _i18n_gui_test.py | 157 ++ _integration_test.py | 541 +++++ check_db.py | 25 + clock_icon.ico | Bin 0 -> 97166 bytes core/__init__.py | 1 + core/database.py | 1603 +++++++++++++++ core/event_monitor.py | 359 ++++ core/i18n.py | 568 +++++ core/notifier.py | 187 ++ core/settings_keys.py | 52 + core/time_calculator.py | 326 +++ core/version.py | 7 + main.py | 138 ++ main.spec | 47 + pytest.ini | 6 + requirements.txt | 6 + resources/resource_links.md | 82 + run_as_admin.bat | 5 + tests/__init__.py | 0 tests/test_database.py | 110 + tests/test_i18n.py | 83 + tests/test_time_calculator.py | 100 + tests/test_updater.py | 90 + ui/__init__.py | 1 + ui/break_view.py | 293 +++ ui/calendar_view.py | 523 +++++ ui/chart_widget.py | 111 + ui/clock_in_dialog.py | 141 ++ ui/controllers/__init__.py | 0 ui/controllers/auto_lunch.py | 55 + ui/controllers/lock_monitor.py | 69 + ui/controllers/notification_orchestrator.py | 40 + ui/help_view.py | 84 + ui/leave_view.py | 371 ++++ ui/main_window.py | 2049 +++++++++++++++++++ ui/mini_widget.py | 101 + ui/overtime_view.py | 507 +++++ ui/settings_view.py | 1149 +++++++++++ ui/stats_view.py | 283 +++ ui/styles.py | 955 +++++++++ updater.py | 148 ++ updater.spec | 42 + utils/__init__.py | 1 + utils/backup.py | 75 + utils/csv_exporter.py | 157 ++ utils/debug_log.py | 33 + utils/lock_detector.py | 45 + utils/resource_manager.py | 161 ++ utils/system_tray.py | 190 ++ utils/time_format.py | 33 + utils/updater_client.py | 207 ++ 63 files changed, 13353 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 3d-alarm.ico create mode 100644 3d-alarm.png create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 _gui_smoke_test.py create mode 100644 _i18n_gui_test.py create mode 100644 _integration_test.py create mode 100644 check_db.py create mode 100644 clock_icon.ico create mode 100644 core/__init__.py create mode 100644 core/database.py create mode 100644 core/event_monitor.py create mode 100644 core/i18n.py create mode 100644 core/notifier.py create mode 100644 core/settings_keys.py create mode 100644 core/time_calculator.py create mode 100644 core/version.py create mode 100644 main.py create mode 100644 main.spec create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 resources/resource_links.md create mode 100644 run_as_admin.bat create mode 100644 tests/__init__.py create mode 100644 tests/test_database.py create mode 100644 tests/test_i18n.py create mode 100644 tests/test_time_calculator.py create mode 100644 tests/test_updater.py create mode 100644 ui/__init__.py create mode 100644 ui/break_view.py create mode 100644 ui/calendar_view.py create mode 100644 ui/chart_widget.py create mode 100644 ui/clock_in_dialog.py create mode 100644 ui/controllers/__init__.py create mode 100644 ui/controllers/auto_lunch.py create mode 100644 ui/controllers/lock_monitor.py create mode 100644 ui/controllers/notification_orchestrator.py create mode 100644 ui/help_view.py create mode 100644 ui/leave_view.py create mode 100644 ui/main_window.py create mode 100644 ui/mini_widget.py create mode 100644 ui/overtime_view.py create mode 100644 ui/settings_view.py create mode 100644 ui/stats_view.py create mode 100644 ui/styles.py create mode 100644 updater.py create mode 100644 updater.spec create mode 100644 utils/__init__.py create mode 100644 utils/backup.py create mode 100644 utils/csv_exporter.py create mode 100644 utils/debug_log.py create mode 100644 utils/lock_detector.py create mode 100644 utils/resource_manager.py create mode 100644 utils/system_tray.py create mode 100644 utils/time_format.py create mode 100644 utils/updater_client.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..10298df --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: pytest unit tests + env: + QT_QPA_PLATFORM: offscreen + run: pytest tests -v + + - name: integration tests + run: python _integration_test.py + + - name: i18n GUI tests + env: + QT_QPA_PLATFORM: offscreen + run: python _i18n_gui_test.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..98dd81d --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,84 @@ +name: Release + +on: + push: + tags: + - 'v*' # v1.0.0, v2.1.0 등 태그 push 시 트리거 + +jobs: + build-and-release: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + PyInstaller + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Run unit tests + env: + QT_QPA_PLATFORM: offscreen + run: | + pip install pytest + pytest tests -q + + - name: Run integration tests + run: python _integration_test.py + + - name: Build main.exe + run: python -m PyInstaller --clean main.spec + + - name: Build updater.exe + run: python -m PyInstaller --clean updater.spec + + - name: Verify both exe exist + run: | + if (-not (Test-Path dist/main.exe)) { throw "main.exe missing" } + if (-not (Test-Path dist/updater.exe)) { throw "updater.exe missing" } + + - name: Create release archive + run: | + New-Item -ItemType Directory -Path dist/release -Force + Copy-Item dist/main.exe dist/release/ + Copy-Item dist/updater.exe dist/release/ + Compress-Archive -Path dist/release/* -DestinationPath dist/ClockOutCalculator.zip -Force + + - name: Publish Release (Gitea) + uses: actions/release-action@main + with: + api_key: ${{ secrets.RELEASE_TOKEN }} + files: | + dist/main.exe + dist/updater.exe + dist/ClockOutCalculator.zip + + # GitHub Actions 호환 fallback (Gitea Actions에서 동작 안 할 경우) + # 위 release-action이 실패하면 아래를 활용: + # + # - name: Publish Release (manual via API) + # shell: pwsh + # env: + # GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} + # GITEA_HOST: https://kindnick-git.duckdns.org + # OWNER: kindnick + # REPO: Clock_out_Time_Calculator + # run: | + # $tag = $env:GITHUB_REF -replace 'refs/tags/', '' + # $body = @{ tag_name = $tag; name = $tag; draft = $false } | ConvertTo-Json + # $headers = @{ Authorization = "token $env:GITEA_TOKEN" } + # $release = Invoke-RestMethod -Uri "$env:GITEA_HOST/api/v1/repos/$env:OWNER/$env:REPO/releases" ` + # -Method Post -Headers $headers -ContentType 'application/json' -Body $body + # $uploadUrl = "$env:GITEA_HOST/api/v1/repos/$env:OWNER/$env:REPO/releases/$($release.id)/assets" + # foreach ($f in @('dist/main.exe', 'dist/updater.exe')) { + # $name = [System.IO.Path]::GetFileName($f) + # Invoke-RestMethod -Uri "$uploadUrl?name=$name" -Method Post ` + # -Headers $headers -InFile $f -ContentType 'application/octet-stream' + # } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d8a543 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~\AppData\Local\pip\Cache + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run integration tests + run: python _integration_test.py + + - name: Run i18n GUI tests (offscreen) + env: + QT_QPA_PLATFORM: offscreen + run: python _i18n_gui_test.py + + - name: Run pytest (if tests/ exists) + env: + QT_QPA_PLATFORM: offscreen + run: | + if (Test-Path tests) { pytest tests --cov=core --cov=utils } + + build: + needs: test + runs-on: windows-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + PyInstaller + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Build exe + run: python -m PyInstaller --clean main.spec + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ClockOutCalculator-exe + path: dist/main.exe + retention-days: 14 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2427b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# PyInstaller 빌드 산출물 (CI에서 자동 빌드되므로 저장소엔 포함 X) +build/ +dist/ +*.spec.bak + +# Python 캐시 +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# 가상환경 +venv/ +env/ +ENV/ +.venv/ + +# 사용자 데이터베이스 (개인 정보 — 절대 커밋 금지) +*.db +!database.example.db +work_records.db +database.db +test_settings*.db + +# 로그 +*.log +~/.clockout_logs/ +shutdown_debug.log +debug.log + +# Backup +.clockout_backups/ +*.bak + +# IDE / Agent metadata +.vscode/ +.idea/ +.claude/ +.opencode/ +*.swp +*.swo +.DS_Store + +# pytest / coverage +.pytest_cache/ +.coverage +htmlcov/ +*.cover +.cache + +# 환경변수 +.env +.env.local +settings.local.json + +# 임시 파일 +*.tmp +nul +analysis/ +download_resources.py +test_settings.db +test_settings2.db + +# Old PyInstaller spec files (legacy) +ClockOutCalculator.spec +퇴근시간계산기.spec diff --git a/3d-alarm.ico b/3d-alarm.ico new file mode 100644 index 0000000000000000000000000000000000000000..c72083d22d8327780ea629e2b601480de15bcaa4 GIT binary patch literal 58116 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 FV1h)tSA75g literal 0 HcmV?d00001 diff --git a/3d-alarm.png b/3d-alarm.png new file mode 100644 index 0000000000000000000000000000000000000000..6ef2f8201c92bc3ece9da662eee9aff0aa0d9071 GIT binary patch literal 127376 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 xEM6_pEu{`X!d8a%#VSFO7LfdTB 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 ogVE8^_dfXGgWLO|

(m*$09OLe0EacA z5B}rS`S70RJnboHGNTq4RiJ7v$j1f#Q6lm`t;lNV+xh)HvuzWd9ajz$X+r74h51&C zSL%zm86{)xY=n_;M%v{@$+2Y0QadChj_JA1t~>M140tfiaJQV{{eYW0dAV zX`@~@B=B(m1bk)jOYlhd6!a_P`ZhyTn@sA7K6wQYP)Z#ZQe1uSz4!iju7JR-2Y_|! z)@A$l?c*CaZhSjqY$qsK?kxQ?iT%O1G=z^Iw_Lbhd(@T*n@}85P;f=L7(U)y2CIr* zm;??bQy4`Uc7_C+(8@q=Uwka}W5xD@N=Xh0GdP}Hw>>W}KCm5+%z5_jI#-|nqv{Nm zPU;@b99}2fS7h%}gX#9F#npSGbLlzu7$p2uF-_>tbrrB};ft_q;3Sj`pkHOCUKA3z zUJcolI71Lf5C~?F25Qs+ksA-e^GTXqxog+1N8H?lS=?qd05}xB;)*NYri8i|7%Zj4 zkBt($BXY{|J<-RKfd&NyiM0)!ffCME4fu6_9(;6qDV$w4;0!CZUpcKKvWVdQl&=AT zx|p5nckH(#VtSx9Q2Ru+bLQ)|ALIMc20XOyCZ#q!qXLJ>RM3`n*Pd!oqv!kQtWh&a zUmW}GmnlqS0(J}@hiwDL;DxM!L6t$u5^em`Mm{zJr%$SVdYc0W0tWh790+82$`bYh zF!CmPe#V&R{FI=P(1jFjMDHX3|2IzW2q>_j z1sqj?2`J$b7=W9~<#1(dG4zWJrhp4!L~(6eqLaAIOy&b|!T^{M25Q6Y2uV3XxzaZ( z0EqQve{)l<$Ng@@C_-#)x=6_HeQ*6nYM_gsx9%F%(Nz73Gx^*T1@1>1Oq?jDx*^Rl zpJQL|DfqjA=i$fsB=kb2RRC#ArsGIa!uEeVP)G>Jx5uOjx(6%nPbC7Sl#>LWO|$d^ zyLaz?d^Ur?tONiz;8$OL_4297sqZnyR>Z^~>-crLfADQV0Gsm+)O}DQm{c5EO2DNs zPu(akU>n5}ZFCx!dRZ?N3GLzmwmG$c1lCl6Hr`|x=4#m=ZwpJY_Cp3`s$gG!9Pa3S9`-lJ zHMj4>VYlwk8a-ZH{B=KwM3B^o{Z2xd$tK`HKq>W9-pJSQ-o5+j*%~LnW-0N@Yp%Iw z;pD{ReGJ&YD@v@H4e3loPNe|+4<&&>^ha@DN=^9g8gX;UE_zNH$w5u1l405GPh>0q zaJYH#B@?3DGNAHwT$QLcXdrOOg0(6^Eb2$WGocg^cgT`V8?Dk9h3Ir5m3T5EwL;ni zkLr*}R1dH7a(aLDyF!kc_R&~vY15jBX%c02x^fGqWm*zeD<6E;QhlP|dfYz8q zvvTkGtXtc_5i|mU{>?&FKy!Zlk8uA0B#6X-e4>#zQVH^r1q&AZ>ged`siC2v1m|YW z+v?cX@c@9xx_9s1Wbxv~iila+hV>hEr)hdQFF6M+^<|Ab_!9BEO}w)c!cPqC@eAF7 zWmy774B`iWw^}Wjo}PxHC_r#w_F+Pc2^3LznkBtmJ-_wDk%NDOBYEn=t?w+RT3aQT zzdk7QfuyQO#084uzh4F;9vNX9X>q-84*@az$~b`J0NX zn|egLoJr7XgJOD>yhP_A5ef`_@|7{K%CxK4dEhQ=BW#jRZ_gzPMsP}~wO3VqLw6z- z%tI9<5c~bH&OXtuO1R@`5LEme)`_CIeJ|vC@u3OEkFr#Ky?Y@4%Ce)|{xyE)RjXEg z4gfyW+tVxi`umdp{(k7`>Cr^*IlcY(+YusvrAFrw{otI?ALIxOGRft(c?;(Kev%|F z-hTV-)ASPdyCbE7P6YrzbB2b7R%cmuIRLByfVBW{ahj%m3L%-9dIt1GzM7Z%syjh| zfqon)0Kij4Q5+INJfygK@X{lV-6M~U;&Pt49b5OQ{^ZcaHLOruI9HbssJtF~k52@vSD-1I1-6(-FgVW`_rgMcC_pGlMCu0~hmo9CYV#`oRL|7Pvh z{FD1{E4OUfg0UW7VvPMVit=vksja7eUBjPCllTsuZ3)%IX0yr5qP#uHlK*tyefR05 z#5xTGIu!uab=O_@w>>>QH=*c_%Q4%1nn(=zzHwdG91z`#TqrKMW~h+ag;6;q-o6o1ra|NduLkdcOMSj&C|cR-gNA(ZO!hsYJMP0;b>gG3`Eu zRwACylk{7Vv46~ZCcd-mg`JL2U=OUzzBr_9Kl0jZuYF6JrVr$KzO*O`9j}RcKXP98 zNN?nv0G${ql%5kM$9bX3vP4e-Yc`wTUVi@a>pIauXxE17Ecf2E*Is*Nnx;2RO-(tM zTIclB5{otn3BiOAspLi_D2k#KT+C~gt^W-G8&|Jhtz1P}tTVS4_9kqf)(^jnpC{Md z_y^e`wPCzjtQQKdU|d}^m^8W&T$(y1SqCFo@KcG6FLa2|-r7QbEqPXUPkLx2WT=?~ zcPjft1tBhyfvN(;BVe?D#e|hLvJ5ahH%-%o!I&yIPt;SB3E!c**xtUZ`@2hzeSK12 z-1@0tYwwEsA-K~WY^v6lY#*o89^{Os6Pz;@`bnkMqhfoanU$$tz*-$S|;j! zs#J?}#`^nMLoahSjhseD_aQK70>orl5faa66sRS>MV&SwkY zC&e<_I2Ng5tec_V70c*wXh|_CGdM9R`FB(fhqA8h{%+tuIOq8G2~XNDIZnSA$3o7u z;?D5!FpP|hpap-05TZY@EOt^;ULrJez`B!5uD@AXmWh<|l8=1kBaP8fZ6xj(gJ4Gi zfE$>yET?hG;Q+p&dR<-9OWjxgU`P1+g8@b+;0WykC#o~*TRRvG ztgXmTuDh|jaiHjzD*eUrvb;pL#4_kg-m8QY7qCkvZNRqlR|=l7J5&?gqB8xQsXRD(>!HAX$f&ZWN<8RB!~MLe6Q7| z#?F6sv_`^FwEt8u%Qy z-j0rH9YtMEKYHJW_kA|Yvtdl>WsKQHJv*b(BqC>l_NqXs4T8A+rT)SG3-|BeKlRh1 zJq3hOcudtTO{LUua`YXCdy1!JBkL7!1(93~B|D2xi=HH7=Rv~G2c^yir4}d!Jxbx) zh$s;-G+0u;Z_1sbaDxd0x-6EOQVh`j_L7qDwBqU~NzPi3rzabUI8c`AkuzOTthp7M z?E0F%!Emx4UPvo~|Eb=h9y^TxICSvPkJ2Pvg+%6Dr=8Li2|0I+fvAS1QYsf*{Mi!+ zpZHzNbUVfd&`}ZqMo#V9w-2VKr~Z~rvs)6D3<@a>2y{sEzGYD*7g>9N5{L*4@+3*V zV2MBdX;I%0j4np$s`Fd09!daKv%Rnv7}IJ4N(z%O2KNK#Pk50#hf=t;H$VEXB28qA z;Rkn}QUeT5+T#`iEMQ&gIQ~q=l}x(SlI~Q%O)EJ2qY)G&O!HbhY1bQZegH0JYvGhc zk>1=|8$W+mU00!9=(gK#D=)k3vcE`^^wy#%a6aI^jsXD`*Osu%ful=6s+w zdw9p_{~ad**rtsnYuB#5k}38LspNd^_V1LQYBP73U*PfyaAI=7M+XN7Z`i+ozt+cJ zPhBtc3JxPEB4?1vZF5jZ7Zs|76yH&+NeFTr9pWC%54DjtIpcJ9g~2l>z%L zpQYIeb3!@0i3&4Ig|0Y>H7HvO)QtxY9JupmVF<5R>x3a}Yc8|u(n~M>_nh sqlite3.Connection: + """데이터베이스 연결 반환. + + timeout=5초: 다른 PC/프로세스가 쓰는 동안 락 충돌 시 대기. + 클라우드 동기화(OneDrive 등)로 같은 DB를 두 PC에서 쓸 때 안전. + """ + conn = sqlite3.connect(self.db_path, timeout=5.0) + conn.row_factory = sqlite3.Row + return conn + + def _enable_concurrency(self): + """WAL 모드 활성화 — 동시 읽기 + 쓰기 가능, 클라우드 동기화 친화.""" + try: + conn = sqlite3.connect(self.db_path, timeout=5.0) + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + conn.commit() + conn.close() + except sqlite3.OperationalError: + # WAL 미지원 환경(읽기전용 폴더 등) — 기본 모드로 fallback + pass + + def init_database(self): + """데이터베이스 초기화 및 테이블 생성""" + conn = self.get_connection() + cursor = conn.cursor() + + # 일일 근무 기록 테이블 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS work_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL UNIQUE, + clock_in TIME NOT NULL, + clock_out TIME, + lunch_break BOOLEAN DEFAULT 0, + total_hours REAL, + overtime_minutes INTEGER DEFAULT 0, + overtime_earned INTEGER DEFAULT 0, + overtime_used INTEGER DEFAULT 0, + work_type TEXT DEFAULT 'normal', + memo TEXT, + is_manual BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 연장근무 적립 내역 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS overtime_bank ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + earned_minutes INTEGER NOT NULL, + date DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) + ) + ''') + + # 연장근무 사용 내역 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS overtime_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + used_minutes INTEGER NOT NULL, + date DATE NOT NULL, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) + ) + ''') + + # 휴가 기록 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS leave_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + leave_type TEXT NOT NULL, + days REAL, + memo TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 설정 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 업적 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS achievements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + earned_date DATE, + badge_icon TEXT + ) + ''') + + # 외출 기록 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS break_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + date DATE NOT NULL, + break_out TIME NOT NULL, + break_in TIME, + total_minutes INTEGER, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE + ) + ''') + + # 공휴일 테이블 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS holidays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL UNIQUE, + name TEXT NOT NULL, + is_recurring BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + conn.commit() + conn.close() + + # 데이터베이스 마이그레이션 실행 + self.migrate_break_records_cascade() + self.migrate_lunch_duration_to_minutes() + self.migrate_leave_records_hours_to_days() + self.migrate_add_dinner_break() + self.migrate_cleanup_balance_adjustments() + self.migrate_work_hours_to_minutes() + self.migrate_annual_leave_keys() + + # 기본 설정 초기화 + self.init_default_settings() + + def migrate_break_records_cascade(self): + """break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션)""" + conn = self.get_connection() + cursor = conn.cursor() + + # 기존 테이블에 CASCADE가 있는지 확인 + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='break_records'") + result = cursor.fetchone() + + if result and 'ON DELETE CASCADE' not in result[0]: + # CASCADE가 없으면 테이블 재생성 + try: + # 1. 기존 데이터 백업 + cursor.execute('SELECT * FROM break_records') + backup_data = cursor.fetchall() + + # 2. 기존 테이블 삭제 + cursor.execute('DROP TABLE IF EXISTS break_records') + + # 3. CASCADE 포함한 새 테이블 생성 + cursor.execute(''' + CREATE TABLE break_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + work_record_id INTEGER, + date DATE NOT NULL, + break_out TIME NOT NULL, + break_in TIME, + total_minutes INTEGER, + reason TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (work_record_id) REFERENCES work_records(id) ON DELETE CASCADE + ) + ''') + + # 4. 데이터 복원 + for row in backup_data: + cursor.execute(''' + INSERT INTO break_records + (id, work_record_id, date, break_out, break_in, total_minutes, reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', tuple(row)) + + conn.commit() + print("break_records 테이블 CASCADE 마이그레이션 완료") + except Exception as e: + conn.rollback() + print(f"마이그레이션 오류: {e}") + + conn.close() + + def migrate_lunch_duration_to_minutes(self): + """lunch_duration을 시간 단위에서 분 단위로 마이그레이션""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 기존 lunch_duration 설정 확인 + cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration'") + result = cursor.fetchone() + + if result: + # 기존 값이 있으면 시간 단위로 저장되어 있으므로 분으로 변환 + lunch_hours = float(result['value']) + lunch_minutes = int(lunch_hours * 60) + + # lunch_duration_minutes로 저장 + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('lunch_duration_minutes', ?, CURRENT_TIMESTAMP) + ''', (str(lunch_minutes),)) + + # 기존 lunch_duration은 삭제하지 않음 (호환성 유지) + + # lunch_duration_minutes가 없으면 기본값 설정 + cursor.execute("SELECT value FROM settings WHERE key = 'lunch_duration_minutes'") + if not cursor.fetchone(): + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) + VALUES ('lunch_duration_minutes', '60') + ''') + + conn.commit() + except Exception as e: + # 마이그레이션 실패 시 무시 (이미 마이그레이션됨) + # 단, 예상치 못한 오류는 로그에 기록 + import sys + if "no such column" not in str(e).lower() and "already exists" not in str(e).lower(): + print(f"lunch_duration 마이그레이션 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_leave_records_hours_to_days(self): + """leave_records.hours 컬럼을 days로 변경 (마이그레이션)""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 현재 테이블 스키마 확인 + cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='leave_records'") + result = cursor.fetchone() + + if result and 'hours REAL' in result[0]: + # hours 컬럼이 있으면 days로 변경 + # 1. 기존 데이터 백업 + cursor.execute('SELECT * FROM leave_records') + backup_data = cursor.fetchall() + + # 2. 기존 테이블 삭제 + cursor.execute('DROP TABLE IF EXISTS leave_records') + + # 3. days 컬럼으로 새 테이블 생성 + cursor.execute(''' + CREATE TABLE leave_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + leave_type TEXT NOT NULL, + days REAL, + memo TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 4. 데이터 복원 (hours -> days, 같은 값) + for row in backup_data: + cursor.execute(''' + INSERT INTO leave_records + (id, date, leave_type, days, memo, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', tuple(row)) + + conn.commit() + print("leave_records 테이블 hours->days 마이그레이션 완료") + except Exception as e: + conn.rollback() + print(f"leave_records 마이그레이션 오류: {e}") + finally: + conn.close() + + def migrate_add_dinner_break(self): + """work_records 테이블에 dinner_break 컬럼 추가 (마이그레이션)""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 현재 테이블 스키마 확인 + cursor.execute("PRAGMA table_info(work_records)") + columns = [row[1] for row in cursor.fetchall()] + + if 'dinner_break' not in columns: + # dinner_break 컬럼 추가 + cursor.execute(''' + ALTER TABLE work_records + ADD COLUMN dinner_break BOOLEAN DEFAULT 0 + ''') + conn.commit() + print("work_records 테이블에 dinner_break 컬럼 추가 완료") + except Exception as e: + import sys + if "duplicate column name" not in str(e).lower(): + print(f"dinner_break 컬럼 추가 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_cleanup_balance_adjustments(self): + """기존 잘못된 조정 데이터 정리 마이그레이션 + + 이전 버전에서 '덮어쓰기' 방식으로 생성된 조정 레코드들을 정리: + - overtime_bank: work_record_id가 NULL인 레코드는 삭제 (초기값은 settings로 이동) + - leave_records: 'manual' 타입이고 '이전 사용분 일괄 추가' 메모가 있는 레코드 삭제 (초기값은 settings로 이동) + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 마이그레이션 완료 여부 확인 (v2로 버전 업) + cursor.execute("SELECT value FROM settings WHERE key = 'balance_adjustment_migrated_v2'") + result = cursor.fetchone() + + if result: + # 이미 마이그레이션 완료 + conn.close() + return + + # 1. overtime_bank에서 수동 추가 레코드 삭제 + # (work_record_id가 NULL인 것 - 이전 방식의 수동 조정) + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id IS NULL + ''') + deleted_overtime = cursor.rowcount + + # 2. leave_records에서 '이전 사용분 일괄 추가' 레코드 삭제 + cursor.execute(''' + DELETE FROM leave_records + WHERE leave_type = 'manual' AND memo LIKE '%이전 사용분 일괄 추가%' + ''') + deleted_leave = cursor.rowcount + + # 마이그레이션 완료 표시 + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('balance_adjustment_migrated_v2', 'true', CURRENT_TIMESTAMP) + ''') + + conn.commit() + + if deleted_overtime > 0 or deleted_leave > 0: + print(f"잔액 조정 마이그레이션 v2 완료: 연장근무 {deleted_overtime}건, 연차 {deleted_leave}건 삭제") + + except Exception as e: + conn.rollback() + import sys + print(f"잔액 조정 마이그레이션 오류: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_work_hours_to_minutes(self): + """work_hours(시간 단위, 정수)를 work_minutes(분 단위)로 마이그레이션. + + 단축근무자(예: 7시간 30분)를 위해 분 단위 저장이 필요. + 기존 work_hours는 호환성 유지를 위해 보존. + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # work_minutes가 이미 있으면 스킵 + cursor.execute("SELECT value FROM settings WHERE key = 'work_minutes'") + if cursor.fetchone(): + conn.close() + return + + # work_hours에서 분으로 변환 + cursor.execute("SELECT value FROM settings WHERE key = 'work_hours'") + row = cursor.fetchone() + if row: + try: + # float 허용 (혹시 외부에서 7.5 등 저장된 경우) + work_hours_val = float(row[0]) + work_minutes_val = int(round(work_hours_val * 60)) + except (ValueError, TypeError): + work_minutes_val = 480 + else: + work_minutes_val = 480 + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('work_minutes', ?, CURRENT_TIMESTAMP) + ''', (str(work_minutes_val),)) + + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"work_minutes 마이그레이션 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def migrate_annual_leave_keys(self): + """annual_leave_total(레거시) ↔ annual_leave_days(UI) 동기화. + + UI는 annual_leave_days를 사용하지만 일부 메서드는 annual_leave_total을 읽음. + 둘 중 하나만 있으면 다른 쪽에 복사. sentinel로 1회만 실행. + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # sentinel 체크: 이미 마이그레이션 완료면 스킵 + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_keys_migrated'") + if cursor.fetchone(): + return + + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_days'") + days_row = cursor.fetchone() + cursor.execute("SELECT value FROM settings WHERE key = 'annual_leave_total'") + total_row = cursor.fetchone() + + if days_row and not total_row: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_total', ?, CURRENT_TIMESTAMP) + ''', (days_row[0],)) + elif total_row and not days_row: + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_days', ?, CURRENT_TIMESTAMP) + ''', (total_row[0],)) + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('annual_leave_keys_migrated', 'true', CURRENT_TIMESTAMP) + ''') + conn.commit() + except Exception as e: + conn.rollback() + import sys + print(f"annual_leave 키 동기화 경고: {e}", file=sys.stderr) + finally: + conn.close() + + def get_setting_int(self, key: str, default: int = 0) -> int: + """설정을 int로 안전하게 조회 (변환 실패 시 default).""" + raw = self.get_setting(key, None) + if raw is None: + return default + try: + return int(raw) + except (ValueError, TypeError): + return default + + def get_setting_float(self, key: str, default: float = 0.0) -> float: + """설정을 float로 안전하게 조회.""" + raw = self.get_setting(key, None) + if raw is None: + return default + try: + return float(raw) + except (ValueError, TypeError): + return default + + def get_setting_bool(self, key: str, default: bool = False) -> bool: + """설정을 bool로 안전하게 조회 ('true'/'1'/'yes' = True).""" + raw = self.get_setting(key, None) + if raw is None: + return default + return str(raw).lower() in ('1', 'true', 'yes') + + def get_work_minutes(self) -> int: + """기본 근무시간 (분 단위) 조회. + + work_minutes 우선, 없으면 work_hours * 60으로 폴백. + 7시간 30분 같은 단축근무 케이스에서 분 단위 정확도 보장. + """ + wm = self.get_setting(WORK_MINUTES, None) + if wm is not None: + try: + return int(wm) + except (ValueError, TypeError): + pass + + wh = self.get_setting(WORK_HOURS, '8') + try: + return int(round(float(wh) * 60)) + except (ValueError, TypeError): + return 480 + + def init_default_settings(self): + """기본 설정 초기화""" + default_settings = { + 'work_hours': '8', + 'work_minutes': '480', + 'lunch_duration_minutes': '60', + 'dinner_duration_minutes': '60', + 'auto_detect_boot': 'true', + 'auto_lunch': 'false', + 'theme': 'light', + 'notification_enabled': 'true', + 'notification_before_minutes': '30', + 'notification_clock_out': 'true', + 'notification_lunch': 'true', + 'notification_overtime': 'true', + 'notification_health': 'true', + 'annual_leave_total': '15', + 'annual_leave_days': '15', # UI에서 사용하는 키 (annual_leave_total과 동기화) + 'annual_leave_used': '0', + 'workday_boundary_hour': '6', + 'overtime_unit': '30', + 'time_format': '24' + } + + conn = self.get_connection() + cursor = conn.cursor() + + for key, value in default_settings.items(): + cursor.execute(''' + INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?) + ''', (key, value)) + + conn.commit() + conn.close() + + # ===== 근무 기록 관련 메서드 ===== + + def add_work_record(self, date: str, clock_in: str, lunch_break: bool = False, + is_manual: bool = False) -> int: + """근무 기록 추가""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO work_records (date, clock_in, lunch_break, is_manual) + VALUES (?, ?, ?, ?) + ''', (date, clock_in, lunch_break, is_manual)) + + record_id = cursor.lastrowid + conn.commit() + conn.close() + return record_id + + def update_clock_out(self, date: str, clock_out: str, total_hours: float, + overtime_minutes: int, overtime_earned: int): + """퇴근 시간 및 연장근무 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET clock_out = ?, total_hours = ?, overtime_minutes = ?, + overtime_earned = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (clock_out, total_hours, overtime_minutes, overtime_earned, date)) + + conn.commit() + conn.close() + + def cancel_clock_out(self, date: str) -> bool: + """퇴근 취소 (퇴근 시간 및 연장근무 기록 삭제) + + Returns: + bool: 성공 여부 + """ + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 1. 해당 날짜의 work_record 조회 + cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) + record = cursor.fetchone() + + if not record: + conn.close() + return False + + work_record_id = record[0] + + # 2. 해당 날짜의 연장근무 적립 내역 삭제 + cursor.execute(''' + DELETE FROM overtime_bank + WHERE work_record_id = ? AND date = ? + ''', (work_record_id, date)) + + # 3. work_records의 퇴근 관련 필드 초기화 + cursor.execute(''' + UPDATE work_records + SET clock_out = NULL, + total_hours = NULL, + overtime_minutes = 0, + overtime_earned = 0, + updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (date,)) + + conn.commit() + conn.close() + return True + + except Exception as e: + conn.rollback() + conn.close() + raise e + + def get_today_record(self) -> Optional[Dict]: + """오늘 근무 기록 조회""" + today = date.today().isoformat() + return self.get_work_record(today) + + def get_work_record(self, date: str) -> Optional[Dict]: + """특정 날짜 근무 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM work_records WHERE date = ? + ''', (date,)) + + row = cursor.fetchone() + conn.close() + + if row: + return dict(row) + return None + + def update_lunch_break(self, date: str, lunch_break: bool): + """점심시간 사용 여부 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET lunch_break = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (lunch_break, date)) + + conn.commit() + conn.close() + + def update_dinner_break(self, date: str, dinner_break: bool): + """저녁시간 사용 여부 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET dinner_break = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (dinner_break, date)) + + conn.commit() + conn.close() + + def delete_work_record(self, date: str): + """특정 날짜의 근무 기록 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + # 먼저 해당 기록의 ID 조회 + cursor.execute('SELECT id FROM work_records WHERE date = ?', (date,)) + record = cursor.fetchone() + + if record: + record_id = record[0] + + # 연관된 연장근무 적립 기록 삭제 + cursor.execute('DELETE FROM overtime_bank WHERE work_record_id = ?', (record_id,)) + + # 연관된 연장근무 사용 기록 삭제 + cursor.execute('DELETE FROM overtime_usage WHERE work_record_id = ?', (record_id,)) + + # 근무 기록 삭제 + cursor.execute('DELETE FROM work_records WHERE id = ?', (record_id,)) + + conn.commit() + conn.close() + + def get_work_records_by_range(self, start_date: str, end_date: str) -> List[Dict]: + """기간별 근무 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM work_records + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + ''', (start_date, end_date)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + # ===== 연장근무 관련 메서드 ===== + + def add_overtime_earned(self, work_record_id: int, earned_minutes: int, date: str): + """연장근무 적립""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (?, ?, ?) + ''', (work_record_id, earned_minutes, date)) + + conn.commit() + conn.close() + + def add_overtime_usage(self, work_record_id: int, used_minutes: int, + date: str, reason: str = None): + """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" + conn = self.get_connection() + cursor = conn.cursor() + + try: + cursor.execute(''' + INSERT INTO overtime_usage (work_record_id, used_minutes, date, reason) + VALUES (?, ?, ?, ?) + ''', (work_record_id, used_minutes, date, reason)) + + # work_records 테이블도 업데이트 (work_record_id가 있을 때만) + if work_record_id is not None: + cursor.execute(''' + UPDATE work_records + SET overtime_used = overtime_used + ? + WHERE id = ? + ''', (used_minutes, work_record_id)) + + conn.commit() + except Exception as e: + conn.rollback() + raise e + finally: + conn.close() + + def get_total_overtime_balance(self) -> int: + """총 연장근무 잔액 조회 (초기값 + 적립 - 사용)""" + conn = self.get_connection() + cursor = conn.cursor() + + # 초기값 (프로그램 사용 전 쌓인 연장근무) + initial_overtime = int(self.get_setting(INITIAL_OVERTIME_MINUTES, '0')) + + # 단일 쿼리로 적립과 사용을 동시에 조회 (원자성 보장) + cursor.execute(''' + SELECT + COALESCE((SELECT SUM(earned_minutes) FROM overtime_bank), 0) - + COALESCE((SELECT SUM(used_minutes) FROM overtime_usage), 0) AS balance + ''') + balance = cursor.fetchone()[0] + + conn.close() + + return initial_overtime + balance + + def get_today_overtime_usage(self) -> int: + """오늘 사용한 추가근무 시간 조회 (분)""" + from datetime import date + + today = date.today().isoformat() + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT SUM(used_minutes) + FROM overtime_usage + WHERE date = ? + ''', (today,)) + + used = cursor.fetchone()[0] or 0 + conn.close() + + return used + + def get_today_leave_minutes(self) -> int: + """오늘 사용한 연차/반차 시간 조회 (분)""" + from datetime import date + + today = date.today().isoformat() + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT SUM(days) + FROM leave_records + WHERE date = ? + ''', (today,)) + + days = cursor.fetchone()[0] or 0.0 + conn.close() + + return int(days * self.get_work_minutes()) + + def add_initial_overtime_balance(self, minutes: int): + """초기 연장근무 잔액 추가""" + from datetime import datetime + + conn = self.get_connection() + cursor = conn.cursor() + + today = datetime.now().date().isoformat() + + # work_record_id 없이 직접 추가 + cursor.execute(''' + INSERT INTO overtime_bank (work_record_id, earned_minutes, date) + VALUES (NULL, ?, ?) + ''', (minutes, today)) + + conn.commit() + conn.close() + + def get_overtime_history(self, limit: int = 30) -> List[Dict]: + """연장근무 내역 조회 (적립 + 사용)""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT 'earned' as type, earned_minutes as minutes, date, + wr.clock_in, wr.clock_out + FROM overtime_bank ob + LEFT JOIN work_records wr ON ob.work_record_id = wr.id + UNION ALL + SELECT 'used' as type, used_minutes as minutes, date, + wr.clock_in, wr.clock_out + FROM overtime_usage ou + LEFT JOIN work_records wr ON ou.work_record_id = wr.id + ORDER BY date DESC + LIMIT ? + ''', (limit,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + # ===== 휴가 관련 메서드 ===== + + def add_leave_record(self, date: str, leave_type: str, days: float, memo: str = None): + """휴가 기록 추가""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO leave_records (date, leave_type, days, memo) + VALUES (?, ?, ?, ?) + ''', (date, leave_type, days, memo)) + + conn.commit() + conn.close() + + def get_leave_records(self, start_date: str = None, end_date: str = None, exclude_bulk: bool = False) -> List[Dict]: + """휴가 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + if start_date and end_date: + if exclude_bulk: + cursor.execute(''' + SELECT * FROM leave_records + WHERE date BETWEEN ? AND ? + AND COALESCE(memo, '') != '이전 사용분 일괄 추가' + ORDER BY date DESC + ''', (start_date, end_date)) + else: + cursor.execute(''' + SELECT * FROM leave_records + WHERE date BETWEEN ? AND ? + ORDER BY date DESC + ''', (start_date, end_date)) + else: + if exclude_bulk: + cursor.execute(''' + SELECT * FROM leave_records + WHERE COALESCE(memo, '') != '이전 사용분 일괄 추가' + ORDER BY date DESC + ''') + else: + cursor.execute('SELECT * FROM leave_records ORDER BY date DESC') + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_annual_leave_balance(self) -> Tuple[float, float]: + """연차 잔여 조회 (총 연차, 사용한 연차) + + Note: + 현재 UI에서는 get_leave_balance()만 사용됨. + 이 메서드는 leave_records 테이블에서 직접 계산하므로 + settings.leave_balance와 불일치할 수 있음. + 향후 연차 관리 기능 개선 시 활용 가능. + """ + total = float(self.get_setting(ANNUAL_LEAVE_TOTAL, '15')) + + conn = self.get_connection() + cursor = conn.cursor() + + # manual 타입이 아닌 모든 연차 사용 기록 합산 + cursor.execute(''' + SELECT SUM(days) FROM leave_records + WHERE leave_type IS NULL OR leave_type NOT IN ('manual', 'bulk') + ''') + + used = cursor.fetchone()[0] or 0 + conn.close() + + return total, used + + # ===== 설정 관련 메서드 ===== + + def get_setting(self, key: str, default: str = None) -> str: + """설정 값 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) + row = cursor.fetchone() + conn.close() + + if row: + return row[0] + return default + + def set_setting(self, key: str, value: str): + """설정 값 저장""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ''', (key, value)) + + conn.commit() + conn.close() + + # ===== 통계 관련 메서드 ===== + + def get_weekly_stats(self) -> Dict: + """주간 통계""" + from datetime import datetime, timedelta + + today = datetime.now().date() + week_ago = today - timedelta(days=7) + + records = self.get_work_records_by_range(week_ago.isoformat(), today.isoformat()) + + total_hours = sum(r.get('total_hours', 0) or 0 for r in records) + total_overtime = sum(r.get('overtime_minutes', 0) or 0 for r in records) + work_days = len([r for r in records if r.get('clock_out')]) + + return { + 'total_hours': total_hours, + 'total_overtime_minutes': total_overtime, + 'work_days': work_days, + 'avg_hours_per_day': total_hours / work_days if work_days > 0 else 0 + } + + def get_consecutive_overtime_days(self, threshold_minutes: int = 30) -> int: + """오늘부터 거꾸로 연속 연장근무한 일수. + + Args: + threshold_minutes: 연장근무로 카운트할 최소 분 (기본 30분) + Returns: + 연속 일수 (오늘 미적립이거나 휴무일이면 0) + """ + from datetime import date, timedelta + count = 0 + d = date.today() + for _ in range(60): # 최대 60일까지만 거슬러 검사 + rec = self.get_work_record(d.isoformat()) + if not rec or not rec.get('clock_out'): + break + if (rec.get('overtime_minutes') or 0) < threshold_minutes: + break + count += 1 + d -= timedelta(days=1) + return count + + def get_monthly_stats(self, year: int, month: int) -> Dict: + """월간 통계""" + from calendar import monthrange + + start_date = f"{year}-{month:02d}-01" + last_day = monthrange(year, month)[1] + end_date = f"{year}-{month:02d}-{last_day}" + + records = self.get_work_records_by_range(start_date, end_date) + + total_hours = sum(r.get('total_hours', 0) or 0 for r in records) + total_overtime = sum(r.get('overtime_minutes', 0) or 0 for r in records) + work_days = len([r for r in records if r.get('clock_out')]) + + return { + 'year': year, + 'month': month, + 'total_hours': total_hours, + 'total_overtime_minutes': total_overtime, + 'work_days': work_days, + 'records': records + } + + # ===== 휴가 관련 메서드 (중복 제거됨 - 356줄의 함수 사용) ===== + + def get_leave_record(self, date: str) -> Optional[Dict]: + """특정 날짜의 휴가 기록 조회""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM leave_records WHERE date = ? + ''', (date,)) + + row = cursor.fetchone() + conn.close() + + return dict(row) if row else None + + def get_all_leave_records(self, limit: int = 100) -> List[Dict]: + """모든 휴가 기록 조회 (최신순)""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM leave_records + ORDER BY date DESC + LIMIT ? + ''', (limit,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def delete_leave_record(self, leave_id: int): + """휴가 기록 삭제""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('DELETE FROM leave_records WHERE id = ?', (leave_id,)) + + conn.commit() + conn.close() + + # ===== 설정 관련 메서드 ===== + + def get_settings(self) -> Dict: + """설정 가져오기""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute('SELECT * FROM settings') + rows = cursor.fetchall() + conn.close() + + # 딕셔너리로 변환 + settings = {} + for row in rows: + key = row['key'] + value = row['value'] + + # 타입 변환 + if value.lower() in ['true', 'false']: + settings[key] = value.lower() == 'true' + else: + # 정수 변환 시도 (음수 포함) + try: + settings[key] = int(value) + except ValueError: + # float 변환 시도 + try: + settings[key] = float(value) + except ValueError: + settings[key] = value + + return settings + + def save_settings(self, settings: Dict): + """설정 저장. + + 키 동기화 처리: + - work_minutes 저장 시 work_hours도 갱신 (호환성) + - work_hours 저장 시 work_minutes도 갱신 + - annual_leave_days ↔ annual_leave_total 양방향 동기화 + """ + # 동기화 키 미리 보강 (호출자가 일부만 줘도 양쪽 다 저장) + synced = dict(settings) + + if 'work_minutes' in synced and 'work_hours' not in synced: + try: + # floor로 통일 (settings_view와 일관성: 450분 → 7시간) + synced['work_hours'] = int(float(synced['work_minutes'])) // 60 + except (ValueError, TypeError): + pass + elif 'work_hours' in synced and 'work_minutes' not in synced: + try: + synced['work_minutes'] = int(round(float(synced['work_hours']) * 60)) + except (ValueError, TypeError): + pass + + if 'annual_leave_days' in synced and 'annual_leave_total' not in synced: + synced['annual_leave_total'] = synced['annual_leave_days'] + elif 'annual_leave_total' in synced and 'annual_leave_days' not in synced: + synced['annual_leave_days'] = synced['annual_leave_total'] + + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + for key, value in synced.items(): + value_str = str(value) + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value) + VALUES (?, ?) + ''', (key, value_str)) + + conn.commit() + conn.close() + + # ===== 외출 관련 메서드 ===== + + def add_break_record(self, work_record_id: int, date: str, break_out: str, reason: str = None) -> int: + """외출 기록 추가""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO break_records (work_record_id, date, break_out, reason) + VALUES (?, ?, ?, ?) + ''', (work_record_id, date, break_out, reason)) + + record_id = cursor.lastrowid + conn.commit() + conn.close() + return record_id + + def update_break_return(self, break_id: int, break_in: str): + """외출 복귀 시간 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + # 복귀 시간 업데이트 + cursor.execute(''' + UPDATE break_records + SET break_in = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_in, break_id)) + + # 총 외출 시간 계산 + cursor.execute(''' + SELECT break_out, break_in FROM break_records WHERE id = ? + ''', (break_id,)) + row = cursor.fetchone() + + if row and row['break_out'] and row['break_in']: + from datetime import datetime, timedelta + break_out_time = datetime.strptime(row['break_out'], "%H:%M:%S") + break_in_time = datetime.strptime(row['break_in'], "%H:%M:%S") + + # 복귀 시간이 외출 시간보다 이전이면 자정을 넘긴 것으로 판단 + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) # 복귀는 다음 날로 처리 + + total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + + # 음수 방지 (혹시 모를 케이스) + if total_minutes < 0: + total_minutes = 0 + + cursor.execute(''' + UPDATE break_records + SET total_minutes = ? + WHERE id = ? + ''', (total_minutes, break_id)) + + conn.commit() + conn.close() + + def get_today_break_records(self) -> List[Dict]: + """오늘의 외출 기록 조회""" + from datetime import date + today = date.today().isoformat() + return self.get_break_records_by_date(today) + + def get_break_records_by_date(self, date: str) -> List[Dict]: + """특정 날짜의 외출 기록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM break_records + WHERE date = ? + ORDER BY break_out ASC + ''', (date,)) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_active_break_record(self, target_date: str = None) -> Optional[Dict]: + """현재 진행 중인 외출 기록 조회 (복귀하지 않은 외출) + + Args: + target_date: 조회할 날짜 (YYYY-MM-DD), None이면 오늘 + """ + from datetime import date + if target_date is None: + target_date = date.today().isoformat() + + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM break_records + WHERE date = ? AND break_in IS NULL + ORDER BY break_out DESC + LIMIT 1 + ''', (target_date,)) + + row = cursor.fetchone() + conn.close() + + if row: + return dict(row) + return None + + def update_break_record(self, break_id: int, break_out: str, break_in: str = None, reason: str = None): + """외출 기록 수정""" + conn = self.get_connection() + cursor = conn.cursor() + + if break_in: + # 총 외출 시간 계산 + from datetime import datetime, timedelta + break_out_time = datetime.strptime(break_out, "%H:%M:%S") + break_in_time = datetime.strptime(break_in, "%H:%M:%S") + + # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 + if break_in_time < break_out_time: + break_in_time += timedelta(days=1) + + total_minutes = int((break_in_time - break_out_time).total_seconds() / 60) + + # 음수 방지 (혹시 모를 케이스) + if total_minutes < 0: + total_minutes = 0 + + cursor.execute(''' + UPDATE break_records + SET break_out = ?, break_in = ?, total_minutes = ?, reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_out, break_in, total_minutes, reason, break_id)) + else: + cursor.execute(''' + UPDATE break_records + SET break_out = ?, reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (break_out, reason, break_id)) + + conn.commit() + conn.close() + + def delete_break_record(self, break_id: int): + """외출 기록 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,)) + + conn.commit() + conn.close() + + def get_total_break_minutes_today(self) -> int: + """오늘의 총 외출 시간 (분), 진행 중인 외출 포함""" + from datetime import date, datetime + today = date.today().isoformat() + + conn = self.get_connection() + cursor = conn.cursor() + + # 완료된 외출 시간 합계 + cursor.execute(''' + SELECT SUM(total_minutes) FROM break_records + WHERE date = ? AND total_minutes IS NOT NULL + ''', (today,)) + + total = cursor.fetchone()[0] or 0 + + # 진행 중인 외출 시간 계산 + cursor.execute(''' + SELECT break_out FROM break_records + WHERE date = ? AND break_in IS NULL + ORDER BY break_out DESC + LIMIT 1 + ''', (today,)) + + active_break = cursor.fetchone() + if active_break: + break_out_str = active_break[0] + now = datetime.now() + break_out_time = datetime.strptime(f"{today} {break_out_str}", "%Y-%m-%d %H:%M:%S") + active_minutes = int((now - break_out_time).total_seconds() / 60) + total += active_minutes + + conn.close() + + return total + + def update_work_memo(self, date: str, memo: str): + """근무 기록 메모 업데이트""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + UPDATE work_records + SET memo = ?, updated_at = CURRENT_TIMESTAMP + WHERE date = ? + ''', (memo, date)) + + conn.commit() + conn.close() + + def get_leave_balance(self) -> float: + """연차 잔여 개수 조회 (총 연차 - 초기 사용량 - 프로그램 기록 사용량)""" + from datetime import datetime + + # 총 연차 일수 + total_annual = int(self.get_setting(ANNUAL_LEAVE_DAYS, '15')) + + # 초기 사용 연차 (프로그램 사용 전) + initial_leave_hours = float(self.get_setting(INITIAL_LEAVE_USED_HOURS, '0')) + initial_leave_days = initial_leave_hours / 8.0 + + # 올해 프로그램에서 기록된 사용량 + current_year = datetime.now().year + all_leaves = self.get_all_leave_records(limit=365) + year_leaves = [r for r in all_leaves if r['date'].startswith(str(current_year))] + used_days = sum(r['days'] for r in year_leaves) + + # 잔여 = 총 - 초기사용 - 프로그램기록 + return total_annual - initial_leave_days - used_days + + def set_leave_balance(self, balance: float): + """연차 잔여 개수 설정""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES ('leave_balance', ?, CURRENT_TIMESTAMP) + ''', (str(balance),)) + + conn.commit() + conn.close() + + def use_leave(self, days: float, date: str, leave_type: str = "연차", memo: str = None): + """연차 사용 + + Args: + days: 사용할 연차 일수 (예: 1.0=하루, 0.5=반차, 0.25=반반차) + + Note: + leave_records 테이블의 'days' 컬럼에 일수를 저장함 + 예: 1.0 = 1일, 0.5 = 반차, 0.125 = 1시간(8분의 1일) + """ + current_balance = self.get_leave_balance() + + if current_balance < days: + raise ValueError(f"연차 잔여 개수가 부족합니다. (잔여: {current_balance}일)") + + conn = self.get_connection() + cursor = conn.cursor() + + # 연차 기록 추가 + cursor.execute(''' + INSERT INTO leave_records (date, leave_type, days, memo) + VALUES (?, ?, ?, ?) + ''', (date, leave_type, days, memo)) + + conn.commit() + conn.close() + + # 잔여 개수 차감 + self.set_leave_balance(current_balance - days) + + # ===== 공휴일 관련 메서드 ===== + + def add_holiday(self, date: str, name: str, is_recurring: bool = False) -> int: + """공휴일 추가 + + Args: + date: 공휴일 날짜 (YYYY-MM-DD) + name: 공휴일 이름 + is_recurring: 매년 반복 여부 (음력 명절 등은 False) + + Returns: + int: 추가된 공휴일 ID + """ + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO holidays (date, name, is_recurring, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ''', (date, name, is_recurring)) + + holiday_id = cursor.lastrowid + conn.commit() + conn.close() + + return holiday_id + + def delete_holiday(self, holiday_id: int): + """공휴일 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('DELETE FROM holidays WHERE id = ?', (holiday_id,)) + + conn.commit() + conn.close() + + def delete_holiday_by_date(self, date: str): + """날짜로 공휴일 삭제""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute('DELETE FROM holidays WHERE date = ?', (date,)) + + conn.commit() + conn.close() + + def is_holiday(self, date: str) -> bool: + """해당 날짜가 공휴일인지 확인 + + Args: + date: 확인할 날짜 (YYYY-MM-DD) + + Returns: + bool: 공휴일이면 True + """ + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT id FROM holidays WHERE date = ? + ''', (date,)) + + result = cursor.fetchone() + conn.close() + + return result is not None + + def get_holiday(self, date: str) -> Optional[Dict]: + """해당 날짜의 공휴일 정보 조회 + + Args: + date: 조회할 날짜 (YYYY-MM-DD) + + Returns: + Dict: 공휴일 정보 또는 None + """ + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM holidays WHERE date = ? + ''', (date,)) + + row = cursor.fetchone() + conn.close() + + if row: + return dict(row) + return None + + def get_holidays_by_year(self, year: int) -> List[Dict]: + """해당 연도의 공휴일 목록 조회 + + Args: + year: 조회할 연도 + + Returns: + List[Dict]: 공휴일 목록 + """ + conn = self.get_connection() + cursor = conn.cursor() + + # LIKE 대신 정확한 날짜 범위 비교 사용 (더 효율적) + cursor.execute(''' + SELECT * FROM holidays + WHERE date >= ? AND date < ? + ORDER BY date ASC + ''', (f"{year}-01-01", f"{year + 1}-01-01")) + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def get_all_holidays(self) -> List[Dict]: + """모든 공휴일 목록 조회""" + conn = self.get_connection() + cursor = conn.cursor() + + cursor.execute(''' + SELECT * FROM holidays + ORDER BY date ASC + ''') + + rows = cursor.fetchall() + conn.close() + + return [dict(row) for row in rows] + + def add_korean_holidays(self, year: int): + """한국 공휴일 일괄 추가 (고정 공휴일만) + + Args: + year: 추가할 연도 + + Note: + 음력 기반 명절(설날, 추석)은 매년 날짜가 변경되므로 + 수동으로 추가해야 합니다. + """ + fixed_holidays = [ + (f"{year}-01-01", "신정"), + (f"{year}-03-01", "삼일절"), + (f"{year}-05-05", "어린이날"), + (f"{year}-06-06", "현충일"), + (f"{year}-08-15", "광복절"), + (f"{year}-10-03", "개천절"), + (f"{year}-10-09", "한글날"), + (f"{year}-12-25", "크리스마스"), + ] + + for date, name in fixed_holidays: + self.add_holiday(date, name, is_recurring=True) + + def add_korean_holidays_auto(self, year: int) -> int: + """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. + + Returns: + 추가된 공휴일 개수. 패키지 미설치 시 -1. + """ + try: + import holidays as _holidays + except ImportError: + return -1 + + kr = _holidays.country_holidays('KR', years=year) + added = 0 + for d, name in kr.items(): + date_str = d.isoformat() + # 이미 등록된 동일 날짜는 스킵 (중복 방지) + if not self.is_holiday(date_str): + self.add_holiday(date_str, name, is_recurring=False) + added += 1 + return added + + def copy_recurring_holidays(self, from_year: int, to_year: int): + """반복 공휴일을 다음 연도로 복사 + + Args: + from_year: 복사할 원본 연도 + to_year: 복사 대상 연도 + """ + holidays = self.get_holidays_by_year(from_year) + + for holiday in holidays: + if holiday['is_recurring']: + new_date = holiday['date'].replace(str(from_year), str(to_year)) + self.add_holiday(new_date, holiday['name'], is_recurring=True) diff --git a/core/event_monitor.py b/core/event_monitor.py new file mode 100644 index 0000000..c0f5744 --- /dev/null +++ b/core/event_monitor.py @@ -0,0 +1,359 @@ +""" +Windows 이벤트 뷰어 모니터 +시스템 부팅, 로그인, 종료 시간을 감지 +""" +import win32evtlog +import win32evtlogutil +import win32con +import win32security +import subprocess +from datetime import datetime, timedelta, date +from typing import Optional, List, Dict + + +class EventMonitor: + """Windows 이벤트 로그 모니터링 클래스""" + + # 이벤트 ID 정의 + EVENT_SYSTEM_BOOT = 6005 # 시스템 부팅 + EVENT_SYSTEM_START = 6009 # 시스템 시작 + EVENT_USER_LOGON = 4624 # 사용자 로그인 + EVENT_SYSTEM_SHUTDOWN = 6006 # 시스템 종료 + EVENT_SLEEP_ENTER = 42 # 절전 모드 진입 (Kernel-Power) + + def __init__(self): + self.server = None # 로컬 컴퓨터 + self.logtype = "System" # 시스템 로그 + + def get_boot_time_powershell(self) -> Optional[datetime]: + """ + PowerShell을 사용하여 시스템 부팅 시간 조회 + 오늘 부팅한 경우에만 반환 + Returns: + datetime: 오늘의 부팅 시간, 오늘 부팅 안 했으면 None + """ + try: + # PowerShell 명령어 실행 + result = subprocess.run( + ['powershell', '-Command', + '(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime.ToString("yyyy-MM-dd HH:mm:ss")'], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0 and result.stdout.strip(): + boot_time_str = result.stdout.strip() + # "yyyy-MM-dd HH:mm:ss" 형식으로 파싱 + boot_time = datetime.strptime(boot_time_str, "%Y-%m-%d %H:%M:%S") + + # 오늘 부팅한 경우에만 반환 + today = datetime.now().date() + if boot_time.date() == today: + return boot_time + else: + # 오늘 부팅하지 않음 (절전 모드에서 깨어난 경우) + print(f"Last boot was on {boot_time.date()}, not today ({today})") + return None + + except Exception as e: + print(f"PowerShell boot time query failed: {e}") + + return None + + def get_today_boot_time(self) -> Optional[datetime]: + """ + 오늘 첫 부팅 시간 조회 (절전 모드 복귀 포함) + 오늘의 가장 빠른 시스템 시작 시간을 반환 + Returns: + datetime: 첫 부팅/시작 시간, 없으면 None + """ + today = datetime.now().date() + earliest_time = None + + # Kernel-General (Event ID 1, 12) - 시스템 시작/재개 + kernel_general_events = self._get_events_by_id(1, limit=50) + for event in kernel_general_events: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + # Event ID 12도 확인 + kernel_general_12 = self._get_events_by_id(12, limit=50) + for event in kernel_general_12: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + # Kernel-General에서 찾았으면 반환 + if earliest_time: + return earliest_time + + # 못 찾았으면 전통적인 부팅 이벤트 확인 + # 6005 - 시스템 부팅 + boot_events = self._get_events_by_id(self.EVENT_SYSTEM_BOOT, limit=10) + for event in boot_events: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + # 6009 - 시스템 시작 + start_events = self._get_events_by_id(self.EVENT_SYSTEM_START, limit=10) + for event in start_events: + event_time = event['time'] + if event_time.date() == today: + if earliest_time is None or event_time < earliest_time: + earliest_time = event_time + + return earliest_time + + def get_last_shutdown_time(self) -> Optional[datetime]: + """ + 마지막 종료 시간 조회 + Returns: + datetime: 마지막 종료 시간, 없으면 None + """ + shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=5) + + if shutdown_events: + return shutdown_events[0]['time'] + + return None + + def get_yesterday_shutdown_time(self) -> Optional[datetime]: + """ + 어제의 종료 시간 조회 (퇴근 시간으로 사용) + Returns: + datetime: 어제의 종료 시간, 없으면 None + """ + yesterday = (datetime.now().date() - timedelta(days=1)) + shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=20) + + for event in shutdown_events: + event_time = event['time'] + if event_time.date() == yesterday: + return event_time + + return None + + def get_shutdown_time_by_date(self, target_date: date) -> Optional[datetime]: + """ + 특정 날짜의 종료 시간 조회 (정상 종료 + 절전 모드) + Args: + target_date: 조회할 날짜 + Returns: + datetime: 해당 날짜의 종료 시간, 없으면 None + """ + # 정상 종료 이벤트 검색 + shutdown_events = self._get_events_by_id(self.EVENT_SYSTEM_SHUTDOWN, limit=2000) + + # 절전 모드 진입 이벤트도 검색 + sleep_events = self._get_events_by_id(self.EVENT_SLEEP_ENTER, limit=2000) + + # 해당 날짜의 모든 종료/절전 시간을 찾아서 가장 늦은 시간 반환 + matching_events = [] + + # 정상 종료 이벤트 확인 + for event in shutdown_events: + event_time = event['time'] + if event_time.date() == target_date: + matching_events.append(('shutdown', event_time)) + + # 절전 모드 이벤트 확인 + for event in sleep_events: + event_time = event['time'] + if event_time.date() == target_date: + matching_events.append(('sleep', event_time)) + + if matching_events: + # 가장 늦은 시간 반환 (타입에 관계없이) + latest_type, latest_time = max(matching_events, key=lambda x: x[1]) + return latest_time + + return None + + def get_today_logon_time(self) -> Optional[datetime]: + """ + 오늘 첫 로그인 시간 조회 (보조 수단) + Returns: + datetime: 첫 로그인 시간, 없으면 None + """ + try: + today = datetime.now().date() + hand = win32evtlog.OpenEventLog(self.server, "Security") + flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ + + events = [] + total = 0 + + while total < 100: # 최근 100개만 확인 + event_list = win32evtlog.ReadEventLog(hand, flags, 0) + if not event_list: + break + + for event in event_list: + if event.EventID == self.EVENT_USER_LOGON: + event_time = datetime.fromtimestamp(int(event.TimeGenerated)) + if event_time.date() == today: + events.append(event_time) + + total += len(event_list) + + win32evtlog.CloseEventLog(hand) + + if events: + return min(events) # 가장 빠른 로그인 시간 + + except Exception as e: + print(f"로그인 이벤트 조회 실패: {e}") + + return None + + def _get_events_by_id(self, event_id: int, limit: int = 10) -> List[Dict]: + """ + 특정 이벤트 ID로 이벤트 조회 + Args: + event_id: 이벤트 ID + limit: 조회할 최대 개수 + Returns: + List[Dict]: 이벤트 목록 + """ + try: + hand = win32evtlog.OpenEventLog(self.server, self.logtype) + flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ + + events = [] + total = 0 + + while len(events) < limit and total < 10000: + event_list = win32evtlog.ReadEventLog(hand, flags, 0) + if not event_list: + break + + for event in event_list: + # 하위 16비트가 실제 이벤트 ID + actual_event_id = event.EventID & 0xFFFF + + if actual_event_id == event_id: + # pywintypes.datetime을 Python datetime으로 변환 + time_generated = event.TimeGenerated + if hasattr(time_generated, 'timestamp'): + # pywintypes.datetime인 경우 + event_time = datetime.fromtimestamp(time_generated.timestamp()) + else: + # 이미 타임스탬프인 경우 + event_time = datetime.fromtimestamp(int(time_generated)) + + events.append({ + 'event_id': actual_event_id, + 'time': event_time, + 'source': event.SourceName + }) + + if len(events) >= limit: + break + + total += len(event_list) + + win32evtlog.CloseEventLog(hand) + return events + + except Exception as e: + print(f"이벤트 조회 실패: {e}") + return [] + + def get_work_start_time(self) -> Optional[datetime]: + """ + 출근 시간 자동 감지 (부팅 또는 로그인 중 빠른 시간) + 우선순위: + 1. PowerShell을 통한 부팅 시간 (가장 확실) + 2. 이벤트 뷰어를 통한 부팅 시간 + 3. 이벤트 뷰어를 통한 로그인 시간 + Returns: + datetime: 출근 시간, 없으면 None + """ + # 1순위: PowerShell로 부팅 시간 조회 (가장 확실한 방법) + powershell_boot_time = self.get_boot_time_powershell() + if powershell_boot_time: + return powershell_boot_time + + # 2순위: 이벤트 뷰어로 부팅 시간 조회 + boot_time = self.get_today_boot_time() + if boot_time: + return boot_time + + # 3순위: 로그인 시간 조회 + logon_time = self.get_today_logon_time() + if logon_time: + return logon_time + + return None + + def test_event_log_access(self) -> bool: + """ + 이벤트 로그 접근 가능 여부 테스트 + Returns: + bool: 접근 가능하면 True + """ + try: + hand = win32evtlog.OpenEventLog(self.server, self.logtype) + win32evtlog.CloseEventLog(hand) + return True + except Exception as e: + print(f"이벤트 로그 접근 실패: {e}") + print("관리자 권한으로 실행해야 할 수 있습니다.") + return False + + +# 테스트 코드 +if __name__ == "__main__": + import sys + # UTF-8 출력 설정 + if sys.platform == 'win32': + import codecs + sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') + + monitor = EventMonitor() + + print("=== Windows Event Monitor Test ===\n") + + # PowerShell 부팅 시간 (1순위) + print("1. PowerShell Boot Time Check:") + powershell_boot = monitor.get_boot_time_powershell() + if powershell_boot: + print(f" [OK] Boot time: {powershell_boot.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print(" [X] PowerShell query failed") + + print("\n2. Event Log Access Check:") + if monitor.test_event_log_access(): + print(" [OK] Event log access successful") + else: + print(" [X] Event log access failed (try running as administrator)") + + print("\n3. Event Viewer Boot Time Check:") + boot_time = monitor.get_today_boot_time() + if boot_time: + print(f" [OK] Boot time: {boot_time.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print(" [X] No boot record found today") + + print("\n4. Event Viewer Logon Time Check:") + logon_time = monitor.get_today_logon_time() + if logon_time: + print(f" [OK] Logon time: {logon_time.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print(" [X] No logon record found today") + + print("\n" + "="*50) + # 출근 시간 (자동 감지 - 최종 결과) + work_start = monitor.get_work_start_time() + if work_start: + print(f"FINAL: Detected work start time") + print(f" {work_start.strftime('%Y-%m-%d %H:%M:%S')}") + else: + print("FINAL: Work start time detection FAILED") + print("="*50) diff --git a/core/i18n.py b/core/i18n.py new file mode 100644 index 0000000..44f0896 --- /dev/null +++ b/core/i18n.py @@ -0,0 +1,568 @@ +""" +경량 i18n. + +`tr('key')` → 현재 언어의 번역 문자열. 키 미존재 시 ko 폴백, ko도 없으면 +키 그대로 반환 (미번역도 동작). 점진 도입 가능. + +새 언어 추가: +1. _DICT['en'] 옆에 'ja'/'zh' 등 추가 +2. 사용자가 설정 → 언어 콤보에서 선택 +""" +from __future__ import annotations + +_current_lang = 'ko' + +_DICT = { + 'ko': { + # === 메뉴/버튼 === + 'menu.stats': '통계', + 'menu.calendar': '캘린더', + 'menu.daily_report': '일일보고', + 'menu.help': '도움말', + 'menu.settings': '설정', + 'btn.clock_out': '퇴근하기', + 'btn.clock_out_cancel': '🔄 퇴근 취소', + 'btn.lunch_add': '점심시간 추가', + 'btn.lunch_applied': '점심시간 (적용됨)', + 'btn.dinner_add': '저녁시간 추가', + 'btn.dinner_applied': '저녁시간 (적용됨)', + 'btn.break_out': '🚪 외출 시작', + 'btn.break_in': '↩️ 복귀', + 'btn.save': '💾 저장', + 'btn.close': '닫기', + 'btn.apply': '적용', + 'btn.cancel': '취소', + 'btn.add': '추가', + 'btn.delete': '삭제', + 'btn.edit': '편집', + 'btn.confirm': '확인', + 'btn.ok': '확인', + + # === 윈도우/다이얼로그 제목 === + 'window.main_title': '퇴근시간 계산기', + 'window.settings': '⚙️ 설정', + 'window.help': '📖 사용 설명서', + 'window.stats': '📊 근무 통계', + 'window.calendar': '📅 캘린더', + 'window.mini_widget': '퇴근시간', + 'window.clock_in_dialog': '출근 시간', + 'window.break_view': '외출 관리', + 'window.overtime_view': '연장근무 관리', + 'window.leave_view': '연차 관리', + + # === 라벨 === + 'label.remaining': '남은 시간', + 'label.overtime_progress': '추가 근무 중', + 'label.expected_clock_out': '예상 퇴근', + 'label.clock_in_time': '출근 시간', + 'label.clock_out_time': '퇴근 시간', + 'label.work_hours_label': '하루 기본 근무:', + 'label.lunch_default': '점심시간 기본:', + 'label.dinner_default': '저녁시간 기본:', + 'label.work_pattern': '근무 패턴:', + 'label.time_format': '시간 형식:', + 'label.theme': '테마:', + 'label.language': '언어 / Language:', + 'label.unit_hour': '시간', + 'label.unit_minute': '분', + 'label.unit_day': '일', + 'label.unit_count': '개', + 'label.weekday_mon': '월', + 'label.weekday_tue': '화', + 'label.weekday_wed': '수', + 'label.weekday_thu': '목', + 'label.weekday_fri': '금', + 'label.weekday_sat': '토', + 'label.weekday_sun': '일', + 'label.am': '오전', + 'label.pm': '오후', + + # === 알림 === + 'notif.clock_out_soon.title': '⏰ 퇴근 시간 임박', + 'notif.clock_out_soon.body': '퇴근까지 {minutes}분 남았습니다.\n마무리 준비를 시작하세요!', + 'notif.lunch_reminder.title': '🍱 점심시간 등록', + 'notif.lunch_reminder.body': '점심시간을 등록하지 않으셨네요.\n점심시간을 추가하시겠어요?', + 'notif.overtime_earning.title': '🔥 연장근무 적립 예정', + 'notif.overtime_earning.body': '오늘 {time_str}의 연장근무가 적립될 예정입니다!', + 'notif.overtime_threshold.title': '💰 연장근무 적립 알림', + 'notif.overtime_threshold.body': '적립된 연장근무가 {hours:.1f}시간 입니다.\n사용을 고려해보세요!', + 'notif.health.title': '⚠️ 건강 경고', + 'notif.health.body': '{days}일 연속 연장근무 중입니다.\n건강을 챙기세요!', + 'notif.weekly_52.title': '🚨 주 52시간 초과', + 'notif.weekly_52.body': '이번 주 총 근무시간이 {hours:.1f}시간입니다.\n법정 근로시간을 초과했습니다!', + + # === 메시지박스 === + 'msg.save_success.title': '저장 완료', + 'msg.save_success.body': '설정이 저장되었습니다.', + 'msg.input_error.title': '입력 오류', + 'msg.work_min_too_small': '하루 기본 근무 시간은 최소 30분 이상이어야 합니다.', + 'msg.no_clock_in.title': '외출 불가', + 'msg.no_clock_in.body': '출근하지 않은 상태입니다.', + 'msg.already_break.body': '이미 외출 중입니다.', + 'msg.no_record.title': '기록 없음', + 'msg.no_record.body': '오늘 출근 기록이 없습니다.', + 'msg.confirm_delete.title': '삭제 확인', + 'msg.no_data.title': '데이터 없음', + + # === 트레이 === + 'tray.open': '프로그램 열기', + 'tray.mini_widget': '📌 미니 위젯', + 'tray.toggle_lunch': '🍱 점심시간 토글', + 'tray.quit': '종료', + 'tray.tooltip_remaining': '퇴근까지: {time}', + 'tray.tooltip_overtime': '추가 근무 중: {time}', + 'tray.background': '프로그램이 트레이에서 실행 중입니다.', + + # === 그룹 박스 === + 'group.work_time': '근무 시간 설정', + 'group.notification': '알림 및 표시 설정', + 'group.overtime': '연장근무 설정', + 'group.leave': '휴가 설정', + 'group.holiday': '공휴일 설정', + 'group.data': '데이터 관리', + + # === StatsView === + 'stats.title': '근무 통계', + 'stats.tab_weekly': '주간', + 'stats.tab_monthly': '월간', + 'stats.tab_pattern': '패턴 분석', + 'stats.weekly_summary': '이번 주 요약', + 'stats.monthly_summary': '이번 달 요약', + 'stats.total_hours': '총 근무시간:', + 'stats.work_days': '근무일수:', + 'stats.avg_hours': '평균 근무시간:', + 'stats.total_overtime': '총 연장근무:', + 'stats.pattern_insights': '근무 패턴 인사이트', + 'stats.analyzing': '데이터를 분석 중입니다...', + 'stats.no_data': '아직 충분한 데이터가 없습니다.', + + # === CalendarView === + 'cal.title': '월간 근무 캘린더', + 'cal.year_label': '년', + 'cal.month_label': '월', + 'cal.prev': '◀ 이전', + 'cal.next': '다음 ▶', + 'cal.today': '오늘', + 'cal.no_record': '기록 없음', + 'cal.edit_record': '기록 편집', + + # === HelpView (각 탭의 큰 HTML은 별도 키) === + 'help.tab_intro': '👋 시작하기', + 'help.tab_work_hours': '🕘 근무시간', + 'help.tab_overtime': '🏦 연장근무', + 'help.tab_leave': '🌴 연차/휴가', + 'help.tab_break': '🚪 외출/저녁', + 'help.tab_faq': '❓ 자주 묻는 질문', + }, + 'en': { + # === Menu/Buttons === + 'menu.stats': 'Stats', + 'menu.calendar': 'Calendar', + 'menu.daily_report': 'Daily Report', + 'menu.help': 'Help', + 'menu.settings': 'Settings', + 'btn.clock_out': 'Clock Out', + 'btn.clock_out_cancel': '🔄 Cancel Clock-out', + 'btn.lunch_add': 'Add Lunch', + 'btn.lunch_applied': 'Lunch (Applied)', + 'btn.dinner_add': 'Add Dinner', + 'btn.dinner_applied': 'Dinner (Applied)', + 'btn.break_out': '🚪 Start Break', + 'btn.break_in': '↩️ Return', + 'btn.save': '💾 Save', + 'btn.close': 'Close', + 'btn.apply': 'Apply', + 'btn.cancel': 'Cancel', + 'btn.add': 'Add', + 'btn.delete': 'Delete', + 'btn.edit': 'Edit', + 'btn.confirm': 'Confirm', + 'btn.ok': 'OK', + + # === Windows === + 'window.main_title': 'Clock-out Time Calculator', + 'window.settings': '⚙️ Settings', + 'window.help': '📖 User Guide', + 'window.stats': '📊 Statistics', + 'window.calendar': '📅 Calendar', + 'window.mini_widget': 'Clock-out', + 'window.clock_in_dialog': 'Clock-in Time', + 'window.break_view': 'Break Management', + 'window.overtime_view': 'Overtime Management', + 'window.leave_view': 'Leave Management', + + # === Labels === + 'label.remaining': 'Remaining', + 'label.overtime_progress': 'Overtime', + 'label.expected_clock_out': 'Expected Clock-out', + 'label.clock_in_time': 'Clock-in Time', + 'label.clock_out_time': 'Clock-out Time', + 'label.work_hours_label': 'Daily work:', + 'label.lunch_default': 'Lunch break:', + 'label.dinner_default': 'Dinner break:', + 'label.work_pattern': 'Work pattern:', + 'label.time_format': 'Time format:', + 'label.theme': 'Theme:', + 'label.language': 'Language / 언어:', + 'label.unit_hour': 'h', + 'label.unit_minute': 'min', + 'label.unit_day': 'day(s)', + 'label.unit_count': '', + 'label.weekday_mon': 'Mon', + 'label.weekday_tue': 'Tue', + 'label.weekday_wed': 'Wed', + 'label.weekday_thu': 'Thu', + 'label.weekday_fri': 'Fri', + 'label.weekday_sat': 'Sat', + 'label.weekday_sun': 'Sun', + 'label.am': 'AM', + 'label.pm': 'PM', + + # === Notifications === + 'notif.clock_out_soon.title': '⏰ Clock-out Soon', + 'notif.clock_out_soon.body': "{minutes} minutes until clock-out.\nWrap things up!", + 'notif.lunch_reminder.title': '🍱 Lunch Reminder', + 'notif.lunch_reminder.body': "You haven't registered lunch yet.\nWant to add it?", + 'notif.overtime_earning.title': '🔥 Overtime Will Accrue', + 'notif.overtime_earning.body': "{time_str} of overtime will be banked today!", + 'notif.overtime_threshold.title': '💰 Overtime Balance High', + 'notif.overtime_threshold.body': "{hours:.1f} hours of overtime banked.\nConsider using some.", + 'notif.health.title': '⚠️ Health Warning', + 'notif.health.body': "{days} consecutive days of overtime.\nTake care of your health!", + 'notif.weekly_52.title': '🚨 Weekly 52h Exceeded', + 'notif.weekly_52.body': "This week's total work hours: {hours:.1f}\nLegal limit exceeded!", + + # === Message Boxes === + 'msg.save_success.title': 'Saved', + 'msg.save_success.body': 'Settings saved successfully.', + 'msg.input_error.title': 'Input Error', + 'msg.work_min_too_small': 'Daily work time must be at least 30 minutes.', + 'msg.no_clock_in.title': 'Cannot Take Break', + 'msg.no_clock_in.body': 'Not clocked in.', + 'msg.already_break.body': 'Already on break.', + 'msg.no_record.title': 'No Record', + 'msg.no_record.body': 'No clock-in record for today.', + 'msg.confirm_delete.title': 'Confirm Delete', + 'msg.no_data.title': 'No Data', + + # === Tray === + 'tray.open': 'Open Program', + 'tray.mini_widget': '📌 Mini Widget', + 'tray.toggle_lunch': '🍱 Toggle Lunch', + 'tray.quit': 'Quit', + 'tray.tooltip_remaining': 'Until clock-out: {time}', + 'tray.tooltip_overtime': 'Overtime: {time}', + 'tray.background': 'Program is running in the tray.', + + # === Groups === + 'group.work_time': 'Work Time Settings', + 'group.notification': 'Notifications & Display', + 'group.overtime': 'Overtime Settings', + 'group.leave': 'Leave Settings', + 'group.holiday': 'Holidays', + 'group.data': 'Data Management', + + # === StatsView === + 'stats.title': 'Work Statistics', + 'stats.tab_weekly': 'Weekly', + 'stats.tab_monthly': 'Monthly', + 'stats.tab_pattern': 'Patterns', + 'stats.weekly_summary': 'This Week', + 'stats.monthly_summary': 'This Month', + 'stats.total_hours': 'Total hours:', + 'stats.work_days': 'Work days:', + 'stats.avg_hours': 'Avg hours/day:', + 'stats.total_overtime': 'Total overtime:', + 'stats.pattern_insights': 'Work Pattern Insights', + 'stats.analyzing': 'Analyzing...', + 'stats.no_data': 'Not enough data yet.', + + # === CalendarView === + 'cal.title': 'Monthly Calendar', + 'cal.year_label': 'Y', + 'cal.month_label': 'M', + 'cal.prev': '◀ Prev', + 'cal.next': 'Next ▶', + 'cal.today': 'Today', + 'cal.no_record': 'No record', + 'cal.edit_record': 'Edit record', + + # === HelpView === + 'help.tab_intro': '👋 Getting Started', + 'help.tab_work_hours': '🕘 Work Hours', + 'help.tab_overtime': '🏦 Overtime', + 'help.tab_leave': '🌴 Leave', + 'help.tab_break': '🚪 Break/Dinner', + 'help.tab_faq': '❓ FAQ', + }, +} + + +# === HelpView 큰 HTML 콘텐츠 (별도 사전) === +_HELP_HTML = { + 'ko': { + 'help.html.intro': """ +

👋 환영합니다!

+

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

+ +

한눈에 보는 기본 흐름

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

처음 켰다면 꼭 확인하세요

+
    +
  • 설정 → 근무 시간: 본인 근무 패턴(8시간 / 단축 7h30m / 6시간 등)을 + 프리셋에서 선택하거나 직접 입력하세요.
  • +
  • 설정 → 휴가 → 연간 연차: 본인 연차 일수를 맞춰 두면 잔여 연차가 + 자동 계산됩니다.
  • +
  • 관리자 권한이 필요할 수 있어요. 부팅 시간이 자동 감지되지 않으면 + 관리자 권한으로 실행해 보세요.
  • +
+ """, + 'help.html.work_hours': """ +

🕘 근무시간 설정

+ +

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

+
    +
  • 표준 8시간 — 점심 60분 (기본값)
  • +
  • 단축근무 7시간 30분 — 점심 30분
  • +
  • 단축근무 7시간 — 점심 60분
  • +
  • 단축근무 6시간 — 점심 30분
  • +
  • 반일 4시간 — 점심 없음
  • +
  • 사용자 정의 — 시간/분 직접 입력 (5분 단위)
  • +
+ +

💡 단축근무 사용자 안내

+

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

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

점심시간 자동 적용

+

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

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

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

+ +

적립 규칙

+

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

+
    +
  • 1시간 35분 일했다면 → 1시간 30분 적립
  • +
  • 55분 일했다면 → 30분 적립
  • +
  • 29분 일했다면 → 0분 적립
  • +
+ +

사용 방법

+

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

+ +

주말·공휴일 근무

+

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

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

🌴 연차·반차 관리

+ +

연차 잔액 자동 계산

+

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

+ +

반차·반반차 지원

+
    +
  • 1.0일 — 종일 연차
  • +
  • 0.5일 — 반차 (4시간)
  • +
  • 0.25일 — 반반차 (2시간)
  • +
+ +

단축근무자의 연차 환산

+

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

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

🚪 외출 / 저녁시간

+ +

외출 (잠깐 자리 비움)

+

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

+ +

화면 잠금 자동 외출

+

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

+ +

저녁시간

+

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

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

❓ 자주 묻는 질문

+ +

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

+

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

+ +

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

+

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

+ +

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

+

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

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

👋 Welcome!

+

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

+ +

Basic Flow

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

First-Time Setup

+
    +
  • Settings → Work Time: pick your pattern from presets + (8h / 7h30m / 6h / 4h half-day) or enter custom values.
  • +
  • Settings → Leave: set your annual leave days.
  • +
  • Admin rights may be required for boot-time auto-detection.
  • +
+ """, + 'help.html.work_hours': """ +

🕘 Work Time Settings

+ +

Supports Standard / Reduced / Part-time

+
    +
  • Standard 8h — 60-min lunch (default)
  • +
  • Reduced 7h30m — 30-min lunch
  • +
  • Reduced 7h — 60-min lunch
  • +
  • Reduced 6h — 30-min lunch
  • +
  • Half-day 4h — no lunch
  • +
  • Custom — direct input (5-min granularity)
  • +
+ +

💡 For Reduced-Hours Users

+

Example: 7h30m daily + 30-min lunch

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

Auto Lunch

+

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

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

🏦 30-Minute Overtime Banking

+ +

Banking Rule

+

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

+
    +
  • 1h35m worked → 1h30m banked
  • +
  • 55min worked → 30min banked
  • +
  • 29min worked → 0min banked
  • +
+ +

Using Banked Overtime

+

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

+ +

Weekend/Holiday Work

+

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

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

🌴 Annual Leave Management

+ +

Auto Balance Calculation

+

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

+ +

Half / Quarter Day

+
    +
  • 1.0 day — full leave
  • +
  • 0.5 day — half (4h)
  • +
  • 0.25 day — quarter (2h)
  • +
+ +

Reduced-Hours Conversion

+

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

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

🚪 Break / Dinner

+ +

Break (Briefly Away)

+

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

+ +

Auto Break on Screen Lock

+

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

+ +

Dinner

+

For overtime with dinner, press Add Dinner.

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

❓ FAQ

+ +

Q. Clock-in time is wrong

+

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

+ +

Q. How to set 7h30m work day?

+

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

+ +

Q. Where is data stored?

+

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

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

missing: {key}

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

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