diff --git a/_integration_test.py b/_integration_test.py index 4591b3a..afe0bfe 100644 --- a/_integration_test.py +++ b/_integration_test.py @@ -696,6 +696,50 @@ 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.""" @@ -789,6 +833,10 @@ def main(): s50_goals() s51_accessibility_keys() s52a_holiday_hotpath() + s52b_planned_leave() + s52c_recurring_leave() + s52d_effective() + s52e_full_day() s52_csv_overtime() print() diff --git a/core/database.py b/core/database.py index c5b5c12..1afa3ef 100644 --- a/core/database.py +++ b/core/database.py @@ -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): """초기 연장근무 잔액 추가""" diff --git a/core/recurring_leaves.py b/core/recurring_leaves.py new file mode 100644 index 0000000..64f2856 --- /dev/null +++ b/core/recurring_leaves.py @@ -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 diff --git a/core/time_calculator.py b/core/time_calculator.py index 047d58d..cdc7627 100644 --- a/core/time_calculator.py +++ b/core/time_calculator.py @@ -236,6 +236,25 @@ 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, diff --git a/tests/test_database.py b/tests/test_database.py index c42e2e4..315d805 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -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') diff --git a/tests/test_recurring_leaves.py b/tests/test_recurring_leaves.py new file mode 100644 index 0000000..95d17f1 --- /dev/null +++ b/tests/test_recurring_leaves.py @@ -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') diff --git a/ui/leave_calendar_view.py b/ui/leave_calendar_view.py index 619bc38..4da2125 100644 --- a/ui/leave_calendar_view.py +++ b/ui/leave_calendar_view.py @@ -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"))) diff --git a/ui/leave_view.py b/ui/leave_view.py index cbaaf9b..0fa671a 100644 --- a/ui/leave_view.py +++ b/ui/leave_view.py @@ -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( diff --git a/ui/main_window.py b/ui/main_window.py index b445919..5506379 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -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,9 +751,10 @@ class MainWindow(QMainWindow): # 총 차감 시간 (추가근무 + 연차/반차) total_time_off = overtime_used_today + leave_used_today - # 휴일/주말이면 출근 직후부터 모든 시간이 연장근무로 흐름. - # 정해진 퇴근 시각이 없으므로 calculate_remaining_time 분기를 건너뛴다. - is_holiday = self.time_calc.is_non_working_day(now, self.db) + # 휴일/주말 또는 종일연차 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( @@ -739,11 +763,12 @@ class MainWindow(QMainWindow): include_dinner=self.dinner_break_enabled, break_minutes=break_minutes, ) - # 추가근무/반차 사용분만큼 시작점을 늦춤 (즉 그만큼 적게 적립) - actual_ot = max(0, actual_ot - total_time_off) + # 사용한 추가근무 차감만 반영 (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, @@ -756,9 +781,11 @@ class MainWindow(QMainWindow): # 남은 시간 표시 및 추가 근무 처리 if remaining.total_seconds() < 0: - # 추가 근무 중 (휴일이면 출근 직후부터 항상 이 분기) + # 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기) day_type = self.time_calc.get_day_type(now, self.db) - if day_type == 'weekend': + 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("공휴일 근무 (전체 적립)") @@ -844,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() @@ -1637,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 @@ -1708,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) diff --git a/ui/recurring_leave_dialog.py b/ui/recurring_leave_dialog.py new file mode 100644 index 0000000..ee9147f --- /dev/null +++ b/ui/recurring_leave_dialog.py @@ -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() diff --git a/ui/schedule_view.py b/ui/schedule_view.py new file mode 100644 index 0000000..f755069 --- /dev/null +++ b/ui/schedule_view.py @@ -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 diff --git a/utils/system_tray.py b/utils/system_tray.py index 8451979..64362d6 100644 --- a/utils/system_tray.py +++ b/utils/system_tray.py @@ -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)