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)
This commit is contained in:
KINDNICK 2026-05-01 13:07:52 +09:00
parent d41e5cb921
commit c98ca361cd
12 changed files with 1250 additions and 22 deletions

View File

@ -696,6 +696,50 @@ def s51_accessibility_keys():
assert db.get_setting_bool('high_contrast') is True 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 → 출근 직후부터 즉시 연장 적립") @case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
def s52a_holiday_hotpath(): def s52a_holiday_hotpath():
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30.""" """update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
@ -789,6 +833,10 @@ def main():
s50_goals() s50_goals()
s51_accessibility_keys() s51_accessibility_keys()
s52a_holiday_hotpath() s52a_holiday_hotpath()
s52b_planned_leave()
s52c_recurring_leave()
s52d_effective()
s52e_full_day()
s52_csv_overtime() s52_csv_overtime()
print() 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): def migrate_break_records_cascade(self):
"""break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션). """break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션).
@ -962,12 +980,108 @@ class Database:
def get_today_leave_minutes(self) -> int: def get_today_leave_minutes(self) -> int:
"""오늘 사용한 연차/반차 시간 조회 (분)""" """오늘 사용한 연차/반차 시간 조회 (분)"""
from datetime import date 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: with self._conn() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?', (today,)) cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?',
days = cursor.fetchone()[0] or 0.0 (date_str,))
return int(days * self.get_work_minutes()) 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): def add_initial_overtime_balance(self, minutes: 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,25 @@ class TimeCalculator:
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner) normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
return normal_clock_out + timedelta(minutes=target_overtime_minutes) 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, def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
include_lunch: bool = False, include_lunch: bool = False,
include_dinner: bool = False, include_dinner: bool = False,

View File

@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0, fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
overtime_minutes=120, overtime_earned=120) overtime_minutes=120, overtime_earned=120)
assert fresh_db.get_consecutive_overtime_days() == 3 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

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

View File

@ -90,6 +90,11 @@ class LeaveView(QDialog):
cal_button.clicked.connect(self._show_calendar) cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button) 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 = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close) close_button.clicked.connect(self.close)
button_layout.addWidget(close_button) button_layout.addWidget(close_button)
@ -102,6 +107,13 @@ class LeaveView(QDialog):
dlg = LeaveCalendarView(self, self.db) dlg = LeaveCalendarView(self, self.db)
dlg.exec_() 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): def load_data(self):
"""데이터 로드""" """데이터 로드"""
# 잔액 업데이트 # 잔액 업데이트
@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
self.date_edit = QDateEdit() self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate()) self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True) self.date_edit.setCalendarPopup(True)
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
row1.addWidget(date_label) row1.addWidget(date_label)
row1.addWidget(self.date_edit) row1.addWidget(self.date_edit)
row1.addSpacing(8) row1.addSpacing(8)
@ -345,6 +359,38 @@ class AddLeaveDialog(QDialog):
) )
return 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 hours = days * 8
reply = QMessageBox.question( reply = QMessageBox.question(

View File

@ -631,6 +631,21 @@ class MainWindow(QMainWindow):
self.clock_out_button.setText("✅ 퇴근하기") self.clock_out_button.setText("✅ 퇴근하기")
else: 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() auto_clock_in = self.event_monitor.get_work_start_time()
@ -701,6 +716,14 @@ class MainWindow(QMainWindow):
self.start_new_workday(now) self.start_new_workday(now)
return 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: if not self.is_clocked_in or not self.clock_in_time:
return return
@ -728,9 +751,10 @@ class MainWindow(QMainWindow):
# 총 차감 시간 (추가근무 + 연차/반차) # 총 차감 시간 (추가근무 + 연차/반차)
total_time_off = overtime_used_today + leave_used_today total_time_off = overtime_used_today + leave_used_today
# 휴일/주말이면 출근 직후부터 모든 시간이 연장근무로 흐름. # 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름.
# 정해진 퇴근 시각이 없으므로 calculate_remaining_time 분기를 건너뛴다. is_non_working = self.time_calc.is_non_working_day(now, self.db)
is_holiday = 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: if is_holiday:
actual_ot, _ = self.time_calc.calculate_holiday_overtime( actual_ot, _ = self.time_calc.calculate_holiday_overtime(
@ -739,11 +763,12 @@ class MainWindow(QMainWindow):
include_dinner=self.dinner_break_enabled, include_dinner=self.dinner_break_enabled,
break_minutes=break_minutes, break_minutes=break_minutes,
) )
# 추가근무/반차 사용분만큼 시작점을 늦춤 (즉 그만큼 적게 적립) # 사용한 추가근무 차감만 반영 (leave_used는 holiday/override 케이스에서 의미 없음)
actual_ot = max(0, actual_ot - total_time_off) actual_ot = max(0, actual_ot - overtime_used_today)
remaining = -timedelta(minutes=actual_ot) remaining = -timedelta(minutes=actual_ot)
else: else:
# 평일: 정상 남은 시간 계산 # 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 leave_used_today에
# 그대로 반영되어 카운트다운이 단축됨.
remaining = self.time_calc.calculate_remaining_time( remaining = self.time_calc.calculate_remaining_time(
self.clock_in_time, self.clock_in_time,
include_lunch=self.lunch_break_enabled, include_lunch=self.lunch_break_enabled,
@ -756,9 +781,11 @@ class MainWindow(QMainWindow):
# 남은 시간 표시 및 추가 근무 처리 # 남은 시간 표시 및 추가 근무 처리
if remaining.total_seconds() < 0: if remaining.total_seconds() < 0:
# 추가 근무 중 (휴일면 출근 직후부터 항상 이 분기) # 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기)
day_type = self.time_calc.get_day_type(now, self.db) 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("주말 근무 (전체 적립)") self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
elif day_type == 'holiday': elif day_type == 'holiday':
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)") self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
@ -844,6 +871,35 @@ class MainWindow(QMainWindow):
date_str = f"{now.year}{now.month}{now.day}{weekday}요일" date_str = f"{now.year}{now.month}{now.day}{weekday}요일"
self.date_label.setText(date_str) 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): def toggle_lunch_break(self):
"""점심시간 토글 — MealController 위임.""" """점심시간 토글 — MealController 위임."""
self._meal.toggle_lunch() self._meal.toggle_lunch()
@ -1637,6 +1693,20 @@ class MainWindow(QMainWindow):
def manual_clock_in(self): 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 # 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
default_time = self.clock_in_time if self.clock_in_time else 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 = CalendarView(self, self.db)
dialog.exec_() dialog.exec_()
def show_schedule(self):
"""통합 스케줄(휴일+연차+반복) 창 표시."""
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
def show_leave_management(self): def show_leave_management(self):
"""휴가 관리 창 표시""" """휴가 관리 창 표시"""
dialog = LeaveView(self, self.db) 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')) cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
menu.addAction(cal_action) 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 = QAction("📖 " + tr('menu.help'), self)
help_action.triggered.connect(lambda: self._call_parent('show_help')) help_action.triggered.connect(lambda: self._call_parent('show_help'))
menu.addAction(help_action) menu.addAction(help_action)