diff --git a/CHANGELOG.md b/CHANGELOG.md index 22de0d6..e4e3f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.13.0] — 2026-08-12 + +### Fixed — 익일(자정 넘김) 퇴근 +- **새벽 퇴근이 출근일 기록에 저장되지 않던 버그** — `clock_out()`이 `datetime.now().date()`를 + 대상 행으로 써서, 자정을 넘겨 퇴근하면 해당 날짜 행이 없어 `UPDATE`가 0건 처리되고 출근일 + 기록이 영구 미퇴근으로 남았다. 근무일 경계(기본 6시) 전까지는 출근일이 근무일이라는 + `handle_workday_rollover()`의 기준을 `clock_out()`에도 적용. `cancel_clock_out()` 동일 수정. +- **야근이 주말/공휴일 근무로 오분류되던 문제** — 근무 성격 판정(`is_non_working_day`/ + `get_day_type`)과 외출 시간 집계를 `now`가 아닌 출근일 기준으로 변경. 금요일 야근이 + 토요일 근무로 뒤바뀌어 전 시간이 연장근무로 적립되지 않는다. +- **익일 퇴근 기록을 수정할 수 없던 문제** — 캘린더의 시간 수정과 과거기록 추가 다이얼로그가 + `퇴근 <= 출근`을 입력 오류로 반려해 익일 퇴근을 저장할 방법이 아예 없었다. 이제 익일로 + 해석하고, 동시각인 경우만 반려. 총 근무시간·연장근무가 음수로 깨지던 계산도 함께 수정. + +### Added +- **일일보고를 다른 날짜로도 복사** — `generate_daily_report(target_date)`로 날짜 파라미터화. + 캘린더에서 날짜 선택 → **보고서 복사** 버튼으로 지난 날짜 보고서를 다시 뽑을 수 있다. + 생략 시 기본값은 진행 중인 근무일(자정~경계시간 사이면 출근일)이라 새벽 퇴근 후에도 + "기록 없음"이 뜨지 않는다. +- **연차 직접입력 버튼** (메인 창) — 연장근무 줄과 동일한 구성. 기존 `AddLeaveDialog`를 + 재사용해 날짜(캘린더)/유형/시간/사유를 한 화면에서 입력. 그쪽은 주말·공휴일 차단과 + 같은 날 1일 초과 중복 검증까지 갖추고 있다. (구현돼 있었으나 어떤 버튼에도 연결되지 + 않았던 `use_custom_leave()`를 연결) +- 익일 퇴근 표기 — 보고서/캘린더 상세에 `익일 01:30`, 수정 다이얼로그에 실시간 안내 + (`🌙 익일 퇴근으로 저장됩니다 (출근~퇴근 16시간 30분)`). +- `core.time_calculator.resolve_work_datetimes()` / `is_overnight()` — 익일 판정을 한 곳으로 + 통일 (외출 기록의 자정 처리와 같은 규칙). 유닛 테스트 6개 추가. + +### Changed +- **연차 빠른 사용에서 날짜·메모 입력 팝업 제거** — 30분/1시간/반차/종일 버튼이 연장근무 + 사용과 동일하게 확인 1회로 끝난다(팝업 3개 → 1개). 날짜·사유 지정은 직접입력으로. +- `Database.get_today_overtime_usage()` / `get_total_break_minutes_today()`에 선택적 + `date_str` 파라미터 추가 (기본값은 기존과 동일한 오늘). + ## [2.12.0] — 2026-06-16 ### Added — 전체 i18n 키화 완료 diff --git a/CLAUDE.md b/CLAUDE.md index ad4499d..357379f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **Tech Stack:** Python 3.9+, PyQt5, SQLite, pywin32, matplotlib, optional `holidays`. +[AGENTS.md](AGENTS.md) is the long-form reference (invariants, past incidents, security notes). This file is the quick map — when the two disagree, AGENTS.md is more detailed but may also be staler; trust the code. + Companion docs: [AGENTS.md](AGENTS.md), [INSTALL.md](INSTALL.md), [README.md](README.md), [CHANGELOG.md](CHANGELOG.md). ## Build and Run @@ -32,9 +34,18 @@ python -m pytest tests # unit tests # Release (one-shot to Gitea) $env:GITEA_TOKEN = '' -.\release.ps1 v2.7.0 +.\release.ps1 v2.12.0 ``` +Env vars (all optional): + +| Var | Effect | +| --- | --- | +| `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` | Skips the background holiday-sync thread in `init_database()`. **Required for any Qt test** — the thread segfaults offscreen runs. Already set in `tests/conftest.py` and the three `_*_test.py` scripts. | +| `CLOCKOUT_HOLIDAY_API_KEY` | 공공데이터포털 특일정보 key for [holiday_api.py](utils/holiday_api.py) (externalized in v2.12.0). | +| `CLOCKOUT_DEBUG` / `CLOCKOUT_DEBUG_DIR` | Enables `dlog()` output + its target dir. | +| `CLOCKOUT_RELEASES_API` / `CLOCKOUT_ASSET_NAME` | Override the Gitea endpoint / asset name for update checks. | + ## Architecture ### core/ @@ -43,8 +54,10 @@ $env:GITEA_TOKEN = '' - **[event_monitor.py](core/event_monitor.py)** — Windows Event IDs 6005/4624/6006. - **[notifier.py](core/notifier.py)** — 7 notifications, each gated by `NOTIF_*` setting + db.has_notification_today guard for daily dedupe. Reads `notification_before_minutes` for clock-out alert threshold. - **[salary.py](core/salary.py)** — `estimate_pay(records, hourly_wage, overtime_rate=1.5)` simple month estimator. -- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en, 30+ categories) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. Runtime retranslate via observer pattern (see B2 in CHANGELOG v2.7.0). -- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants. Modules import these instead of raw strings. ~35 keys. +- **[achievements.py](core/achievements.py)** — ~357 achievements as `Achievement` dataclass + per-item evaluator `(db) -> (progress, target)`. Evaluated on a 5-min throttle, unearned only; new unlocks emit `notify_achievement_unlocked`. Names/descriptions live in i18n as `achieve.{code}.name/desc`, not in the dataclass. +- **[recurring_leaves.py](core/recurring_leaves.py)** — Pattern parser (`weekly:friday`, `weekly:mon,wed,fri`, `biweekly:friday`, `monthly:15`). Instances are **not** persisted — `expand_for_range()` computes them on call, and callers must reconcile against concrete `leave_records` rows for the same date. +- **[i18n.py](core/i18n.py)** — `_DICT` (ko/en) + `_HELP_HTML` (6 tabs). API: `tr(key, **kwargs)`, `tr_html(key)`, `set_language()`. UI string keying is **complete as of v2.12.0** (all 22 `ui/` files + achievement metadata + chart labels) — add new user-facing strings as keys, never literals. +- **[settings_keys.py](core/settings_keys.py)** — All setting keys as constants (60 keys). Modules import these instead of raw strings. - **[version.py](core/version.py)** — `__version__` single source of truth. ### ui/ @@ -61,7 +74,11 @@ $env:GITEA_TOKEN = '' - **[help_view.py](ui/help_view.py)** — 6 tabs from `_HELP_HTML`. Bottom-left "Re-run Onboarding" button. - **[chart_widget.py](ui/chart_widget.py)** — matplotlib QtAgg helpers: `draw_daily_hours` (with hover annotation), `draw_weekday_avg`, `draw_clock_in_distribution`. - **[accessibility.py](ui/accessibility.py)** — Font scale + high-contrast QSS overlay. -- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. Window titles use `tr()`; deeper labels Korean (incremental i18n). +- **[achievements_view.py](ui/achievements_view.py)** — 4 tabs (all / in-progress / done / secret). Tier-gradient cards. +- **[schedule_view.py](ui/schedule_view.py)** — Unified holiday + leave + recurring-pattern calendar. Recurring patterns managed via `recurring_leave_dialog.py`. +- **[dark_components.py](ui/dark_components.py)** — Shared card/header/tab widgets for the dark dialogs. Every sub-label sets explicit `transparent` + `border:none` to escape the global QSS cascade — keep that when adding components. +- **[icons.py](ui/icons.py)** — `get_icon(name, color=None)`: QtSvg 24x24 line icons cached by `(name, color, size)`, tinted with the current theme color. Replaced emoji in v2.11.0. +- Dialogs: `calendar_view`, `break_view`, `overtime_view`, `leave_view`, `clock_in_dialog`. ### ui/controllers/ - **[lock_monitor.py](ui/controllers/lock_monitor.py)** — Windows screen-lock 5s polling. Two modes: AUTO_BREAK_ON_LOCK (lock→break_out, unlock→break_in) and CLOCK_IN_ON_UNLOCK (first unlock = clock-in for users who never reboot). @@ -71,7 +88,7 @@ $env:GITEA_TOKEN = '' ### ui/ (cross-cutting) - **[i18n_runtime.py](ui/i18n_runtime.py)** — Runtime retranslate plumbing. `register(widget, key)` keeps a weakref; `set_language_and_retranslate(lang)` re-fetches all live widgets via `tr()`. Dead widgets auto-cleaned. Main window title + bottom 5 menu buttons currently registered; dialogs migrate incrementally. -- **[styles.py](ui/styles.py)** — Shared QSS / color tokens. +- **[styles.py](ui/styles.py)** — `LIGHT_COLORS`/`DARK_COLORS` token dicts → `generate_theme(colors, is_dark)` QSS. **Dark is the default** for new installs (v2.11.0). Read colors at paint time via `ThemeColors.get('key')` — never hardcode hex in a widget, or it will ignore the theme (that was the v2.11.1 bug: stats/help/achievements stayed dark in light mode). `apply_dark_titlebar(widget)` for the Win32 titlebar; dialogs call it in `__init__`. Theme choice is the `THEME` setting key. ### utils/ - **[backup.py](utils/backup.py)** — `backup_db_if_needed()`. Daily, 7-file rotation, `sqlite3.Connection.backup` API. @@ -85,6 +102,8 @@ $env:GITEA_TOKEN = '' - **[debug_log.py](utils/debug_log.py)** — `dlog()` env-gated by `CLOCKOUT_DEBUG`. - **[time_format.py](utils/time_format.py)** — `format_hours_minutes(minutes)` shared helper. - **[system_tray.py](utils/system_tray.py)** — Tray menu, tooltips i18n. +- **[holiday_api.py](utils/holiday_api.py)** — 한국천문연구원 특일정보 `getRestDeInfo` client, backfilling the temporary holidays the `holidays` package misses. Network failure returns `None` silently — callers must fall back. +- **[font_loader.py](utils/font_loader.py)** — Registers bundled NanumSquare TTFs from `font/` into `QFontDatabase` (frozen + dev paths). Falls back to Malgun Gothic via the QSS font chain if registration fails. ### Top-level - **[main.py](main.py)** — Entry point. Bootstraps DB, reads `db_path_override`, runs auto-backup, registers crash handler, shows onboarding (if needed), instantiates MainWindow. @@ -126,7 +145,7 @@ Migration sentinels prevent re-running. ## i18n -`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Many deeper dialog labels still Korean — `_DICT['ko']/['en']`에 키 추가 + `tr()` 교체로 점진 확장. +`tr('key', **kwargs)` reads `_DICT[current_lang]`, falls back to `ko`, then literal key. `tr_html('help.html.X')` for HelpView. Full UI keying landed in v2.12.0 — new strings go in `_DICT['ko']` **and** `['en']`. Watch apostrophes in English values (`Children's`) — they broke `i18n.py` with a SyntaxError in v2.12.0. Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via `register_translatable(widget, key)` from `ui/i18n_runtime.py`; on `set_language()` change, all registered widgets are re-fetched. @@ -138,13 +157,14 @@ Runtime retranslate (v2.7.0+): observer pattern. Widgets register their text via - Single-instance dev: `QLocalServer` blocks second `python main.py`. Use `QT_QPA_PLATFORM=offscreen` for GUI smoke tests. - PyInstaller frozen: `getattr(sys, 'frozen', False)` + `sys._MEIPASS` for resource paths. - main.exe self-extracts updater.exe to its own folder on first launch (`_ensure_updater_extracted()` in main.py). +- matplotlib in frozen builds: import `backend_qtagg` (with a `backend_qt5agg` fallback) and keep the `numpy.core._multiarray_tests` hiddenimport in [main.spec](main.spec). Both were silent "matplotlib 필요" fallbacks in v2.11.0–2.11.1 that only reproduced in main.exe, never in `python main.py` — chart changes need a real build to verify. ## Tests - [_integration_test.py](_integration_test.py) — Business-logic scenarios (no Qt). - [_gui_smoke_test.py](_gui_smoke_test.py) — Widget instantiation via `QT_QPA_PLATFORM=offscreen`. - [_i18n_gui_test.py](_i18n_gui_test.py) — ko/en switching on real widgets. -- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`). +- [tests/](tests/) — pytest unit tests: `test_time_calculator`, `test_database`, `test_i18n`, `test_i18n_runtime`, `test_updater`, `test_csv_importer`, `test_discord_webhook`, `test_salary`, `test_crash_handler`, `test_holiday_api`, `test_overtime_accrual_guard`, `test_recurring_leaves`. Auto-discovered via [pytest.ini](pytest.ini) (`testpaths = tests`); [tests/conftest.py](tests/conftest.py) sets `CLOCKOUT_DISABLE_HOLIDAY_SYNC` at import time. Run a single test: `python -m pytest tests/test_time_calculator.py::TestX::test_y -v`. diff --git a/core/database.py b/core/database.py index 42badc8..1383e77 100644 --- a/core/database.py +++ b/core/database.py @@ -1026,10 +1026,10 @@ class Database: balance = cursor.fetchone()[0] return initial_overtime + balance - def get_today_overtime_usage(self) -> int: - """오늘 사용한 추가근무 시간 조회 (분)""" + def get_today_overtime_usage(self, date_str: str = None) -> int: + """오늘 사용한 추가근무 시간 조회 (분). date_str 지정 시 그 날짜 기준.""" from datetime import date - today = date.today().isoformat() + today = date_str or date.today().isoformat() with self._conn() as conn: cursor = conn.cursor() cursor.execute('SELECT SUM(used_minutes) FROM overtime_usage WHERE date = ?', @@ -1590,10 +1590,14 @@ class Database: cursor.execute('DELETE FROM break_records WHERE id = ?', (break_id,)) conn.commit() - def get_total_break_minutes_today(self) -> int: - """오늘의 총 외출 시간 (분), 진행 중인 외출 포함""" + def get_total_break_minutes_today(self, date_str: str = None) -> int: + """오늘의 총 외출 시간 (분), 진행 중인 외출 포함. + + date_str 지정 시 그 근무일 기준 (야근으로 자정을 넘긴 뒤 퇴근할 때 + 호출자가 출근일을 넘겨줘야 그 날의 외출이 집계된다). + """ from datetime import date, datetime - today = date.today().isoformat() + today = date_str or date.today().isoformat() with self._conn() as conn: cursor = conn.cursor() cursor.execute(''' diff --git a/core/i18n.py b/core/i18n.py index 26a5dda..cb39318 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -466,6 +466,10 @@ _DICT = { 'report.memo': '📝 메모: {memo}', 'report.copied.title': '보고서 복사 완료', 'report.copied.body': '일일 근무 보고서가 클립보드에 복사되었습니다.\n\n{report}', + # 익일(자정 넘김) 퇴근 표기 + 'label.next_day_time': '익일 {time}', + 'edit.overnight_note': '🌙 익일 퇴근으로 저장됩니다 (출근~퇴근 {hours}시간 {minutes}분)', + 'cal.copy_report': '보고서 복사', 'label.lunch': '🍱 점심시간', 'label.lunch_short': '점심', 'label.dinner': '🍽️ 저녁시간', @@ -732,7 +736,7 @@ _DICT = { 'cal.save_memo': '메모 저장', 'cal.save_memo_body': '{date}의 메모가 저장되었습니다.', 'cal.save_memo_title': '메모 저장', - 'cal.time_error_body': '퇴근 시간은 출근 시간보다 늦어야 합니다.', + 'cal.time_error_body': '퇴근 시간이 출근 시간과 같습니다.\n(퇴근이 출근보다 이르면 익일 퇴근으로 저장됩니다.)', 'cal.time_error_title': '시간 오류', 'clock_in_dialog.cancelled': '취소됨', 'clock_in_dialog.selected': '선택된 시간: {time}', @@ -814,7 +818,7 @@ _DICT = { 'past_record.check_lunch': '점심시간 포함', 'past_record.dialog_title': '기록 추가 — {date}', 'past_record.info': '{date} 근무 기록을 입력하세요.', - 'past_record.input_error_body': '퇴근 시간이 출근 시간보다 빠르거나 같습니다.', + 'past_record.input_error_body': '퇴근 시간이 출근 시간과 같습니다.\n(퇴근이 출근보다 이르면 익일 퇴근으로 저장됩니다.)', 'past_record.input_error_title': '입력 오류', 'past_record.label_clock_in': '출근:', 'past_record.label_clock_out': '퇴근:', @@ -1625,6 +1629,10 @@ _DICT = { 'report.memo': '📝 Memo: {memo}', 'report.copied.title': 'Report Copied', 'report.copied.body': 'Daily work report copied to clipboard.\n\n{report}', + # Next-day (past-midnight) clock-out + 'label.next_day_time': 'next day {time}', + 'edit.overnight_note': '🌙 Saved as a next-day clock-out ({hours}h {minutes}m in to out)', + 'cal.copy_report': 'Copy Report', 'label.lunch': '🍱 Lunch', 'label.lunch_short': 'Lunch', 'label.dinner': '🍽️ Dinner', @@ -1891,7 +1899,7 @@ _DICT = { 'cal.save_memo': 'Save Memo', 'cal.save_memo_body': 'Memo saved for {date}.', 'cal.save_memo_title': 'Save Memo', - 'cal.time_error_body': 'Clock-out must be later than clock-in.', + 'cal.time_error_body': 'Clock-out is the same as clock-in.\n(An earlier clock-out is saved as the next day.)', 'cal.time_error_title': 'Time Error', 'clock_in_dialog.cancelled': 'Cancelled', 'clock_in_dialog.selected': 'Selected time: {time}', @@ -1973,7 +1981,7 @@ _DICT = { 'past_record.check_lunch': 'Include lunch', 'past_record.dialog_title': 'Add Record — {date}', 'past_record.info': 'Enter work record for {date}.', - 'past_record.input_error_body': 'Clock-out must be later than clock-in.', + 'past_record.input_error_body': 'Clock-out is the same as clock-in.\n(An earlier clock-out is saved as the next day.)', 'past_record.input_error_title': 'Input Error', 'past_record.label_clock_in': 'Clock-in:', 'past_record.label_clock_out': 'Clock-out:', diff --git a/core/time_calculator.py b/core/time_calculator.py index 747b6d3..7921ee6 100644 --- a/core/time_calculator.py +++ b/core/time_calculator.py @@ -6,6 +6,32 @@ from datetime import datetime, time, timedelta from typing import Tuple, Optional +def resolve_work_datetimes(date_str: str, clock_in: str, clock_out: str) -> Tuple[datetime, datetime]: + """출근일 기준 (출근, 퇴근) datetime. 퇴근이 출근보다 이르면 익일로 해석. + + work_records는 출근일 날짜 한 행에 clock_in/clock_out TIME만 저장하므로 + "09:00 출근 → 01:30 퇴근"(야근)은 clock_out < clock_in 으로 나타난다. + break_records의 자정 처리(복귀 < 외출 → 익일)와 같은 규칙. + + Args: + date_str: 출근일 'YYYY-MM-DD' + clock_in / clock_out: 'HH:MM:SS' (또는 'HH:MM') + """ + day = datetime.strptime(date_str, '%Y-%m-%d').date() + fmt = '%H:%M:%S' if clock_in.count(':') == 2 else '%H:%M' + ci = datetime.combine(day, datetime.strptime(clock_in, fmt).time()) + fmt = '%H:%M:%S' if clock_out.count(':') == 2 else '%H:%M' + co = datetime.combine(day, datetime.strptime(clock_out, fmt).time()) + if co < ci: + co += timedelta(days=1) + return ci, co + + +def is_overnight(clock_in: str, clock_out: str) -> bool: + """퇴근 시각이 익일인지 (문자열 비교 — 'HH:MM:SS' 는 사전순=시간순).""" + return bool(clock_out) and clock_out < clock_in + + class TimeCalculator: """근무시간 계산 클래스""" diff --git a/core/version.py b/core/version.py index e6aa5f8..8e6ec31 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.12.0' +__version__ = '2.13.0' diff --git a/tests/test_time_calculator.py b/tests/test_time_calculator.py index 5b1628b..625054e 100644 --- a/tests/test_time_calculator.py +++ b/tests/test_time_calculator.py @@ -9,7 +9,7 @@ import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from core.time_calculator import TimeCalculator +from core.time_calculator import TimeCalculator, resolve_work_datetimes, is_overnight @pytest.fixture @@ -172,3 +172,39 @@ class TestHolidayOvertime: ci, now, include_lunch=True, include_dinner=True ) assert actual == 0 and earned == 0 + + +class TestResolveWorkDatetimes: + """익일(자정 넘김) 퇴근 해석 — 야근 기록 저장/수정의 기준.""" + + def test_same_day(self): + ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '18:00:00') + assert ci == datetime(2026, 5, 1, 9, 0) + assert co == datetime(2026, 5, 1, 18, 0) + + def test_overnight_clock_out_rolls_to_next_day(self): + ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '01:30:00') + assert co == datetime(2026, 5, 2, 1, 30) + assert (co - ci) == timedelta(hours=16, minutes=30) + + def test_equal_times_stay_same_day(self): + # 동시각은 익일로 밀지 않음 (UI에서 반려하는 케이스) + ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '09:00:00') + assert ci == co + + def test_accepts_hh_mm(self): + ci, co = resolve_work_datetimes('2026-05-01', '09:00', '01:30') + assert (co - ci) == timedelta(hours=16, minutes=30) + + def test_is_overnight(self): + assert is_overnight('09:00:00', '01:30:00') is True + assert is_overnight('09:00:00', '18:00:00') is False + assert is_overnight('09:00:00', '') is False + + def test_overtime_uses_resolved_datetimes(self, calc_8h): + """익일 퇴근도 정상 연장근무로 계산되는지 (음수 방지).""" + ci, co = resolve_work_datetimes('2026-05-01', '09:00:00', '01:30:00') + actual, earned = calc_8h.calculate_overtime(ci, co, include_lunch=True) + # 09:00 출근 + 8h + 점심 1h → 정시 18:00, 익일 01:30 퇴근 = 450분 초과 + assert actual == 450 + assert earned == 450 diff --git a/ui/calendar_view.py b/ui/calendar_view.py index b345e26..fea05b4 100644 --- a/ui/calendar_view.py +++ b/ui/calendar_view.py @@ -85,6 +85,11 @@ class CalendarView(QDialog): self.edit_time_button.clicked.connect(self.edit_work_time) button_layout.addWidget(self.edit_time_button) + self.copy_report_button = QPushButton(tr('cal.copy_report')) + self.copy_report_button.setEnabled(False) + self.copy_report_button.clicked.connect(self.copy_daily_report) + button_layout.addWidget(self.copy_report_button) + self.delete_record_button = QPushButton(tr('cal.delete_record')) self.delete_record_button.setObjectName("btn_danger") self.delete_record_button.setEnabled(False) @@ -196,10 +201,9 @@ class CalendarView(QDialog): try: wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True) if data.get('clock_out'): - # 총 시간/연장근무 계산 - from datetime import datetime as _dt - ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S') - co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S') + # 총 시간/연장근무 계산 (익일 퇴근이면 퇴근에 +1일) + from core.time_calculator import resolve_work_datetimes + ci, co = resolve_work_datetimes(date_str, data['clock_in'], data['clock_out']) from core.time_calculator import TimeCalculator wm = self.db.get_work_minutes() lunch = self.db.get_setting_int('lunch_duration_minutes', 60) @@ -275,7 +279,11 @@ class CalendarView(QDialog): detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n' if record.get('clock_out'): - detail += tr('cal.detail_clock_out', time=record['clock_out']) + '\n' + from core.time_calculator import is_overnight + out_str = record['clock_out'] + if is_overnight(record['clock_in'], out_str): + out_str = tr('label.next_day_time', time=out_str) + detail += tr('cal.detail_clock_out', time=out_str) + '\n' detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n' if record.get('lunch_break'): @@ -302,6 +310,7 @@ class CalendarView(QDialog): self.detail_text.setText(detail) self.edit_time_button.setEnabled(True) self.delete_record_button.setEnabled(True) + self.copy_report_button.setEnabled(True) # 메모 필드 업데이트 self.memo_edit.setPlainText(record.get('memo', '')) @@ -310,9 +319,22 @@ class CalendarView(QDialog): self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record')) self.edit_time_button.setEnabled(False) self.delete_record_button.setEnabled(False) + self.copy_report_button.setEnabled(False) self.memo_edit.setPlainText('') self.save_memo_button.setEnabled(False) + def copy_daily_report(self): + """선택한 날짜의 일일보고서를 클립보드로 복사. + + 메인 창의 생성기를 그대로 재사용 — 당일 보고를 놓쳐도 나중에 다시 뽑을 수 있다. + """ + if not self.selected_date_str: + return + main_window = self.parent() + if main_window is None or not hasattr(main_window, 'generate_daily_report'): + return # 단독 실행(__main__) 등 부모가 메인 창이 아닌 경우 + main_window.generate_daily_report(self.selected_date_str) + def delete_selected_record(self): """선택된 날짜의 출근 기록 삭제""" if not self.selected_date_str: @@ -455,6 +477,10 @@ class EditWorkTimeDialog(QDialog): 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) + else: + # 미퇴근 기록: 기본값 00:00은 "익일 자정 퇴근"으로 오인될 수 있으므로 + # 출근 시각과 동일하게 두어 save_changes()의 동시각 반려에 걸리게 한다. + self.clock_out_edit.setTime(clock_in_time) clock_out_layout.addWidget(self.clock_out_edit) clock_out_plus_btn = QPushButton(tr('cal.btn_plus_30')) @@ -463,6 +489,15 @@ class EditWorkTimeDialog(QDialog): clock_out_layout.addWidget(clock_out_plus_btn) layout.addLayout(clock_out_layout) + # 익일 퇴근 안내 (퇴근 < 출근이면 표시) + self.overnight_note = QLabel('') + self.overnight_note.setObjectName("note_text") + self.overnight_note.setVisible(False) + layout.addWidget(self.overnight_note) + self.clock_in_edit.timeChanged.connect(self._refresh_overnight_note) + self.clock_out_edit.timeChanged.connect(self._refresh_overnight_note) + self._refresh_overnight_note() + # 점심/저녁 체크박스 - 한 줄에 from PyQt5.QtWidgets import QCheckBox check_layout = QHBoxLayout() @@ -501,6 +536,18 @@ class EditWorkTimeDialog(QDialog): new_time = current_time.addSecs(minutes * 60) time_edit.setTime(new_time) + def _refresh_overnight_note(self): + """퇴근이 출근보다 이르면 익일 퇴근으로 해석됨을 표시.""" + ci = self.clock_in_edit.time() + co = self.clock_out_edit.time() + if co >= ci: + self.overnight_note.setVisible(False) + return + total_min = ci.secsTo(co) // 60 + 24 * 60 # 익일까지 + self.overnight_note.setText(tr('edit.overnight_note', + hours=total_min // 60, minutes=total_min % 60)) + self.overnight_note.setVisible(True) + def save_changes(self): """변경사항 저장""" clock_in = self.clock_in_edit.time().toString("HH:mm:ss") @@ -508,8 +555,8 @@ class EditWorkTimeDialog(QDialog): lunch_break = self.lunch_check.isChecked() dinner_break = self.dinner_check.isChecked() - # 퇴근 시간이 출근 시간보다 빠른지 확인 - if clock_out <= clock_in: + # 퇴근 < 출근은 익일 퇴근(야근)으로 허용. 같은 시각만 반려. + if clock_out == clock_in: QMessageBox.warning( self, tr('cal.time_error_title'), @@ -519,12 +566,10 @@ class EditWorkTimeDialog(QDialog): # 근무 시간 계산 from datetime import datetime, timedelta - from core.time_calculator import TimeCalculator + from core.time_calculator import TimeCalculator, resolve_work_datetimes - # 해당 날짜의 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()) + # 해당 날짜의 datetime 객체 생성 (익일 퇴근이면 퇴근에 +1일) + clock_in_dt, clock_out_dt = resolve_work_datetimes(self.date_str, clock_in, clock_out) # 총 근무시간 계산 total_hours = (clock_out_dt - clock_in_dt).total_seconds() / 3600 diff --git a/ui/main_window.py b/ui/main_window.py index 8f576a7..abead33 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -25,7 +25,7 @@ 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 core.time_calculator import TimeCalculator, resolve_work_datetimes from ui.clock_in_dialog import ClockInDialog from ui.calendar_view import CalendarView from ui.stats_view import StatsView @@ -608,9 +608,11 @@ class MainWindow(QMainWindow): use_1hour_leave_button = QPushButton(tr('btn.use_1hour')) use_half_leave_button = QPushButton(tr('btn.half_leave')) use_full_leave_button = QPushButton(tr('btn.full_leave')) + use_custom_leave_button = QPushButton(tr('btn.custom_input')) leave_detail_button = QPushButton(tr('btn.detail')) - for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button, use_full_leave_button, leave_detail_button]: + for btn in [use_30min_leave_button, use_1hour_leave_button, use_half_leave_button, + use_full_leave_button, use_custom_leave_button, leave_detail_button]: btn.setObjectName("btn_small") leave_button_layout.addWidget(btn) @@ -618,6 +620,7 @@ class MainWindow(QMainWindow): 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)) + use_custom_leave_button.clicked.connect(self.use_custom_leave) # 연장근무와 동일한 상세입력 leave_detail_button.clicked.connect(self.show_leave_detail) layout.addLayout(leave_button_layout) @@ -1060,8 +1063,13 @@ class MainWindow(QMainWindow): # 다이얼로그 종료 후 잔액 업데이트 self.update_overtime_balance() - def use_leave(self, days: float): - """연차 사용""" + def use_leave(self, days: float, date_str: str = None, memo: str = None): + """연차 사용. + + date_str 생략 시 오늘 날짜로 바로 확인만 받고 처리 — 연장근무 사용 + (use_overtime)과 같은 흐름. 날짜·사유를 지정하려면 상세입력 + (use_custom_leave → AddLeaveDialog)을 쓴다. + """ balance = self.db.get_leave_balance() if balance < days: @@ -1072,44 +1080,21 @@ class MainWindow(QMainWindow): ) return - # 사용 날짜 입력 - from PyQt5.QtWidgets import QInputDialog, QLineEdit from datetime import date - today = date.today().isoformat() - date_str, ok = QInputDialog.getText( - self, - tr('msg.leave_use_date.title'), - tr('msg.leave_use_date.body'), - QLineEdit.Normal, - today - ) - - if not ok or not date_str: - return - - # 날짜 형식 검증 - try: - datetime.strptime(date_str, "%Y-%m-%d") - except ValueError: - QMessageBox.warning( - self, - tr('msg.input_error.title'), - tr('msg.input_error.date_format') - ) - return - - # 메모 입력 - memo, ok = QInputDialog.getText( - self, - tr('msg.leave_use_reason.title'), - tr('msg.leave_use_reason.body'), - QLineEdit.Normal, - "" - ) - - if not ok: - return + if date_str is None: + date_str = date.today().isoformat() + else: + # 날짜 형식 검증 + try: + datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + QMessageBox.warning( + self, + tr('msg.input_error.title'), + tr('msg.input_error.date_format') + ) + return # 사용 확인 if days == 1.0: @@ -1197,38 +1182,18 @@ class MainWindow(QMainWindow): self.use_overtime(minutes) def use_custom_leave(self): - """사용자 정의 연차 사용""" - from PyQt5.QtWidgets import QInputDialog + """연차 상세 입력 — 날짜(캘린더)/유형/시간/사유를 한 화면에서. - balance = self.db.get_leave_balance() + 연차 내역 화면의 AddLeaveDialog를 그대로 재사용한다. 그쪽은 잔액 외에 + 주말·공휴일 차단과 같은 날 1일 초과 중복 검증까지 갖추고 있어, + 메인 창에서 바로 띄우는 편이 QInputDialog 연쇄보다 안전하고 간편하다. + """ + from PyQt5.QtWidgets import QDialog + from ui.leave_view import AddLeaveDialog - # 사용할 시간 입력 (시간 단위) - hours, ok = QInputDialog.getDouble( - self, - tr('msg.input.title'), - tr('msg.leave_input.body'), - 0.5, - 0.5, - 80.0, - 1 - ) - - if not ok: - return - - # 시간을 일수로 변환 (8시간 = 1일) - days = hours / 8.0 - - if days > balance: - QMessageBox.warning( - self, - tr('msg.leave_short.title'), - tr('msg.leave_short_hours.body', balance=balance, balance_hours=int(balance * 8), days=days, hours=hours) - ) - return - - # use_leave 메서드 호출 - self.use_leave(days) + dialog = AddLeaveDialog(self, self.db) + if dialog.exec_() == QDialog.Accepted: + self.update_leave_balance() def show_leave_detail(self): """연차 상세 내역 보기""" @@ -1247,12 +1212,32 @@ class MainWindow(QMainWindow): # 퇴근 완료 -> 퇴근 취소 self.cancel_clock_out() + def current_workday_date(self) -> str: + """진행 중인(또는 방금 끝난) 근무일 날짜 'YYYY-MM-DD'. + + 자정~근무일 경계시간(기본 6시) 사이에는 달력상 날짜가 이미 넘어갔어도 + 출근일이 근무일이다. handle_workday_rollover()가 경계시간 전까지 + 전날 근무를 유지하는 것과 같은 기준. + """ + if self.is_clocked_in and self.clock_in_time: + return self.clock_in_time.date().isoformat() + now = datetime.now() + boundary = int(self.db.get_setting(WORKDAY_BOUNDARY_HOUR, '6')) + today_str = now.date().isoformat() + # 새벽에 퇴근 직후: 오늘 기록이 아직 없으면 전날이 근무일 + if now.hour < boundary and not self.db.get_work_record(today_str): + return (now.date() - timedelta(days=1)).isoformat() + return today_str + def clock_out(self): """퇴근 처리""" if not self.is_clocked_in: return now = datetime.now() + # 근무일 = 출근일. 자정 넘겨 퇴근하면 now.date()는 이미 다음 날이므로 + # 이 값을 써야 출근일 행에 퇴근이 기록된다 (UPDATE 0건 방지). + workday = self.clock_in_time.date().isoformat() # 확인 메시지 reply = QMessageBox.question( @@ -1272,12 +1257,13 @@ class MainWindow(QMainWindow): 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) + # 주말/공휴일 체크 — 근무 성격은 출근일로 판정 (금요일 야근이 토요일 + # 근무로 뒤바뀌지 않게). 롤오버 경로와 동일 기준. + is_non_working_day = self.time_calc.is_non_working_day(self.clock_in_time, self.db) + day_type = self.time_calc.get_day_type(self.clock_in_time, self.db) - # 오늘의 외출 시간 가져오기 - break_minutes = self.db.get_total_break_minutes_today() + # 근무일의 외출 시간 가져오기 + break_minutes = self.db.get_total_break_minutes_today(workday) # 적립 단위(분) — 사용자 설정. 기본 30, 옵션 15/60. unit_minutes = self.db.get_setting_int('overtime_unit', 30) @@ -1318,7 +1304,7 @@ class MainWindow(QMainWindow): overtime_earned = 0 # 적립 스킵 (overtime_actual은 기록용으로 유지) # DB 업데이트 - today = datetime.now().date().isoformat() + today = workday clock_out_str = now.strftime("%H:%M:%S") self.db.update_clock_out( @@ -1328,7 +1314,7 @@ class MainWindow(QMainWindow): # 연장근무 적립 기록 if overtime_earned > 0: - today_record = self.db.get_today_record() + today_record = self.db.get_work_record(today) if today_record: self.db.add_overtime_earned( today_record['id'], overtime_earned, today @@ -1387,9 +1373,8 @@ class MainWindow(QMainWindow): return try: - # DB에서 퇴근 취소 - today = datetime.now().date().isoformat() - success = self.db.cancel_clock_out(today) + # DB에서 퇴근 취소 — 새벽 퇴근이면 달력 날짜가 아니라 출근일 행 + success = self.db.cancel_clock_out(self.current_workday_date()) if success: # 상태 복원 @@ -2315,10 +2300,13 @@ class MainWindow(QMainWindow): return report_lines.append("") - def generate_daily_report(self): - """오늘 하루 근무 내역 보고서 생성 및 클립보드 복사""" - from datetime import date + def generate_daily_report(self, target_date: str = None): + """근무 내역 보고서 생성 및 클립보드 복사. + Args: + target_date: 'YYYY-MM-DD'. 생략 시 진행 중인 근무일 + (자정~경계시간 사이면 출근일). 캘린더에서 과거 날짜로도 호출. + """ # 도전과제 카운터 (보고서 생성 횟수) try: cur = self.db.get_setting_int('daily_report_count', 0) @@ -2326,10 +2314,10 @@ class MainWindow(QMainWindow): except Exception as e: dlog(f"daily report counter failed: {e}") - today = date.today().isoformat() + date_str = target_date or self.current_workday_date() - # 오늘의 근무 기록 조회 - work_record = self.db.get_today_record() + # 해당 근무일의 기록 조회 + work_record = self.db.get_work_record(date_str) if not work_record: QMessageBox.warning( @@ -2342,17 +2330,21 @@ class MainWindow(QMainWindow): # 보고서 작성 report_lines = [] report_lines.append("=" * 40) - report_lines.append(tr('report.title', date=today)) + report_lines.append(tr('report.title', date=date_str)) report_lines.append("=" * 40) report_lines.append("") - # 출근/퇴근 시간 - clock_in_dt = datetime.fromisoformat(f"{today} {work_record['clock_in']}") + # 출근/퇴근 시간 (퇴근이 출근보다 이르면 익일 퇴근) + clock_in_dt = datetime.fromisoformat(f"{date_str} {work_record['clock_in']}") report_lines.append(tr('report.clock_in', time=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(tr('report.clock_out', time=self.format_time(clock_out_dt, include_seconds=True))) + _, clock_out_dt = resolve_work_datetimes( + date_str, work_record['clock_in'], work_record['clock_out']) + out_str = self.format_time(clock_out_dt, include_seconds=True) + if clock_out_dt.date() != clock_in_dt.date(): + out_str = tr('label.next_day_time', time=out_str) + report_lines.append(tr('report.clock_out', time=out_str)) # 총 근무 시간 total_work_hours = work_record.get('total_hours') or work_record.get('work_hours', 0) @@ -2367,7 +2359,7 @@ class MainWindow(QMainWindow): # 외출 / 점심 / 저녁 분리 — break_type 으로 구분 (v2.7.0+) # 'break'(또는 NULL) = 일반 외출, 'lunch'/'dinner' = 실측 식사 기록 - all_break_records = self.db.get_today_break_records() + all_break_records = self.db.get_break_records_by_date(date_str) real_break_records = [b for b in all_break_records if (b.get('break_type') or 'break') == 'break'] lunch_records = [b for b in all_break_records if b.get('break_type') == 'lunch'] @@ -2379,9 +2371,9 @@ class MainWindow(QMainWindow): if real_break_minutes > 0 or has_active_break: report_lines.append(tr('report.break_time', time=format_hours_minutes(real_break_minutes))) for br in real_break_records: - break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}") + break_out_time = datetime.fromisoformat(f"{date_str} {br['break_out']}") if br['break_in']: - break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}") + break_in_time = datetime.fromisoformat(f"{date_str} {br['break_in']}") # 자정 경계 처리: 복귀 시간이 외출 시간보다 이전이면 다음날로 간주 if break_in_time < break_out_time: break_in_time += timedelta(days=1) @@ -2397,7 +2389,7 @@ class MainWindow(QMainWindow): lunch_flag = bool(work_record.get('lunch_break', False)) if lunch_flag or lunch_records: self._append_meal_section( - report_lines, today, tr('label.lunch'), + report_lines, date_str, tr('label.lunch'), lunch_flag, lunch_records, self.time_calc.lunch_duration_minutes, ) @@ -2406,7 +2398,7 @@ class MainWindow(QMainWindow): dinner_flag = bool(work_record.get('dinner_break', False)) if dinner_flag or dinner_records: self._append_meal_section( - report_lines, today, tr('label.dinner'), + report_lines, date_str, tr('label.dinner'), dinner_flag, dinner_records, self.time_calc.dinner_duration_minutes, ) @@ -2425,8 +2417,8 @@ class MainWindow(QMainWindow): report_lines.append(tr('report.overtime_banked', time=tr('label.time_hours_minutes', hours=earned_hours, minutes=earned_mins))) report_lines.append("") - # 오늘 사용한 추가근무 - overtime_used_today = self.db.get_today_overtime_usage() + # 해당 날짜에 사용한 추가근무 + overtime_used_today = self.db.get_today_overtime_usage(date_str) if overtime_used_today > 0: used_hours = overtime_used_today // 60 used_mins = overtime_used_today % 60 @@ -2440,7 +2432,7 @@ class MainWindow(QMainWindow): FROM overtime_usage WHERE date = ? ORDER BY created_at ASC - ''', (today,)) + ''', (date_str,)) usage_records = cursor.fetchall() conn.close() @@ -2453,8 +2445,8 @@ class MainWindow(QMainWindow): report_lines.append(tr('report.overtime_used_detail', time=tr('label.time_hours_minutes', hours=used_h, minutes=used_m), reason=reason_text)) report_lines.append("") - # 오늘 사용한 연차 (일괄 추가 및 수동 조정 제외) - leave_records = self.db.get_leave_records(start_date=today, end_date=today, exclude_bulk=False) + # 해당 날짜에 사용한 연차 (일괄 추가 및 수동 조정 제외) + leave_records = self.db.get_leave_records(start_date=date_str, end_date=date_str, exclude_bulk=False) # manual 타입이거나 메모에 "일괄 추가"가 포함된 것은 제외 filtered_leave_records = [ diff --git a/ui/past_record_dialog.py b/ui/past_record_dialog.py index c79fdc8..a55b81a 100644 --- a/ui/past_record_dialog.py +++ b/ui/past_record_dialog.py @@ -21,7 +21,7 @@ class PastRecordDialog(QDialog): self.date_str = date_str self.setWindowTitle(tr('past_record.dialog_title', date=date_str)) self.setModal(True) - self.setFixedSize(380, 320) + self.setFixedSize(380, 348) layout = QVBoxLayout() layout.setSpacing(8) @@ -55,6 +55,15 @@ class PastRecordDialog(QDialog): co_row.addStretch() layout.addLayout(co_row) + # 익일 퇴근 안내 (퇴근 < 출근이면 표시) + self.overnight_note = QLabel('') + self.overnight_note.setObjectName("note_text") + self.overnight_note.setVisible(False) + layout.addWidget(self.overnight_note) + self.clock_in_edit.timeChanged.connect(self._refresh_overnight_note) + self.clock_out_edit.timeChanged.connect(self._refresh_overnight_note) + self.clock_out_check.toggled.connect(self._refresh_overnight_note) + # 점심/저녁 meal_row = QHBoxLayout() self.lunch_check = QCheckBox(tr('past_record.check_lunch')) @@ -88,11 +97,25 @@ class PastRecordDialog(QDialog): self.setLayout(layout) apply_dark_titlebar(self) + def _refresh_overnight_note(self): + """퇴근이 출근보다 이르면 익일 퇴근으로 해석됨을 표시.""" + if not self.clock_out_check.isChecked(): + self.overnight_note.setVisible(False) + return + ci = self.clock_in_edit.time() + co = self.clock_out_edit.time() + if co >= ci: + self.overnight_note.setVisible(False) + return + total_min = ci.secsTo(co) // 60 + 24 * 60 # 익일까지 + self.overnight_note.setText(tr('edit.overnight_note', + hours=total_min // 60, minutes=total_min % 60)) + self.overnight_note.setVisible(True) + def _validate_and_accept(self): + # 퇴근 < 출근은 익일 퇴근(야근)으로 허용. 같은 시각만 반려. if self.clock_out_check.isChecked(): - ci = self.clock_in_edit.time() - co = self.clock_out_edit.time() - if co <= ci: + if self.clock_out_edit.time() == self.clock_in_edit.time(): QMessageBox.warning(self, tr('past_record.input_error_title'), tr('past_record.input_error_body')) return