Clock_out_Time_Calculator/ui/calendar_view.py
KINDNICK 9ebf4ad961 v2.4.0: Phase 2 — meal time, past records, goals, CSV import, crash report
- Meal time dialog (right-click lunch/dinner button to enter actual times)
- Calendar right-click context: add/edit/delete past records
- Monthly goal settings + progress widget (overtime cap, avg daily)
- CSV import (our standard format) with conflict policy
- Global crash handler with Gitea Issues auto-report

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:38:38 +09:00

628 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
캘린더 뷰 - 월간 근무 기록 조회
"""
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)
# 우클릭 컨텍스트 메뉴 (과거 일자 수동 추가)
self.calendar.setContextMenuPolicy(Qt.CustomContextMenu)
self.calendar.customContextMenuRequested.connect(self._show_date_context)
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 _show_date_context(self, pos):
"""캘린더 우클릭 메뉴 — 과거 일자 추가/편집/삭제."""
from PyQt5.QtWidgets import QMenu
qdate = self.calendar.selectedDate()
date_str = qdate.toString('yyyy-MM-dd')
existing = self.db.get_work_record(date_str)
menu = QMenu(self)
if existing:
edit_action = menu.addAction(f"✏️ {date_str} 편집")
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
else:
add_action = menu.addAction(f" {date_str} 기록 추가")
action = menu.exec_(self.calendar.mapToGlobal(pos))
if action is None:
return
if existing and action.text().startswith("✏️"):
self._open_edit_dialog(date_str)
elif existing and action.text().startswith("🗑️"):
self._delete_record(date_str)
elif not existing and action.text().startswith(""):
self._add_past_record(date_str)
def _add_past_record(self, date_str: str):
"""과거 일자 수동 추가."""
from ui.past_record_dialog import PastRecordDialog
dialog = PastRecordDialog(self, date_str)
if dialog.exec_() != QDialog.Accepted:
return
data = dialog.get_data()
if not data:
return
try:
wid = self.db.add_work_record(date_str, data['clock_in'], is_manual=True)
if data.get('clock_out'):
# 총 시간/연장근무 계산
from datetime import datetime as _dt
ci = _dt.strptime(f"{date_str} {data['clock_in']}", '%Y-%m-%d %H:%M:%S')
co = _dt.strptime(f"{date_str} {data['clock_out']}", '%Y-%m-%d %H:%M:%S')
from core.time_calculator import TimeCalculator
wm = self.db.get_work_minutes()
lunch = self.db.get_setting_int('lunch_duration_minutes', 60)
calc = TimeCalculator(work_minutes=wm, lunch_duration_minutes=lunch)
total = (co - ci).total_seconds() / 3600
ot_actual, ot_earned = calc.calculate_overtime(
ci, co,
include_lunch=data.get('lunch', False),
include_dinner=data.get('dinner', False),
)
self.db.update_clock_out(date_str, data['clock_out'], total, ot_actual, ot_earned)
if data.get('lunch'):
self.db.update_lunch_break(date_str, True)
if data.get('dinner'):
self.db.update_dinner_break(date_str, True)
if ot_earned > 0:
self.db.add_overtime_earned(wid, ot_earned, date_str)
self._refresh_calendar()
QMessageBox.information(self, "추가 완료", f"{date_str} 기록이 추가되었습니다.")
except Exception as e:
QMessageBox.critical(self, "오류", f"기록 추가 실패: {e}")
def _open_edit_dialog(self, date_str: str):
"""기존 일자 편집 — date_selected로 우회 (이미 EditTimeDialog 있음)."""
from PyQt5.QtCore import QDate
y, m, d = date_str.split('-')
self.calendar.setSelectedDate(QDate(int(y), int(m), int(d)))
self.date_selected(self.calendar.selectedDate())
# 사용자가 화면 하단에 표시된 "✏️ 시간 수정" 버튼 클릭하면 편집
def _delete_record(self, date_str: str):
reply = QMessageBox.question(
self, "삭제 확인",
f"{date_str} 기록을 정말 삭제하시겠습니까?\n(연장근무 적립 내역도 함께 삭제됩니다)",
QMessageBox.Yes | QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
conn = self.db.get_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM overtime_bank WHERE date = ?", (date_str,))
cursor.execute("DELETE FROM break_records WHERE date = ?", (date_str,))
cursor.execute("DELETE FROM work_records WHERE date = ?", (date_str,))
conn.commit()
self._refresh_calendar()
QMessageBox.information(self, "삭제 완료", f"{date_str} 기록 삭제됨")
except Exception as e:
conn.rollback()
QMessageBox.critical(self, "오류", str(e))
finally:
conn.close()
def _refresh_calendar(self):
"""캘린더 마킹 갱신."""
if hasattr(self, 'load_calendar_data'):
self.load_calendar_data()
elif hasattr(self, 'load_records'):
self.load_records()
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_()