Compare commits

...

3 Commits

Author SHA1 Message Date
KINDNICK
47296dd35b v2.9.0: \ud734\uc77c hot-path \uc218\uc815 + \uc5f0\ucc28 \ubbf8\ub9ac\ub4f1\ub85d + \ubc18\ubcf5 \ud328\ud134 + \uadfc\ub85c\uc790\uc758\ub0a0 \ucd94\uac00
\uc0ac\uc6a9\uc790 \ubcf4\uace0: \ud734\uc77c\uc5d0 \ucd9c\uadfc\ud574\ub3c4 \uc815\uc0c1 \ucd9c\uadfc\uc73c\ub85c \ucc98\ub9ac\ub418\uc5b4 \ucd94\uac00\uadfc\ubb34 \uc801\ub9bd \uc548\ub428.
- update_display() 1Hz \ub8e8\ud504\uc5d0 is_non_working_day \ubd84\uae30 \ub204\ub77d\uc774 \uc6d0\uc778 (d41e5cb)
- \uc5f0\ucc28 \ubbf8\ub9ac\ub4f1\ub85d \uc2dc\uc2a4\ud15c\uacfc \ud568\uaed8 v2.9.0\uc73c\ub85c \ud1b5\ud569 \ub9b4\ub9ac\uc2a4 (c98ca36)
- holidays.KR\uc774 \uc54a \uc7a1\ub294 \uadfc\ub85c\uc790\uc758 \ub0a0(5/1) \uba85\uc2dc\uc801 \uc790\ub3d9 \ucd94\uac00

\ub9b4\ub9ac\uc2a4 \uadf8\ub8f9: 122\u2192175 pytest, 48\u219253 \ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 (\uc0c1\uc138 CHANGELOG \ucc38\uc870)
2026-05-01 13:13:01 +09:00
KINDNICK
c98ca361cd feat(leave): \uc5f0\ucc28 \ubbf8\ub9ac \ub4f1\ub85d + \uc218\uc544\ud55c \uc790\ub3d9 \uc801\uc6a9 + \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
  get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
    \u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
    \u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
    \u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
    \u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
    \u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
    \u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac

Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
    \u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
    \u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
    \u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
    \u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc

Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
2026-05-01 13:07:52 +09:00
KINDNICK
d41e5cb921 fix(holiday): live hot-path now banks overtime from minute 1 on holidays
3 bugs found and fixed:

A. update_display() 1Hz 루프에 is_non_working_day 분기 누락
   - 휴일/주말 출근 시 '남은 시간 8h'부터 카운트다운 → 실제 휴일 추가근무 시작 안 됨
   - 수정: is_non_working_day=True면 calculate_holiday_overtime로 즉시 음수 remaining 표시
   - 그룹 타이틀: '주말 근무 (전체 적립)' / '공휴일 근무 (전체 적립)'
   - 진행바: 휴일은 100% 고정 (의미 없음)
   - 예상 퇴근: '휴일 근무 (정해진 퇴근시각 없음)'

B. check_clock_out_soon 알림이 휴일에도 fire
   - 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림
   - 수정: orchestrator.tick(is_holiday=True)면 스킵
   - 점심/저녁/장시간 휴식 알림은 휴일에도 유지 (식사·건강은 휴일에도 챙김)

C. 자동복구 퇴근 3곳이 (work_minutes // 30) * 30 하드코딩
   - main_window.py:1385, 1512, 1581 — 사용자 overtime_unit (15/30/60) 무시
   - 수정: 모두 settings에서 unit_minutes 읽어 calculate_holiday_overtime/calculate_overtime에 전달

리팩터링: 4곳에 중복되던 휴일 연장 계산 로직을 TimeCalculator.calculate_holiday_overtime로 추출.

Tests:
- tests/test_time_calculator.py: 9개 신규 (TestHolidayOvertime)
- _integration_test.py: S52A 휴일 hot-path 회귀 시나리오
2026-05-01 12:54:24 +09:00
16 changed files with 1550 additions and 101 deletions

View File

@ -4,6 +4,54 @@ 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.9.0] — 2026-05-01
### Fixed — 휴일 hot-path 버그 (사용자 보고)
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
- 진행바: 휴일은 100% 고정 (의미 없음)
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
#### Phase 1 — 연차 미리등록 + 자동 적용
- **DB:** `get_leave_minutes_for(date)` / `has_full_day_leave(date)` /
`get_leave_records_by_date(date)` / `get_leave_records_by_range(start, end)`
- **TimeCalculator:** `effective_work_minutes(date_obj, db)` — 부분 연차만큼 정규 근무 차감
- **종일 연차일 자동 처리:**
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
#### Phase 2 — 통합 스케줄 + 반복 연차
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
### Changed
- **근로자의 날(5/1) 자동 추가**`holidays.KR` 패키지가 누락하는 노동자 휴일을
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
### Tests
- pytest: 122 → **175** (+53)
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
- 통합 시나리오: 48 → **53** (+5)
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
## [2.8.0] — 2026-05-01
### Added — 도전과제 시스템 + 디자인 리뉴얼

View File

@ -696,6 +696,74 @@ def s51_accessibility_keys():
assert db.get_setting_bool('high_contrast') is True
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
def s52b_planned_leave():
db = fresh_db('s52b')
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert db.has_full_day_leave('2026-05-15')
assert db.get_leave_minutes_for('2026-05-15') == 480
# 다른 날엔 영향 없음
assert not db.has_full_day_leave('2026-05-16')
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
def s52c_recurring_leave():
db = fresh_db('s52c')
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
# 2026-05-01 = Friday
assert db.get_leave_minutes_for('2026-05-01') == 240
# Monday
assert db.get_leave_minutes_for('2026-05-04') == 0
# 종일 아님
assert not db.has_full_day_leave('2026-05-01')
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
def s52d_effective():
from core.time_calculator import TimeCalculator
db = fresh_db('s52d')
db.add_leave_record('2026-05-15', '오전반차', 0.5)
calc = TimeCalculator(work_minutes=480)
target = datetime(2026, 5, 15)
assert calc.effective_work_minutes(target, db) == 240
# 다른 날엔 변화 없음
other = datetime(2026, 5, 16)
assert calc.effective_work_minutes(other, db) == 480
@case("S52E. effective_work_minutes: 종일 연차 시 0")
def s52e_full_day():
from core.time_calculator import TimeCalculator
db = fresh_db('s52e')
db.add_leave_record('2026-05-15', '연차', 1.0)
calc = TimeCalculator(work_minutes=480)
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
def s52a_holiday_hotpath():
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
from core.time_calculator import TimeCalculator
db = fresh_db('s52a')
holiday_date = '2026-05-01' # 근로자의 날
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
ci = datetime(2026, 5, 1, 9, 0)
# 휴일 인식
assert calc.is_non_working_day(ci, db)
assert calc.get_day_type(ci, db) == 'holiday'
# 출근 1분 후: 적립 0 (30분 단위 절삭)
now1 = ci + timedelta(minutes=1)
actual, earned = calc.calculate_holiday_overtime(ci, now1)
assert actual == 1 and earned == 0
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
now30 = ci + timedelta(minutes=30)
actual, earned = calc.calculate_holiday_overtime(ci, now30)
assert actual == 30 and earned == 30
@case("S52. CSV import + overtime 적립까지 정상 동작")
def s52_csv_overtime():
from utils.csv_importer import parse_csv, import_records
@ -764,6 +832,11 @@ def main():
s49_discord_empty()
s50_goals()
s51_accessibility_keys()
s52a_holiday_hotpath()
s52b_planned_leave()
s52c_recurring_leave()
s52d_effective()
s52e_full_day()
s52_csv_overtime()
print()

View File

@ -209,6 +209,24 @@ class Database:
)
''')
# 반복 연차 패턴 테이블 (P2)
# pattern 형식:
# 'weekly:friday' / 'weekly:mon,wed' (요일 영문 소문자, 콤마 구분)
# 'biweekly:friday' (격주)
# 'monthly:15' (매월 N일)
cursor.execute('''
CREATE TABLE IF NOT EXISTS recurring_leaves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pattern TEXT NOT NULL,
leave_type TEXT NOT NULL,
days REAL NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
memo TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
def migrate_break_records_cascade(self):
"""break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션).
@ -962,12 +980,108 @@ class Database:
def get_today_leave_minutes(self) -> int:
"""오늘 사용한 연차/반차 시간 조회 (분)"""
from datetime import date
today = date.today().isoformat()
return self.get_leave_minutes_for(date.today().isoformat())
def get_leave_minutes_for(self, date_str: str) -> int:
"""특정 날짜에 등록된 연차 합계를 분 단위로 반환.
구체 leave_records + 매치되는 recurring_leaves 인스턴스 합산.
예정/사용 구분 없음.
"""
days = self._effective_leave_days_for(date_str)
return int(days * self.get_work_minutes())
def has_full_day_leave(self, date_str: str) -> bool:
"""해당 날짜에 종일(또는 그 이상) 연차가 등록되어 있는지.
구체 + 반복 패턴 모두 검사.
"""
return self._effective_leave_days_for(date_str) >= 1.0
def _effective_leave_days_for(self, date_str: str) -> float:
"""구체 leave_records + 반복 패턴 매치를 합산한 일수."""
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?', (today,))
days = cursor.fetchone()[0] or 0.0
return int(days * self.get_work_minutes())
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?',
(date_str,))
concrete_days = float(cursor.fetchone()[0] or 0.0)
# 반복 패턴 — 매번 호출되니 lazy import + 가벼운 query
try:
from core.recurring_leaves import expand_for_date
from datetime import datetime as _dt
target = _dt.strptime(date_str, '%Y-%m-%d').date()
recs = self.get_recurring_leaves(active_on=date_str)
recurring_days = sum(o.days for o in expand_for_date(recs, target))
except Exception:
recurring_days = 0.0
return concrete_days + recurring_days
def get_leave_records_by_date(self, date_str: str) -> List[Dict]:
"""해당 날짜에 등록된 leave_records 전체 (디스플레이/편집용)."""
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('SELECT * FROM leave_records WHERE date = ? ORDER BY id',
(date_str,))
return [dict(row) for row in cursor.fetchall()]
def get_leave_records_by_range(self, start_date: str, end_date: str) -> List[Dict]:
"""기간 내 leave_records (스케줄 화면용). start/end inclusive."""
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM leave_records
WHERE date BETWEEN ? AND ?
ORDER BY date ASC, id ASC
''', (start_date, end_date))
return [dict(row) for row in cursor.fetchall()]
# ===== 반복 연차 (recurring_leaves) — P2 =====
def add_recurring_leave(self, pattern: str, leave_type: str, days: float,
start_date: str, end_date: str = None,
memo: str = None) -> int:
"""반복 연차 패턴 등록.
Args:
pattern: 'weekly:friday' / 'biweekly:mon' / 'monthly:15'
leave_type: '연차' / '반차' / '시간' (표시용)
days: 회당 차감 일수 (0.5 = 반차)
start_date: 시작일 'YYYY-MM-DD'
end_date: 종료일 또는 None(=무기한)
memo: 옵션
"""
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO recurring_leaves
(pattern, leave_type, days, start_date, end_date, memo)
VALUES (?, ?, ?, ?, ?, ?)
''', (pattern, leave_type, days, start_date, end_date, memo))
conn.commit()
return cursor.lastrowid
def get_recurring_leaves(self, active_on: str = None) -> List[Dict]:
"""반복 패턴 목록. active_on 지정 시 그날 유효한 것만."""
with self._conn() as conn:
cursor = conn.cursor()
if active_on:
cursor.execute('''
SELECT * FROM recurring_leaves
WHERE start_date <= ?
AND (end_date IS NULL OR end_date >= ?)
ORDER BY id
''', (active_on, active_on))
else:
cursor.execute('SELECT * FROM recurring_leaves ORDER BY id')
return [dict(row) for row in cursor.fetchall()]
def delete_recurring_leave(self, rec_id: int) -> None:
with self._conn() as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM recurring_leaves WHERE id = ?', (rec_id,))
conn.commit()
def add_initial_overtime_balance(self, minutes: int):
"""초기 연장근무 잔액 추가"""
@ -1644,6 +1758,15 @@ class Database:
if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=False)
added += 1
# holidays.KR이 누락하는 한국 노동자 휴일 보강.
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
extra = [(f"{y}-05-01", "근로자의 날")]
for date_str, name in extra:
if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=True)
added += 1
return added
def copy_recurring_holidays(self, from_year: int, to_year: int):

153
core/recurring_leaves.py Normal file
View File

@ -0,0 +1,153 @@
"""
반복 연차 패턴 파싱 + 일자 확장.
지원 패턴:
- 'weekly:friday' 매주 금요일
- 'weekly:mon,wed,fri' 매주 ··
- 'biweekly:friday' 격주 금요일 (start_date 기준)
- 'monthly:15' 매월 15 (해당 월에 일이 없으면 스킵)
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range() 펼친다.
같은 날짜에 leave_records(구체 인스턴스) 이미 있으면 호출자가 합산 로직 책임.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import List, Dict, Optional
_WEEKDAY_MAP = {
'mon': 0, 'monday': 0,
'tue': 1, 'tuesday': 1,
'wed': 2, 'wednesday': 2,
'thu': 3, 'thursday': 3,
'fri': 4, 'friday': 4,
'sat': 5, 'saturday': 5,
'sun': 6, 'sunday': 6,
}
@dataclass
class Occurrence:
"""반복 패턴이 펼친 한 인스턴스."""
date: date
leave_type: str
days: float
pattern: str
memo: str = ''
recurring_id: Optional[int] = None
def _parse_pattern(pattern: str):
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
if not pattern or ':' not in pattern:
return None
kind, rest = pattern.split(':', 1)
kind = kind.strip().lower()
rest = rest.strip().lower()
if kind in ('weekly', 'biweekly'):
days = [d.strip() for d in rest.split(',') if d.strip()]
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
if not weekdays:
return None
return (kind, weekdays)
if kind == 'monthly':
try:
day_of_month = int(rest)
if 1 <= day_of_month <= 31:
return (kind, day_of_month)
except ValueError:
return None
return None
def matches(rec: Dict, target_date: date) -> bool:
"""단일 패턴 rec이 target_date에 매치되는지."""
start = _parse_date(rec.get('start_date'))
end = _parse_date(rec.get('end_date'))
if start is None:
return False
if target_date < start:
return False
if end is not None and target_date > end:
return False
parsed = _parse_pattern(rec.get('pattern', ''))
if parsed is None:
return False
kind, info = parsed
if kind == 'weekly':
return target_date.weekday() in info
if kind == 'biweekly':
if target_date.weekday() not in info:
return False
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
weeks = (target_date - start).days // 7
return weeks % 2 == 0
if kind == 'monthly':
return target_date.day == info
return False
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
반환은 날짜 오름차순. 같은 여러 패턴이 매치되면 모두 포함.
"""
out: List[Occurrence] = []
if start > end:
return out
cur = start
while cur <= end:
for r in records:
try:
if matches(r, cur):
out.append(Occurrence(
date=cur,
leave_type=r.get('leave_type') or '연차',
days=float(r.get('days') or 0),
pattern=r.get('pattern', ''),
memo=r.get('memo') or '',
recurring_id=r.get('id'),
))
except Exception:
# 잘못된 패턴 1개가 전체를 망치지 않도록
continue
cur += timedelta(days=1)
return out
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
"""단일 날짜에 매치되는 인스턴스만."""
return expand_for_range(records, target_date, target_date)
def _parse_date(s: Optional[str]) -> Optional[date]:
if not s:
return None
try:
return datetime.strptime(s, '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
_KO_WEEKDAY_NAMES = ['', '', '', '', '', '', '']
def describe_pattern(pattern: str) -> str:
"""사용자에게 보여줄 패턴 설명. ko."""
parsed = _parse_pattern(pattern)
if parsed is None:
return pattern
kind, info = parsed
if kind in ('weekly', 'biweekly'):
names = [_KO_WEEKDAY_NAMES[w] for w in info]
prefix = '매주' if kind == 'weekly' else '격주'
return f"{prefix} {','.join(names)}요일"
if kind == 'monthly':
return f"매월 {info}"
return pattern

View File

@ -236,6 +236,55 @@ class TimeCalculator:
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
def effective_work_minutes(self, date_obj: datetime, db) -> int:
"""해당 날짜의 실효 근무 시간(분).
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
종일 연차(>= work_minutes) 0 반환.
Args:
date_obj: 기준 날짜 (datetime; 시각은 무시)
db: Database 인스턴스 (None이면 차감 없음)
Returns:
실효 work_minutes (>= 0)
"""
if db is None:
return self.work_minutes
date_str = date_obj.strftime("%Y-%m-%d")
leave_min = db.get_leave_minutes_for(date_str)
return max(0, self.work_minutes - leave_min)
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
include_lunch: bool = False,
include_dinner: bool = False,
break_minutes: int = 0,
unit_minutes: int = 30) -> Tuple[int, int]:
"""
휴일/주말 근무: 모든 시간을 연장근무로 계산.
Args:
clock_in: 출근 시간
current_time: 현재(또는 퇴근) 시간
include_lunch/dinner: 식사 시간 차감 여부
break_minutes: 외출 시간 () 연장근무에서 제외
unit_minutes: 적립 단위 (15/30/60)
Returns:
(실제 연장 , 적립 ) 0 이상.
"""
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
if include_lunch:
elapsed_minutes -= self.lunch_duration_minutes
if include_dinner:
elapsed_minutes -= self.dinner_duration_minutes
elapsed_minutes -= break_minutes
elapsed_minutes = max(0, elapsed_minutes)
unit = unit_minutes if unit_minutes > 0 else 30
earned = (elapsed_minutes // unit) * unit
return elapsed_minutes, earned
def is_weekend(self, date_obj: datetime) -> bool:
"""
주말 여부 확인

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 .
"""
__version__ = '2.8.0'
__version__ = '2.9.0'

View File

@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
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
class TestLeaveQueriesByDate:
def test_get_leave_minutes_for_no_records(self, fresh_db):
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
def test_full_day_leave_detected(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
assert fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
def test_half_day_not_full(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
assert not fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
def test_two_halves_become_full(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
assert fresh_db.has_full_day_leave('2026-05-15')
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
def test_records_by_date(self, fresh_db):
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
recs = fresh_db.get_leave_records_by_date('2026-05-15')
assert len(recs) == 1
assert recs[0]['leave_type'] == '연차'
assert recs[0]['memo'] == '메모'
def test_records_by_range(self, fresh_db):
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
assert len(recs) == 2
# 날짜 정렬
assert recs[0]['date'] == '2026-05-01'
assert recs[1]['date'] == '2026-05-10'
class TestRecurringLeavesDB:
def test_add_and_list(self, fresh_db):
rid = fresh_db.add_recurring_leave(
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
)
assert rid > 0
recs = fresh_db.get_recurring_leaves()
assert len(recs) == 1
assert recs[0]['pattern'] == 'weekly:friday'
assert recs[0]['memo'] == '단축'
def test_active_on_filter(self, fresh_db):
# 종료일이 지난 패턴
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
'2025-01-01', '2025-12-31')
# 아직 시작 안 한 패턴
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
'2027-01-01', None)
# 현재 활성 패턴
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
'2026-01-01', None)
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
assert len(active) == 1
assert active[0]['pattern'] == 'monthly:15'
def test_delete(self, fresh_db):
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
'2026-01-01')
assert len(fresh_db.get_recurring_leaves()) == 1
fresh_db.delete_recurring_leave(rid)
assert fresh_db.get_recurring_leaves() == []
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
# 매주 금요일 반차
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
'2026-01-01')
# 2026-05-01 = Friday → 240분
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
# 2026-05-04 = Monday → 0분
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
def test_concrete_plus_recurring_sum(self, fresh_db):
# 매주 금요일 반차 + 그날 별도 반반차 추가
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
# 0.5 + 0.25 = 0.75일 = 360분
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
assert not fresh_db.has_full_day_leave('2026-05-01')
def test_concrete_plus_recurring_full_day(self, fresh_db):
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
# 0.5 + 0.5 = 1.0일
assert fresh_db.has_full_day_leave('2026-05-01')

View File

@ -0,0 +1,153 @@
"""
core.recurring_leaves 단위 테스트.
"""
import os
import sys
from datetime import date
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.recurring_leaves import (
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
)
class TestParsePattern:
@pytest.mark.parametrize("pattern,expected_kind", [
('weekly:friday', 'weekly'),
('weekly:fri', 'weekly'),
('weekly:mon,wed,fri', 'weekly'),
('biweekly:friday', 'biweekly'),
('monthly:15', 'monthly'),
('monthly:1', 'monthly'),
])
def test_valid(self, pattern, expected_kind):
result = _parse_pattern(pattern)
assert result is not None
assert result[0] == expected_kind
@pytest.mark.parametrize("pattern", [
'', 'weekly', 'weekly:', 'weekly:xyz',
'monthly:0', 'monthly:32', 'monthly:abc',
'unknown:fri', None,
])
def test_invalid(self, pattern):
assert _parse_pattern(pattern) is None
class TestMatches:
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
return {
'pattern': pattern,
'start_date': start,
'end_date': end,
'days': days,
'leave_type': leave_type,
}
def test_weekly_single_day(self):
rec = self._rec('weekly:friday')
assert matches(rec, date(2026, 5, 1)) # Fri
assert not matches(rec, date(2026, 5, 2)) # Sat
assert not matches(rec, date(2026, 5, 4)) # Mon
def test_weekly_multiple_days(self):
rec = self._rec('weekly:mon,wed,fri')
assert matches(rec, date(2026, 5, 4)) # Mon
assert matches(rec, date(2026, 5, 6)) # Wed
assert matches(rec, date(2026, 5, 8)) # Fri
assert not matches(rec, date(2026, 5, 5)) # Tue
assert not matches(rec, date(2026, 5, 7)) # Thu
def test_biweekly_alignment(self):
# start_date 2026-01-02 = Friday (week 0)
rec = self._rec('biweekly:friday', start='2026-01-02')
assert matches(rec, date(2026, 1, 2)) # week 0
assert not matches(rec, date(2026, 1, 9)) # week 1
assert matches(rec, date(2026, 1, 16)) # week 2
def test_monthly(self):
rec = self._rec('monthly:15')
assert matches(rec, date(2026, 1, 15))
assert matches(rec, date(2026, 5, 15))
assert not matches(rec, date(2026, 5, 14))
assert not matches(rec, date(2026, 5, 16))
def test_monthly_skipped_in_short_month(self):
# 31일은 30일 달에는 매치되지 않음
rec = self._rec('monthly:31')
assert matches(rec, date(2026, 1, 31))
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
def test_before_start(self):
rec = self._rec('weekly:friday', start='2026-05-01')
assert matches(rec, date(2026, 5, 1))
assert not matches(rec, date(2026, 4, 24)) # 시작 전
def test_after_end(self):
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
def test_no_end_means_forever(self):
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
def test_invalid_pattern_returns_false(self):
rec = self._rec('garbage:xyz')
assert not matches(rec, date(2026, 5, 1))
class TestExpandRange:
def _rec(self, pattern, start='2026-01-01'):
return {
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
'days': 0.5, 'leave_type': '반차', 'memo': '',
}
def test_expand_weekly_one_month(self):
rec = self._rec('weekly:friday')
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
assert len(occs) == 5
assert all(o.date.weekday() == 4 for o in occs)
def test_expand_empty_when_outside(self):
rec = self._rec('weekly:friday', start='2027-01-01')
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
assert occs == []
def test_expand_invalid_range(self):
# start > end
rec = self._rec('weekly:friday')
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
assert occs == []
def test_expand_multiple_recs(self):
rec_fri = self._rec('weekly:friday')
rec_mon = self._rec('weekly:monday')
rec_mon['id'] = 2
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
assert len(occs) == 2
def test_expand_for_date_single(self):
rec = self._rec('monthly:15')
occs = expand_for_date([rec], date(2026, 5, 15))
assert len(occs) == 1
assert occs[0].date == date(2026, 5, 15)
class TestDescribePattern:
def test_weekly_korean(self):
assert '매주' in describe_pattern('weekly:friday')
assert '' in describe_pattern('weekly:friday')
def test_biweekly(self):
assert '격주' in describe_pattern('biweekly:friday')
def test_monthly(self):
assert '매월' in describe_pattern('monthly:15')
assert '15' in describe_pattern('monthly:15')

View File

@ -98,3 +98,77 @@ class TestDayType:
mon = datetime(2026, 5, 4)
assert not calc.is_weekend(mon)
assert calc.get_day_type(mon) == 'normal'
class TestHolidayOvertime:
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
def test_zero_elapsed_returns_zero(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
assert actual == 0 and earned == 0
def test_one_minute_elapsed_no_lunch(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=1)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 1
assert earned == 0 # 30분 단위 절삭
def test_30min_elapsed_truncates_to_30(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=30)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 30 and earned == 30
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=29)
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
assert actual == 29 and earned == 0
def test_lunch_subtracted(self, calc_8h):
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=9)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, include_lunch=True
)
assert actual == 8 * 60
assert earned == 8 * 60
def test_break_minutes_subtracted(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=2)
# 외출 30분 → 90분 적립
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, break_minutes=30
)
assert actual == 90 and earned == 90
def test_unit_minutes_15(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=44)
# 44분 → 30분 적립 (15분 단위)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, unit_minutes=15
)
assert actual == 44 and earned == 30
def test_unit_minutes_60(self, calc_8h):
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(minutes=119)
# 119분 → 60분 적립 (60분 단위)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, unit_minutes=60
)
assert actual == 119 and earned == 60
def test_negative_clamped_to_zero(self, calc_8h):
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
ci = datetime(2026, 5, 1, 9, 0)
now = ci + timedelta(hours=1)
actual, earned = calc_8h.calculate_holiday_overtime(
ci, now, include_lunch=True, include_dinner=True
)
assert actual == 0 and earned == 0

View File

@ -82,10 +82,13 @@ class NotificationOrchestrator:
except Exception as e:
dlog(f"discord weekly_report failed: {e}")
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
is_holiday: bool = False) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
n.check_clock_out_soon(expected_clock_out, now)
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
if not is_holiday:
n.check_clock_out_soon(expected_clock_out, now)
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
n.check_lunch_reminder(self.window.clock_in_time,
self.window.lunch_break_enabled, now)
n.check_dinner_reminder(self.window.clock_in_time,

View File

@ -43,11 +43,10 @@ class LeaveCalendarView(QDialog):
header.addStretch()
layout.addLayout(header)
# 범례
# 범례 (사용 완료 + 예정 분리)
legend = QHBoxLayout()
for label, color in [("🟩 종일(1.0)", "#4caf50"),
("🟨 반차(0.5)", "#ffc107"),
("🟪 반반차(0.25)", "#9c27b0")]:
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
"🔵 예정", "🔘 종일+예정"]:
l = QLabel(label)
l.setStyleSheet(f"padding: 2px 6px;")
legend.addWidget(l)
@ -76,7 +75,9 @@ class LeaveCalendarView(QDialog):
self.setLayout(layout)
def _mark_dates(self):
"""연차 사용 일자에 색상 표시."""
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
from datetime import date as _date
today = _date.today()
records = self.db.get_all_leave_records(limit=365)
for r in records:
try:
@ -85,12 +86,18 @@ class LeaveCalendarView(QDialog):
continue
qd = QDate(d.year, d.month, d.day)
days = float(r.get('days') or 0)
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
is_planned = d > today
if is_planned:
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
else:
color = QColor("#9c27b0")
# 과거/오늘 = 사용 완료 색상
if days >= 1.0:
color = QColor("#4caf50")
elif days >= 0.5:
color = QColor("#ffc107")
else:
color = QColor("#9c27b0")
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor("white")))

View File

@ -90,6 +90,11 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
schedule_button = QPushButton("🗓️ 스케줄")
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_button)
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
@ -102,6 +107,13 @@ class LeaveView(QDialog):
dlg = LeaveCalendarView(self, self.db)
dlg.exec_()
def _show_schedule(self):
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
# 닫고 돌아오면 잔액/리스트 갱신
self.load_data()
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
@ -345,6 +359,38 @@ class AddLeaveDialog(QDialog):
)
return
# 휴일/주말 검증 — 차감 의미 없으므로 차단
from datetime import datetime as _dt
date_dt = _dt.strptime(date, "%Y-%m-%d")
if date_dt.weekday() in (5, 6): # 토/일
QMessageBox.warning(
self,
"주말 등록 불가",
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
)
return
if self.db.is_holiday(date):
holiday = self.db.get_holiday(date)
name = (holiday or {}).get('name', '공휴일')
QMessageBox.warning(
self,
"공휴일 등록 불가",
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
)
return
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
existing_min = self.db.get_leave_minutes_for(date)
existing_days = existing_min / max(1, self.db.get_work_minutes())
if existing_days + days > 1.0001: # 부동소수점 여유
QMessageBox.warning(
self,
"중복 등록 초과",
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
)
return
# 확인 메시지
hours = days * 8
reply = QMessageBox.question(

View File

@ -631,6 +631,21 @@ class MainWindow(QMainWindow):
self.clock_out_button.setText("✅ 퇴근하기")
else:
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
today_str = datetime.now().date().isoformat()
if self.db.has_full_day_leave(today_str):
self.is_clocked_in = False
self.clock_out_button.setEnabled(False)
# 점심/저녁/외출/잔액 갱신만 수행
self.lunch_button.setChecked(False)
self.update_lunch_status()
self.dinner_button.setChecked(False)
self.update_dinner_status()
self.update_break_status()
self.update_overtime_balance()
self.update_leave_balance()
return
# 출근 기록 없음 - 자동 감지 시도
auto_clock_in = self.event_monitor.get_work_start_time()
@ -701,6 +716,14 @@ class MainWindow(QMainWindow):
self.start_new_workday(now)
return
# 종일 연차일 — 출근 안 한 상태에서 전용 카드만 표시 후 종료.
# (수동 출근 override는 handle_clock_in 경로에서 별도 처리)
if not self.is_clocked_in:
today_str = now.date().isoformat()
if self.db.has_full_day_leave(today_str):
self._render_full_day_leave_state(today_str)
return
# 출근하지 않았으면 여기서 종료
if not self.is_clocked_in or not self.clock_in_time:
return
@ -728,21 +751,46 @@ class MainWindow(QMainWindow):
# 총 차감 시간 (추가근무 + 연차/반차)
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)
# 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름.
is_non_working = self.time_calc.is_non_working_day(now, self.db)
is_full_day_leave = self.db.has_full_day_leave(now.date().isoformat())
is_holiday = is_non_working or is_full_day_leave
if is_holiday:
actual_ot, _ = self.time_calc.calculate_holiday_overtime(
self.clock_in_time, now,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
)
# 사용한 추가근무 차감만 반영 (leave_used는 holiday/override 케이스에서 의미 없음)
actual_ot = max(0, actual_ot - overtime_used_today)
remaining = -timedelta(minutes=actual_ot)
else:
# 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 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("추가 근무 중")
# 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기)
day_type = self.time_calc.get_day_type(now, self.db)
if is_full_day_leave and not is_non_working:
self.remaining_time_group.setTitle("연차 override (전체 적립)")
elif day_type == 'weekend':
self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
elif day_type == 'holiday':
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
else:
self.remaining_time_group.setTitle("추가 근무 중")
# + 기호로 표시
total_seconds = int(abs(remaining.total_seconds()))
hours = total_seconds // 3600
@ -765,35 +813,46 @@ class MainWindow(QMainWindow):
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))
# 휴일은 정해진 근무시간이 없으므로 게이지 의미 없음 → 100%로 채워둠.
if is_holiday:
self.progress_bar.setValue(100)
else:
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)}"
)
# 휴일은 정해진 퇴근 시각이 없음 → 출근 시각을 그대로 표시 (= 즉시 적립 시작 의미)
if is_holiday:
expected_clock_out = self.clock_in_time
self._set_text_if_changed(
self.expected_time_label,
"휴일 근무 (정해진 퇴근시각 없음)"
)
else:
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())
# 휴일이면 "퇴근 30분 전" 알림은 의미 없으므로 플래그로 게이팅.
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds(),
is_holiday=is_holiday)
# 트레이 / 미니 위젯 갱신
if remaining.total_seconds() < 0:
@ -812,6 +871,35 @@ class MainWindow(QMainWindow):
date_str = f"{now.year}{now.month}{now.day}{weekday}요일"
self.date_label.setText(date_str)
def _render_full_day_leave_state(self, today_str: str) -> None:
"""오늘이 종일 연차이고 출근 안 한 상태 → 카운트다운 대신 휴가 카드 표시."""
records = self.db.get_leave_records_by_date(today_str)
# 가장 큰 일수의 leave_type을 대표로 표시 (보통 1.0짜리 1건)
if records:
primary = max(records, key=lambda r: r.get('days') or 0)
label = primary.get('leave_type') or '연차'
memo = primary.get('memo') or ''
else:
label = '연차'
memo = ''
self.remaining_time_group.setTitle("🌴 오늘은 휴가")
self.remaining_time_label.setText("연차 사용 중")
self.remaining_time_label.setStyleSheet(
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
)
self.progress_bar.setValue(100)
if memo:
self._set_text_if_changed(self.expected_time_label,
f"🌴 {label}{memo}")
else:
self._set_text_if_changed(self.expected_time_label,
f"🌴 {label}")
# 트레이/미니 위젯
self.tray_icon.update_time_display("🌴 휴가")
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
self._mini_widget.update_remaining("🌴 휴가")
def toggle_lunch_break(self):
"""점심시간 토글 — MealController 위임."""
self._meal.toggle_lunch()
@ -1156,16 +1244,14 @@ class MainWindow(QMainWindow):
unit_minutes = 30
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 // unit_minutes) * unit_minutes
overtime_actual = work_minutes
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 시간 제외)
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
self.clock_in_time, now,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
else:
# 평일: 정상 연장근무 계산 (외출 시간 포함)
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
@ -1342,24 +1428,25 @@ class MainWindow(QMainWindow):
break_minutes = cursor.fetchone()[0] or 0
conn.close()
# 추가근무 계산
# 추가근무 계산 (사용자 설정 적립 단위 적용)
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
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
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
self.clock_in_time, workday_end,
include_lunch=self.lunch_break_enabled,
include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes,
unit_minutes=unit_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
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
@ -1498,26 +1585,26 @@ class MainWindow(QMainWindow):
lunch_enabled = bool(record.get('lunch_break', False))
dinner_enabled = bool(record.get('dinner_break', False))
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
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
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 제외)
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
clock_in_time, shutdown_time,
include_lunch=lunch_enabled,
include_dinner=dinner_enabled,
break_minutes=break_minutes,
unit_minutes=unit_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
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
@ -1569,23 +1656,24 @@ class MainWindow(QMainWindow):
lunch_enabled = bool(record.get('lunch_break', False))
dinner_enabled = bool(record.get('dinner_break', False))
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
if unit_minutes not in (15, 30, 60):
unit_minutes = 30
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
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
clock_in_time, fallback_time,
include_lunch=lunch_enabled,
include_dinner=dinner_enabled,
break_minutes=break_minutes,
unit_minutes=unit_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
break_minutes=break_minutes,
unit_minutes=unit_minutes,
)
# DB 업데이트
@ -1605,6 +1693,20 @@ class MainWindow(QMainWindow):
def manual_clock_in(self):
"""수동 출근 시간 입력"""
# 종일 연차 등록일이면 override 의도 확인
today_str = datetime.now().date().isoformat()
if self.db.has_full_day_leave(today_str):
reply = QMessageBox.question(
self,
"종일 연차 등록됨",
"오늘은 종일 연차로 등록되어 있습니다.\n"
"그래도 출근하시겠어요?\n\n"
"(출근 시 모든 시간이 연장근무로 적립됩니다.)",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
default_time = self.clock_in_time if self.clock_in_time else None
@ -1676,6 +1778,12 @@ class MainWindow(QMainWindow):
dialog = CalendarView(self, self.db)
dialog.exec_()
def show_schedule(self):
"""통합 스케줄(휴일+연차+반복) 창 표시."""
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
def show_leave_management(self):
"""휴가 관리 창 표시"""
dialog = LeaveView(self, self.db)

View File

@ -0,0 +1,199 @@
"""
반복 연차 등록/관리 다이얼로그.
지원: 매주/격주 요일, 매월 N일.
"""
from __future__ import annotations
from datetime import date
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QComboBox, QDateEdit, QSpinBox,
QDoubleSpinBox, QLineEdit, QGroupBox,
QListWidget, QListWidgetItem, QMessageBox,
QCheckBox, QButtonGroup, QRadioButton)
from PyQt5.QtCore import QDate, Qt
from core.recurring_leaves import describe_pattern
from ui.styles import apply_dark_titlebar
_KO_WEEKDAYS = [('', 'mon'), ('', 'tue'), ('', 'wed'),
('', 'thu'), ('', 'fri'), ('', 'sat'), ('', 'sun')]
class RecurringLeaveDialog(QDialog):
"""반복 연차 패턴 추가/삭제."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("🔁 반복 연차 관리")
self.setMinimumSize(540, 480)
self._build_ui()
self._reload_list()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 기존 패턴 목록
list_group = QGroupBox("등록된 반복 패턴")
lg = QVBoxLayout()
self.list_widget = QListWidget()
self.list_widget.setMinimumHeight(160)
lg.addWidget(self.list_widget)
del_btn = QPushButton("선택 삭제")
del_btn.clicked.connect(self._delete_selected)
lg.addWidget(del_btn)
list_group.setLayout(lg)
layout.addWidget(list_group)
# 신규 등록
add_group = QGroupBox("신규 패턴 추가")
ag = QVBoxLayout()
# 패턴 종류
kind_row = QHBoxLayout()
kind_row.addWidget(QLabel("주기:"))
self.kind_group = QButtonGroup(self)
self.rb_weekly = QRadioButton("매주")
self.rb_weekly.setChecked(True)
self.rb_biweekly = QRadioButton("격주")
self.rb_monthly = QRadioButton("매월 N일")
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
self.kind_group.addButton(rb)
kind_row.addWidget(rb)
kind_row.addStretch()
ag.addLayout(kind_row)
# 요일 체크박스 (weekly/biweekly)
wd_row = QHBoxLayout()
wd_row.addWidget(QLabel("요일:"))
self.weekday_checks = []
for ko, en in _KO_WEEKDAYS:
cb = QCheckBox(ko)
self.weekday_checks.append((cb, en))
wd_row.addWidget(cb)
wd_row.addStretch()
ag.addLayout(wd_row)
# 매월 N일
month_row = QHBoxLayout()
month_row.addWidget(QLabel("매월:"))
self.day_of_month = QSpinBox()
self.day_of_month.setRange(1, 31)
self.day_of_month.setValue(15)
self.day_of_month.setSuffix("")
month_row.addWidget(self.day_of_month)
month_row.addStretch()
ag.addLayout(month_row)
# 차감 일수
days_row = QHBoxLayout()
days_row.addWidget(QLabel("차감:"))
self.days_combo = QComboBox()
self.days_combo.addItem("1.0일 (종일)", 1.0)
self.days_combo.addItem("0.5일 (반차)", 0.5)
self.days_combo.addItem("0.25일 (반반차)", 0.25)
days_row.addWidget(self.days_combo)
days_row.addStretch()
ag.addLayout(days_row)
# 시작/종료 날짜
date_row = QHBoxLayout()
date_row.addWidget(QLabel("시작:"))
self.start_edit = QDateEdit()
self.start_edit.setDate(QDate.currentDate())
self.start_edit.setCalendarPopup(True)
date_row.addWidget(self.start_edit)
date_row.addWidget(QLabel("종료:"))
self.end_edit = QDateEdit()
self.end_edit.setDate(QDate.currentDate().addMonths(6))
self.end_edit.setCalendarPopup(True)
date_row.addWidget(self.end_edit)
self.no_end_check = QCheckBox("종료 없음 (무기한)")
self.no_end_check.toggled.connect(
lambda v: self.end_edit.setEnabled(not v)
)
date_row.addWidget(self.no_end_check)
date_row.addStretch()
ag.addLayout(date_row)
# 메모
memo_row = QHBoxLayout()
memo_row.addWidget(QLabel("메모:"))
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
memo_row.addWidget(self.memo_edit)
ag.addLayout(memo_row)
# 추가 버튼
add_btn = QPushButton(" 추가")
add_btn.setObjectName("btn_primary")
add_btn.clicked.connect(self._save)
ag.addWidget(add_btn)
add_group.setLayout(ag)
layout.addWidget(add_group)
# 닫기
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
def _reload_list(self):
self.list_widget.clear()
for r in self.db.get_recurring_leaves():
desc = describe_pattern(r['pattern'])
end = r.get('end_date') or '무기한'
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
f"· {r['start_date']} ~ {end}")
if r.get('memo'):
text += f"{r['memo']}"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, r['id'])
self.list_widget.addItem(item)
def _delete_selected(self):
item = self.list_widget.currentItem()
if not item:
return
rec_id = item.data(Qt.UserRole)
reply = QMessageBox.question(
self, "삭제 확인",
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(rec_id)
self._reload_list()
def _build_pattern(self) -> str | None:
if self.rb_monthly.isChecked():
return f"monthly:{self.day_of_month.value()}"
# weekly/biweekly
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
if not chosen:
return None
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
return f"{prefix}:" + ",".join(chosen)
def _save(self):
pattern = self._build_pattern()
if not pattern:
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
return
days = self.days_combo.currentData()
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
start = self.start_edit.date().toString('yyyy-MM-dd')
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
memo = self.memo_edit.text().strip()
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
QMessageBox.information(self, "추가 완료",
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
self.memo_edit.clear()
self._reload_list()

315
ui/schedule_view.py Normal file
View File

@ -0,0 +1,315 @@
"""
통합 스케줄 화면 휴일 + 연차(예정/사용) + 반복 패턴.
기능:
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 /, 반차 노랑, 반반차 보라, 반복 회색)
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
- 반복 패턴 관리 RecurringLeaveDialog
"""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import List
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget, QListWidget,
QListWidgetItem, QMessageBox, QMenu,
QGroupBox, QSplitter, QWidget)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
from core.recurring_leaves import expand_for_range, describe_pattern
from ui.styles import apply_dark_titlebar
# 색상 팔레트
_C_HOLIDAY = QColor("#e53935") # 빨강
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
class ScheduleView(QDialog):
"""월간 통합 스케줄 다이얼로그."""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.setWindowTitle("🗓️ 스케줄")
self.setMinimumSize(820, 560)
self._build_ui()
self._reload()
apply_dark_titlebar(self)
def _build_ui(self):
layout = QVBoxLayout()
# 상단 툴바
bar = QHBoxLayout()
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
bar.addWidget(title)
bar.addStretch()
rec_btn = QPushButton("🔁 반복 패턴 관리")
rec_btn.clicked.connect(self._open_recurring_dialog)
bar.addWidget(rec_btn)
add_btn = QPushButton(" 연차 등록")
add_btn.clicked.connect(self._open_add_leave_dialog)
bar.addWidget(add_btn)
layout.addLayout(bar)
# 범례
legend = QHBoxLayout()
for label, color in [("공휴일", _C_HOLIDAY),
("연차 사용", _C_LEAVE_FULL_PAST),
("연차 예정", _C_LEAVE_FULL_PLAN),
("반차/반반차", _C_LEAVE_HALF_PAST),
("반복 패턴", _C_RECURRING)]:
sw = QLabel(f" {label} ")
sw.setStyleSheet(
f"background-color: {color.name()}; color: white; "
f"padding: 2px 6px; border-radius: 3px;"
)
legend.addWidget(sw)
legend.addStretch()
layout.addLayout(legend)
# 캘린더 + 상세 splitter
splitter = QSplitter(Qt.Horizontal)
# 좌측: 캘린더
self.calendar = QCalendarWidget()
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self._on_date_click)
self.calendar.currentPageChanged.connect(self._on_page_change)
splitter.addWidget(self.calendar)
# 우측: 상세 패널
right = QWidget()
right_layout = QVBoxLayout()
self.detail_title = QLabel("날짜를 선택하세요")
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
right_layout.addWidget(self.detail_title)
self.detail_list = QListWidget()
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
right_layout.addWidget(self.detail_list, 1)
right.setLayout(right_layout)
splitter.addWidget(right)
splitter.setSizes([520, 280])
layout.addWidget(splitter, 1)
close_btn = QPushButton("닫기")
close_btn.clicked.connect(self.close)
layout.addWidget(close_btn)
self.setLayout(layout)
# ------------------------------------------------------------- reload
def _reload(self):
"""현재 화면 월에 대해 색상/리스트 갱신."""
# 모든 날짜 포맷 초기화
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
y = self.calendar.yearShown()
m = self.calendar.monthShown()
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
first = date(y, m, 1)
if m == 12:
last = date(y + 1, 1, 1) - timedelta(days=1)
else:
last = date(y, m + 1, 1) - timedelta(days=1)
view_start = first - timedelta(days=7)
view_end = last + timedelta(days=7)
# 휴일
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
view_end.isoformat()) \
if hasattr(self.db, 'get_holidays_in_range') else []
if not holidays:
holidays = self._fallback_holidays(view_start, view_end)
for h in holidays:
d = self._parse_date(h.get('date'))
if d is None:
continue
self._paint(d, _C_HOLIDAY, fg='white')
# 연차 (구체)
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
view_end.isoformat())
today = date.today()
for r in leaves:
d = self._parse_date(r.get('date'))
if d is None:
continue
days = float(r.get('days') or 0)
is_planned = d > today
if is_planned:
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
else:
if days >= 1.0:
color = _C_LEAVE_FULL_PAST
elif days >= 0.5:
color = _C_LEAVE_HALF_PAST
else:
color = _C_LEAVE_QUART_PAST
self._paint(d, color, fg='white')
# 반복 패턴 인스턴스
recurring = self.db.get_recurring_leaves()
for occ in expand_for_range(recurring, view_start, view_end):
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
existing = self.calendar.dateTextFormat(
QDate(occ.date.year, occ.date.month, occ.date.day))
if existing.background() != QBrush():
continue
self._paint(occ.date, _C_RECURRING, fg='white')
def _paint(self, d: date, color: QColor, fg: str = 'white'):
qd = QDate(d.year, d.month, d.day)
fmt = QTextCharFormat()
fmt.setBackground(QBrush(color))
fmt.setForeground(QBrush(QColor(fg)))
self.calendar.setDateTextFormat(qd, fmt)
# ------------------------------------------------------------- events
def _on_date_click(self, qd: QDate):
d = date(qd.year(), qd.month(), qd.day())
date_str = d.isoformat()
weekday_kr = ['', '', '', '', '', '', '']
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
self.detail_list.clear()
# 휴일
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
if holiday:
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
item.setForeground(QBrush(QColor("#e53935")))
self.detail_list.addItem(item)
elif d.weekday() in (5, 6):
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
self.detail_list.addItem(item)
# 연차 (구체)
for r in self.db.get_leave_records_by_date(date_str):
days = float(r.get('days') or 0)
t = r.get('leave_type', '연차')
memo = r.get('memo') or ''
label = f"📌 {t} {days}"
if memo:
label += f"{memo}"
label += f" [id={r['id']}]"
item = QListWidgetItem(label)
item.setData(Qt.UserRole, ('concrete', r['id']))
self.detail_list.addItem(item)
# 반복 패턴 매치
recurring = self.db.get_recurring_leaves(active_on=date_str)
from core.recurring_leaves import expand_for_date
for occ in expand_for_date(recurring, d):
item = QListWidgetItem(
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
)
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
self.detail_list.addItem(item)
if self.detail_list.count() == 0:
self.detail_list.addItem("일정 없음")
def _on_page_change(self, year: int, month: int):
self._reload()
def _on_list_menu(self, pos):
item = self.detail_list.currentItem()
if not item:
return
data = item.data(Qt.UserRole)
if not data:
return
kind, _id = data
menu = QMenu(self)
del_act = menu.addAction("삭제")
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
if chosen == del_act:
self._delete_record(kind, _id)
def _delete_record(self, kind: str, _id: int):
if kind == 'concrete':
reply = QMessageBox.question(
self, "삭제 확인",
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_leave_record(_id)
self._reload()
# 상세 갱신
d = self.calendar.selectedDate()
self._on_date_click(d)
elif kind == 'recurring':
reply = QMessageBox.question(
self, "삭제 확인",
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.db.delete_recurring_leave(_id)
self._reload()
d = self.calendar.selectedDate()
self._on_date_click(d)
def _open_recurring_dialog(self):
from ui.recurring_leave_dialog import RecurringLeaveDialog
dlg = RecurringLeaveDialog(self, self.db)
dlg.exec_()
self._reload()
def _open_add_leave_dialog(self):
from ui.leave_view import AddLeaveDialog
dlg = AddLeaveDialog(self, self.db)
# 선택된 날짜로 기본값 설정
d = self.calendar.selectedDate()
if d.isValid():
dlg.date_edit.setDate(d)
if dlg.exec_() == dlg.Accepted:
self._reload()
self._on_date_click(d)
# ------------------------------------------------------------- helpers
@staticmethod
def _parse_date(s):
if not s:
return None
try:
return datetime.strptime(s, '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
if not hasattr(self.db, 'get_holiday'):
return []
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
out = []
cur = view_start
while cur <= view_end:
h = self.db.get_holiday(cur.isoformat())
if h:
out.append(h)
cur += timedelta(days=1)
return out

View File

@ -98,6 +98,10 @@ class SystemTrayIcon(QSystemTrayIcon):
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action)
schedule_action = QAction("🗓️ 스케줄", self)
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
menu.addAction(schedule_action)
help_action = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action)