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:
parent
d41e5cb921
commit
c98ca361cd
@ -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()
|
||||
|
||||
122
core/database.py
122
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):
|
||||
"""초기 연장근무 잔액 추가"""
|
||||
|
||||
153
core/recurring_leaves.py
Normal file
153
core/recurring_leaves.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
153
tests/test_recurring_leaves.py
Normal file
153
tests/test_recurring_leaves.py
Normal 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')
|
||||
@ -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")))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
199
ui/recurring_leave_dialog.py
Normal file
199
ui/recurring_leave_dialog.py
Normal 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
315
ui/schedule_view.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user