Some checks failed
CI / test (push) Has been cancelled
핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의) - Windows 이벤트 뷰어 자동 출퇴근 감지 - 30분 단위 연장근무 적립/사용 시스템 - 1.0/0.5/0.25일 연차·반차·반반차 - 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출 - 한국 공휴일 자동 등록 (음력 포함, holidays 패키지) - matplotlib 차트 기반 주간/월간/패턴 통계 - 미니 위젯 + 시스템 트레이 통합 - 한국어/English i18n - 자가 업데이트 (updater.exe + Gitea Releases) 아키텍처: - core/ (db, time_calculator, notifier, i18n, version, settings_keys) - ui/ (main_window + 9 dialogs + 3 controllers) - utils/ (backup, lock_detector, debug_log, updater_client, time_format) - tests/ (66 pytest 단위) + 통합/i18n GUI 검증 CI/CD: - .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트 - .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
524 lines
19 KiB
Python
524 lines
19 KiB
Python
"""
|
|
캘린더 뷰 - 월간 근무 기록 조회
|
|
"""
|
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QPushButton, QCalendarWidget, QTextEdit, QGroupBox,
|
|
QMessageBox)
|
|
from PyQt5.QtCore import QDate, Qt
|
|
from PyQt5.QtGui import QTextCharFormat, QColor
|
|
from datetime import datetime, date
|
|
import sys
|
|
import os
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from core.database import Database
|
|
from ui.styles import ThemeColors, apply_dark_titlebar
|
|
|
|
|
|
class CalendarView(QDialog):
|
|
"""캘린더 뷰 다이얼로그"""
|
|
|
|
def __init__(self, parent=None, db=None):
|
|
super().__init__(parent)
|
|
self.db = db if db else Database()
|
|
self.init_ui()
|
|
self.load_calendar_data()
|
|
apply_dark_titlebar(self)
|
|
|
|
def init_ui(self):
|
|
"""UI 초기화"""
|
|
from core.i18n import tr
|
|
self.setWindowTitle(tr('window.calendar'))
|
|
self.setModal(True)
|
|
self.setMinimumSize(520, 820)
|
|
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(6)
|
|
layout.setContentsMargins(12, 10, 12, 10)
|
|
|
|
# 제목
|
|
title = QLabel("월간 근무 기록")
|
|
title.setObjectName("dialog_title")
|
|
title.setAlignment(Qt.AlignCenter)
|
|
layout.addWidget(title)
|
|
|
|
# 캘린더
|
|
self.calendar = QCalendarWidget()
|
|
self.calendar.setMinimumHeight(280)
|
|
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
|
self.calendar.clicked.connect(self.date_selected)
|
|
layout.addWidget(self.calendar, 1)
|
|
|
|
# 범례
|
|
legend_layout = QHBoxLayout()
|
|
legend_layout.setSpacing(12)
|
|
legend_layout.addWidget(QLabel("🟢 정상"))
|
|
legend_layout.addWidget(QLabel("🔴 연장"))
|
|
legend_layout.addWidget(QLabel("🟡 휴가"))
|
|
legend_layout.addWidget(QLabel("⚪ 없음"))
|
|
legend_layout.addStretch()
|
|
layout.addLayout(legend_layout)
|
|
|
|
# 선택된 날짜 상세 정보
|
|
detail_group = QGroupBox("선택된 날짜 정보")
|
|
detail_layout = QVBoxLayout()
|
|
detail_layout.setSpacing(6)
|
|
detail_layout.setContentsMargins(10, 20, 10, 8)
|
|
|
|
self.detail_text = QTextEdit()
|
|
self.detail_text.setReadOnly(True)
|
|
self.detail_text.setMaximumHeight(100)
|
|
detail_layout.addWidget(self.detail_text)
|
|
|
|
# 버튼 레이아웃
|
|
button_layout = QHBoxLayout()
|
|
button_layout.setSpacing(6)
|
|
|
|
self.edit_time_button = QPushButton("✏️ 시간 수정")
|
|
self.edit_time_button.setObjectName("btn_primary")
|
|
self.edit_time_button.setEnabled(False)
|
|
self.edit_time_button.clicked.connect(self.edit_work_time)
|
|
button_layout.addWidget(self.edit_time_button)
|
|
|
|
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
|
|
self.delete_record_button.setObjectName("btn_danger")
|
|
self.delete_record_button.setEnabled(False)
|
|
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
|
button_layout.addWidget(self.delete_record_button)
|
|
|
|
detail_layout.addLayout(button_layout)
|
|
detail_group.setLayout(detail_layout)
|
|
layout.addWidget(detail_group)
|
|
|
|
# 메모 그룹
|
|
memo_group = QGroupBox("메모")
|
|
memo_layout = QVBoxLayout()
|
|
memo_layout.setSpacing(6)
|
|
memo_layout.setContentsMargins(10, 20, 10, 8)
|
|
|
|
self.memo_edit = QTextEdit()
|
|
self.memo_edit.setMaximumHeight(70)
|
|
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
|
memo_layout.addWidget(self.memo_edit)
|
|
|
|
self.save_memo_button = QPushButton("💾 메모 저장")
|
|
self.save_memo_button.setObjectName("btn_primary")
|
|
self.save_memo_button.setEnabled(False)
|
|
self.save_memo_button.clicked.connect(self.save_memo)
|
|
memo_layout.addWidget(self.save_memo_button)
|
|
memo_group.setLayout(memo_layout)
|
|
layout.addWidget(memo_group)
|
|
|
|
# 선택된 날짜 저장용
|
|
self.selected_date_str = None
|
|
|
|
# 닫기 버튼
|
|
close_button = QPushButton(tr('btn.close'))
|
|
close_button.clicked.connect(self.close)
|
|
layout.addWidget(close_button)
|
|
|
|
self.setLayout(layout)
|
|
|
|
def load_calendar_data(self):
|
|
"""캘린더 데이터 로드"""
|
|
# 현재 표시된 월의 데이터 가져오기
|
|
current_date = self.calendar.selectedDate()
|
|
year = current_date.year()
|
|
month = current_date.month()
|
|
|
|
# 월간 통계 가져오기
|
|
stats = self.db.get_monthly_stats(year, month)
|
|
records = stats.get('records', [])
|
|
|
|
# 캘린더에 마킹
|
|
for record in records:
|
|
record_date = datetime.strptime(record['date'], '%Y-%m-%d').date()
|
|
qdate = QDate(record_date.year, record_date.month, record_date.day)
|
|
|
|
# 포맷 설정
|
|
fmt = QTextCharFormat()
|
|
|
|
if record.get('overtime_earned', 0) > 0:
|
|
# 연장근무
|
|
fmt.setBackground(QColor(ThemeColors.get('cal_overtime')))
|
|
elif record.get('clock_out'):
|
|
# 정상 근무
|
|
fmt.setBackground(QColor(ThemeColors.get('cal_normal')))
|
|
else:
|
|
# 출근만 있음
|
|
fmt.setBackground(QColor(ThemeColors.get('cal_incomplete')))
|
|
|
|
fmt.setForeground(QColor(ThemeColors.get('text_primary')))
|
|
|
|
self.calendar.setDateTextFormat(qdate, fmt)
|
|
|
|
def date_selected(self, qdate):
|
|
"""날짜 선택 시"""
|
|
selected_date = qdate.toPyDate()
|
|
date_str = selected_date.isoformat()
|
|
self.selected_date_str = date_str
|
|
|
|
# 해당 날짜 기록 조회
|
|
record = self.db.get_work_record(date_str)
|
|
|
|
if record:
|
|
# 상세 정보 표시
|
|
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
|
detail += f"출근: {record['clock_in']}\n"
|
|
|
|
if record.get('clock_out'):
|
|
detail += f"퇴근: {record['clock_out']}\n"
|
|
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
|
|
|
|
if record.get('lunch_break'):
|
|
detail += f"점심시간: 사용함\n"
|
|
else:
|
|
detail += f"점심시간: 미사용\n"
|
|
|
|
if record.get('dinner_break'):
|
|
detail += f"저녁시간: 사용함\n"
|
|
else:
|
|
detail += f"저녁시간: 미사용\n"
|
|
|
|
if record.get('overtime_earned', 0) > 0:
|
|
earned_min = record['overtime_earned']
|
|
earned_hours = earned_min // 60
|
|
earned_mins = earned_min % 60
|
|
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n"
|
|
else:
|
|
detail += f"퇴근: 미기록\n"
|
|
|
|
if record.get('memo'):
|
|
detail += f"\n메모: {record['memo']}\n"
|
|
|
|
self.detail_text.setText(detail)
|
|
self.edit_time_button.setEnabled(True)
|
|
self.delete_record_button.setEnabled(True)
|
|
|
|
# 메모 필드 업데이트
|
|
self.memo_edit.setPlainText(record.get('memo', ''))
|
|
self.save_memo_button.setEnabled(True)
|
|
else:
|
|
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
|
self.edit_time_button.setEnabled(False)
|
|
self.delete_record_button.setEnabled(False)
|
|
self.memo_edit.setPlainText('')
|
|
self.save_memo_button.setEnabled(False)
|
|
|
|
def delete_selected_record(self):
|
|
"""선택된 날짜의 출근 기록 삭제"""
|
|
if not self.selected_date_str:
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self,
|
|
"출근 기록 삭제",
|
|
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
|
|
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
|
|
f"※ 이 작업은 되돌릴 수 없습니다.",
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
self.db.delete_work_record(self.selected_date_str)
|
|
|
|
QMessageBox.information(
|
|
self,
|
|
"삭제 완료",
|
|
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
|
|
)
|
|
|
|
# 캘린더 새로고침
|
|
self.load_calendar_data()
|
|
self.detail_text.clear()
|
|
self.delete_record_button.setEnabled(False)
|
|
|
|
def save_memo(self):
|
|
"""메모 저장"""
|
|
if not self.selected_date_str:
|
|
return
|
|
|
|
memo = self.memo_edit.toPlainText().strip()
|
|
|
|
# 메모 업데이트
|
|
self.db.update_work_memo(self.selected_date_str, memo)
|
|
|
|
QMessageBox.information(
|
|
self,
|
|
"메모 저장",
|
|
f"{self.selected_date_str}의 메모가 저장되었습니다."
|
|
)
|
|
|
|
# 상세 정보 새로고침
|
|
qdate = self.calendar.selectedDate()
|
|
self.date_selected(qdate)
|
|
|
|
def edit_work_time(self):
|
|
"""출퇴근 시간 수정"""
|
|
if not self.selected_date_str:
|
|
return
|
|
|
|
# 기존 기록 조회
|
|
record = self.db.get_work_record(self.selected_date_str)
|
|
if not record:
|
|
return
|
|
|
|
# 수정 다이얼로그 표시
|
|
dialog = EditWorkTimeDialog(self, self.db, self.selected_date_str, record)
|
|
if dialog.exec_():
|
|
# 수정 성공 시 캘린더 새로고침
|
|
self.load_calendar_data()
|
|
qdate = self.calendar.selectedDate()
|
|
self.date_selected(qdate)
|
|
|
|
# 부모 윈도우 업데이트
|
|
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
|
self.parent().update_overtime_balance()
|
|
|
|
|
|
class EditWorkTimeDialog(QDialog):
|
|
"""출퇴근 시간 수정 다이얼로그"""
|
|
|
|
def __init__(self, parent, db, date_str, record):
|
|
super().__init__(parent)
|
|
self.db = db
|
|
self.date_str = date_str
|
|
self.record = record
|
|
self.init_ui()
|
|
apply_dark_titlebar(self)
|
|
|
|
def init_ui(self):
|
|
"""UI 초기화"""
|
|
from PyQt5.QtWidgets import QTimeEdit
|
|
from PyQt5.QtCore import QTime
|
|
|
|
self.setWindowTitle("출퇴근 시간 수정")
|
|
self.setModal(True)
|
|
self.setMinimumWidth(420)
|
|
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(8)
|
|
layout.setContentsMargins(12, 10, 12, 10)
|
|
|
|
# 제목
|
|
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
|
title.setObjectName("dialog_subtitle")
|
|
layout.addWidget(title)
|
|
|
|
# 출근 시간
|
|
clock_in_layout = QHBoxLayout()
|
|
clock_in_layout.setSpacing(4)
|
|
clock_in_label = QLabel("출근:")
|
|
clock_in_label.setObjectName("field_label")
|
|
clock_in_label.setFixedWidth(40)
|
|
clock_in_layout.addWidget(clock_in_label)
|
|
|
|
clock_in_minus_btn = QPushButton("-30분")
|
|
clock_in_minus_btn.setFixedWidth(55)
|
|
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
|
|
clock_in_layout.addWidget(clock_in_minus_btn)
|
|
|
|
self.clock_in_edit = QTimeEdit()
|
|
self.clock_in_edit.setDisplayFormat("HH:mm:ss")
|
|
clock_in_time = QTime.fromString(self.record['clock_in'], "HH:mm:ss")
|
|
self.clock_in_edit.setTime(clock_in_time)
|
|
clock_in_layout.addWidget(self.clock_in_edit)
|
|
|
|
clock_in_plus_btn = QPushButton("+30분")
|
|
clock_in_plus_btn.setFixedWidth(55)
|
|
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
|
|
clock_in_layout.addWidget(clock_in_plus_btn)
|
|
layout.addLayout(clock_in_layout)
|
|
|
|
# 퇴근 시간
|
|
clock_out_layout = QHBoxLayout()
|
|
clock_out_layout.setSpacing(4)
|
|
clock_out_label = QLabel("퇴근:")
|
|
clock_out_label.setObjectName("field_label")
|
|
clock_out_label.setFixedWidth(40)
|
|
clock_out_layout.addWidget(clock_out_label)
|
|
|
|
clock_out_minus_btn = QPushButton("-30분")
|
|
clock_out_minus_btn.setFixedWidth(55)
|
|
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
|
|
clock_out_layout.addWidget(clock_out_minus_btn)
|
|
|
|
self.clock_out_edit = QTimeEdit()
|
|
self.clock_out_edit.setDisplayFormat("HH:mm:ss")
|
|
if self.record.get('clock_out'):
|
|
clock_out_time = QTime.fromString(self.record['clock_out'], "HH:mm:ss")
|
|
self.clock_out_edit.setTime(clock_out_time)
|
|
clock_out_layout.addWidget(self.clock_out_edit)
|
|
|
|
clock_out_plus_btn = QPushButton("+30분")
|
|
clock_out_plus_btn.setFixedWidth(55)
|
|
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
|
|
clock_out_layout.addWidget(clock_out_plus_btn)
|
|
layout.addLayout(clock_out_layout)
|
|
|
|
# 점심/저녁 체크박스 - 한 줄에
|
|
from PyQt5.QtWidgets import QCheckBox
|
|
check_layout = QHBoxLayout()
|
|
self.lunch_check = QCheckBox("점심 (1시간)")
|
|
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
|
check_layout.addWidget(self.lunch_check)
|
|
|
|
self.dinner_check = QCheckBox("저녁 (1시간)")
|
|
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
|
check_layout.addWidget(self.dinner_check)
|
|
layout.addLayout(check_layout)
|
|
|
|
# 안내 메시지
|
|
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
|
|
note.setObjectName("note_text")
|
|
layout.addWidget(note)
|
|
|
|
# 버튼
|
|
button_layout = QHBoxLayout()
|
|
save_button = QPushButton("저장")
|
|
save_button.setObjectName("btn_success")
|
|
save_button.clicked.connect(self.save_changes)
|
|
|
|
cancel_button = QPushButton("취소")
|
|
cancel_button.clicked.connect(self.reject)
|
|
|
|
button_layout.addWidget(save_button)
|
|
button_layout.addWidget(cancel_button)
|
|
layout.addLayout(button_layout)
|
|
|
|
self.setLayout(layout)
|
|
|
|
def adjust_time(self, time_edit, minutes: int):
|
|
"""시간 조정 (±분)"""
|
|
current_time = time_edit.time()
|
|
new_time = current_time.addSecs(minutes * 60)
|
|
time_edit.setTime(new_time)
|
|
|
|
def save_changes(self):
|
|
"""변경사항 저장"""
|
|
clock_in = self.clock_in_edit.time().toString("HH:mm:ss")
|
|
clock_out = self.clock_out_edit.time().toString("HH:mm:ss")
|
|
lunch_break = self.lunch_check.isChecked()
|
|
dinner_break = self.dinner_check.isChecked()
|
|
|
|
# 퇴근 시간이 출근 시간보다 빠른지 확인
|
|
if clock_out <= clock_in:
|
|
QMessageBox.warning(
|
|
self,
|
|
"시간 오류",
|
|
"퇴근 시간은 출근 시간보다 늦어야 합니다."
|
|
)
|
|
return
|
|
|
|
# 근무 시간 계산
|
|
from datetime import datetime, timedelta
|
|
from core.time_calculator import TimeCalculator
|
|
|
|
# 해당 날짜의 datetime 객체 생성
|
|
date_obj = datetime.strptime(self.date_str, "%Y-%m-%d").date()
|
|
clock_in_dt = datetime.combine(date_obj, datetime.strptime(clock_in, "%H:%M:%S").time())
|
|
clock_out_dt = datetime.combine(date_obj, datetime.strptime(clock_out, "%H:%M:%S").time())
|
|
|
|
# 총 근무시간 계산
|
|
total_hours = (clock_out_dt - clock_in_dt).total_seconds() / 3600
|
|
|
|
from core.settings_keys import (
|
|
WORK_MINUTES, WORK_HOURS, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
|
)
|
|
settings = self.db.get_settings()
|
|
work_minutes = settings.get(WORK_MINUTES)
|
|
if work_minutes is None:
|
|
# 레거시 DB: work_hours만 있고 마이그레이션 전인 경우 폴백
|
|
try:
|
|
work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60))
|
|
except (ValueError, TypeError):
|
|
work_minutes = 480
|
|
|
|
time_calc = TimeCalculator(
|
|
work_minutes=int(work_minutes),
|
|
lunch_duration_minutes=int(settings.get(LUNCH_DURATION_MINUTES, 60)),
|
|
dinner_duration_minutes=int(settings.get(DINNER_DURATION_MINUTES, 60)),
|
|
)
|
|
|
|
# 해당 날짜의 외출 시간 조회
|
|
break_records = self.db.get_break_records_by_date(self.date_str)
|
|
break_minutes = sum(r.get('total_minutes', 0) or 0 for r in break_records)
|
|
|
|
# calculate_overtime 호출
|
|
overtime_actual, overtime_earned = time_calc.calculate_overtime(
|
|
clock_in_dt, clock_out_dt,
|
|
include_lunch=lunch_break,
|
|
include_dinner=dinner_break,
|
|
break_minutes=break_minutes
|
|
)
|
|
|
|
# DB 업데이트
|
|
conn = None
|
|
try:
|
|
conn = self.db.get_connection()
|
|
cursor = conn.cursor()
|
|
|
|
# 기존 overtime_earned 값 조회
|
|
old_overtime_earned = self.record.get('overtime_earned', 0) or 0
|
|
|
|
# work_records 업데이트 (dinner_break 포함)
|
|
cursor.execute('''
|
|
UPDATE work_records
|
|
SET clock_in = ?, clock_out = ?, lunch_break = ?, dinner_break = ?,
|
|
total_hours = ?, overtime_minutes = ?, overtime_earned = ?
|
|
WHERE date = ?
|
|
''', (clock_in, clock_out, lunch_break, dinner_break, total_hours, overtime_actual, overtime_earned, self.date_str))
|
|
|
|
# overtime_bank 테이블도 업데이트 (연장근무 적립 내역)
|
|
work_record_id = self.record.get('id')
|
|
if work_record_id:
|
|
# 기존 적립 내역 삭제
|
|
cursor.execute('''
|
|
DELETE FROM overtime_bank
|
|
WHERE work_record_id = ? AND date = ?
|
|
''', (work_record_id, self.date_str))
|
|
|
|
# 새로운 적립 내역 추가 (0보다 클 때만)
|
|
if overtime_earned > 0:
|
|
cursor.execute('''
|
|
INSERT INTO overtime_bank (work_record_id, earned_minutes, date)
|
|
VALUES (?, ?, ?)
|
|
''', (work_record_id, overtime_earned, self.date_str))
|
|
|
|
conn.commit()
|
|
|
|
QMessageBox.information(
|
|
self,
|
|
"수정 완료",
|
|
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
|
|
f"출근: {clock_in}\n"
|
|
f"퇴근: {clock_out}\n"
|
|
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
|
|
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
|
|
f"외출시간: {break_minutes}분\n"
|
|
f"총 근무시간: {total_hours:.1f}시간\n"
|
|
f"연장근무: {overtime_earned}분 적립"
|
|
)
|
|
|
|
self.accept()
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(
|
|
self,
|
|
"오류",
|
|
f"수정 중 오류가 발생했습니다:\n{str(e)}"
|
|
)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
|
|
# 테스트 코드
|
|
if __name__ == "__main__":
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
app = QApplication(sys.argv)
|
|
dialog = CalendarView()
|
|
dialog.exec_()
|