Fixed: - clock_out()이 datetime.now().date() 행에 퇴근을 써서 자정 넘긴 퇴근이 UPDATE 0건으로 유실되던 버그. 출근일(근무일) 기준으로 수정. cancel_clock_out() 및 주말/공휴일 판정·외출 집계도 동일 기준으로 통일. - 캘린더 시간 수정 / 과거기록 추가가 퇴근<=출근을 반려해 익일 퇴근을 저장할 수 없던 문제. 익일로 해석하고 동시각만 반려. 총 근무시간·연장근무 음수 계산도 수정. Added: - generate_daily_report(target_date) 날짜 파라미터화 + 캘린더 '보고서 복사' 버튼. 기본값은 진행 중인 근무일(경계시간 전이면 출근일). - 연차 직접입력 버튼(메인 창) — 미연결 상태였던 use_custom_leave()를 기존 AddLeaveDialog 재사용으로 연결. - resolve_work_datetimes() / is_overnight() 공용 헬퍼 + 유닛 테스트 6개. Changed: - 연차 빠른 사용에서 날짜·메모 팝업 제거 (연장근무와 동일하게 확인 1회). - get_today_overtime_usage() / get_total_break_minutes_today()에 선택적 date_str 파라미터 추가. 테스트: 유닛 200 / 통합 53 / GUI 8 / i18n 5 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
672 lines
27 KiB
Python
672 lines
27 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 core.i18n import tr
|
|
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(tr('cal.dialog_title'))
|
|
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)
|
|
for _color, _txt in [('#51CF66', tr('cal.legend_normal')), ('#FA5252', tr('cal.legend_overtime')),
|
|
('#FAB005', tr('cal.legend_leave')), ('#6C6E73', tr('cal.legend_none'))]:
|
|
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
|
_item.setTextFormat(Qt.RichText)
|
|
legend_layout.addWidget(_item)
|
|
legend_layout.addStretch()
|
|
layout.addLayout(legend_layout)
|
|
|
|
# 선택된 날짜 상세 정보
|
|
detail_group = QGroupBox(tr('cal.detail_group_title'))
|
|
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(tr('cal.edit_time'))
|
|
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.copy_report_button = QPushButton(tr('cal.copy_report'))
|
|
self.copy_report_button.setEnabled(False)
|
|
self.copy_report_button.clicked.connect(self.copy_daily_report)
|
|
button_layout.addWidget(self.copy_report_button)
|
|
|
|
self.delete_record_button = QPushButton(tr('cal.delete_record'))
|
|
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(tr('cal.memo_group'))
|
|
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(tr('cal.memo_placeholder'))
|
|
memo_layout.addWidget(self.memo_edit)
|
|
|
|
self.save_memo_button = QPushButton(tr('cal.save_memo'))
|
|
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)
|
|
edit_action = delete_action = add_action = None
|
|
if existing:
|
|
edit_action = menu.addAction(tr('cal.context_edit', date=date_str))
|
|
delete_action = menu.addAction(tr('cal.context_delete', date=date_str))
|
|
else:
|
|
add_action = menu.addAction(tr('cal.context_add', date=date_str))
|
|
|
|
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
|
if action is None:
|
|
return
|
|
|
|
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
|
if action == edit_action:
|
|
self._open_edit_dialog(date_str)
|
|
elif action == delete_action:
|
|
self._delete_record(date_str)
|
|
elif action == add_action:
|
|
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'):
|
|
# 총 시간/연장근무 계산 (익일 퇴근이면 퇴근에 +1일)
|
|
from core.time_calculator import resolve_work_datetimes
|
|
ci, co = resolve_work_datetimes(date_str, data['clock_in'], data['clock_out'])
|
|
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, tr('cal.add_done_title'), tr('cal.add_done_body', date=date_str))
|
|
except Exception as e:
|
|
QMessageBox.critical(self, tr('cal.add_error_title'), tr('cal.add_error_body', error=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(
|
|
tr('cal.delete_confirm_title'),
|
|
tr('cal.delete_confirm_body', date=date_str),
|
|
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, tr('cal.delete_done_title'), tr('cal.delete_done_body', date=date_str))
|
|
except Exception as e:
|
|
conn.rollback()
|
|
QMessageBox.critical(self, tr('cal.edit_error_title'), 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 = tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n'
|
|
detail += tr('cal.detail_clock_in', time=record['clock_in']) + '\n'
|
|
|
|
if record.get('clock_out'):
|
|
from core.time_calculator import is_overnight
|
|
out_str = record['clock_out']
|
|
if is_overnight(record['clock_in'], out_str):
|
|
out_str = tr('label.next_day_time', time=out_str)
|
|
detail += tr('cal.detail_clock_out', time=out_str) + '\n'
|
|
detail += tr('cal.detail_total_hours', hours=record.get('total_hours', 0)) + '\n'
|
|
|
|
if record.get('lunch_break'):
|
|
detail += tr('cal.detail_lunch_used') + '\n'
|
|
else:
|
|
detail += tr('cal.detail_lunch_unused') + '\n'
|
|
|
|
if record.get('dinner_break'):
|
|
detail += tr('cal.detail_dinner_used') + '\n'
|
|
else:
|
|
detail += tr('cal.detail_dinner_unused') + '\n'
|
|
|
|
if record.get('overtime_earned', 0) > 0:
|
|
earned_min = record['overtime_earned']
|
|
earned_hours = earned_min // 60
|
|
earned_mins = earned_min % 60
|
|
detail += '\n' + tr('cal.detail_overtime_earned', hours=earned_hours, minutes=earned_mins) + '\n'
|
|
else:
|
|
detail += tr('cal.detail_clock_out_none') + '\n'
|
|
|
|
if record.get('memo'):
|
|
detail += '\n' + tr('cal.detail_memo', memo=record['memo']) + '\n'
|
|
|
|
self.detail_text.setText(detail)
|
|
self.edit_time_button.setEnabled(True)
|
|
self.delete_record_button.setEnabled(True)
|
|
self.copy_report_button.setEnabled(True)
|
|
|
|
# 메모 필드 업데이트
|
|
self.memo_edit.setPlainText(record.get('memo', ''))
|
|
self.save_memo_button.setEnabled(True)
|
|
else:
|
|
self.detail_text.setText(tr('cal.detail_date_fmt', year=selected_date.year, month=selected_date.month, day=selected_date.day) + '\n\n' + tr('cal.no_record'))
|
|
self.edit_time_button.setEnabled(False)
|
|
self.delete_record_button.setEnabled(False)
|
|
self.copy_report_button.setEnabled(False)
|
|
self.memo_edit.setPlainText('')
|
|
self.save_memo_button.setEnabled(False)
|
|
|
|
def copy_daily_report(self):
|
|
"""선택한 날짜의 일일보고서를 클립보드로 복사.
|
|
|
|
메인 창의 생성기를 그대로 재사용 — 당일 보고를 놓쳐도 나중에 다시 뽑을 수 있다.
|
|
"""
|
|
if not self.selected_date_str:
|
|
return
|
|
main_window = self.parent()
|
|
if main_window is None or not hasattr(main_window, 'generate_daily_report'):
|
|
return # 단독 실행(__main__) 등 부모가 메인 창이 아닌 경우
|
|
main_window.generate_daily_report(self.selected_date_str)
|
|
|
|
def delete_selected_record(self):
|
|
"""선택된 날짜의 출근 기록 삭제"""
|
|
if not self.selected_date_str:
|
|
return
|
|
|
|
reply = QMessageBox.question(
|
|
self,
|
|
tr('cal.delete_selected_title'),
|
|
tr('cal.delete_selected_body', date=self.selected_date_str),
|
|
QMessageBox.Yes | QMessageBox.No,
|
|
QMessageBox.No
|
|
)
|
|
|
|
if reply == QMessageBox.Yes:
|
|
self.db.delete_work_record(self.selected_date_str)
|
|
|
|
QMessageBox.information(
|
|
self,
|
|
tr('cal.delete_done_title'),
|
|
tr('cal.delete_done_body', date=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,
|
|
tr('cal.save_memo_title'),
|
|
tr('cal.save_memo_body', date=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(tr('cal.edit_dialog_title'))
|
|
self.setModal(True)
|
|
self.setMinimumWidth(420)
|
|
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(8)
|
|
layout.setContentsMargins(12, 10, 12, 10)
|
|
|
|
# 제목
|
|
title = QLabel(tr('cal.edit_dialog_subtitle', date=self.date_str))
|
|
title.setObjectName("dialog_subtitle")
|
|
layout.addWidget(title)
|
|
|
|
# 출근 시간
|
|
clock_in_layout = QHBoxLayout()
|
|
clock_in_layout.setSpacing(4)
|
|
clock_in_label = QLabel(tr('cal.label_clock_in'))
|
|
clock_in_label.setObjectName("field_label")
|
|
clock_in_label.setFixedWidth(40)
|
|
clock_in_layout.addWidget(clock_in_label)
|
|
|
|
clock_in_minus_btn = QPushButton(tr('cal.btn_minus_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(tr('cal.btn_plus_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(tr('cal.label_clock_out'))
|
|
clock_out_label.setObjectName("field_label")
|
|
clock_out_label.setFixedWidth(40)
|
|
clock_out_layout.addWidget(clock_out_label)
|
|
|
|
clock_out_minus_btn = QPushButton(tr('cal.btn_minus_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)
|
|
else:
|
|
# 미퇴근 기록: 기본값 00:00은 "익일 자정 퇴근"으로 오인될 수 있으므로
|
|
# 출근 시각과 동일하게 두어 save_changes()의 동시각 반려에 걸리게 한다.
|
|
self.clock_out_edit.setTime(clock_in_time)
|
|
clock_out_layout.addWidget(self.clock_out_edit)
|
|
|
|
clock_out_plus_btn = QPushButton(tr('cal.btn_plus_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)
|
|
|
|
# 익일 퇴근 안내 (퇴근 < 출근이면 표시)
|
|
self.overnight_note = QLabel('')
|
|
self.overnight_note.setObjectName("note_text")
|
|
self.overnight_note.setVisible(False)
|
|
layout.addWidget(self.overnight_note)
|
|
self.clock_in_edit.timeChanged.connect(self._refresh_overnight_note)
|
|
self.clock_out_edit.timeChanged.connect(self._refresh_overnight_note)
|
|
self._refresh_overnight_note()
|
|
|
|
# 점심/저녁 체크박스 - 한 줄에
|
|
from PyQt5.QtWidgets import QCheckBox
|
|
check_layout = QHBoxLayout()
|
|
self.lunch_check = QCheckBox(tr('cal.check_lunch_1h'))
|
|
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
|
|
check_layout.addWidget(self.lunch_check)
|
|
|
|
self.dinner_check = QCheckBox(tr('cal.check_dinner_1h'))
|
|
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
|
|
check_layout.addWidget(self.dinner_check)
|
|
layout.addLayout(check_layout)
|
|
|
|
# 안내 메시지
|
|
note = QLabel(tr('cal.edit_note'))
|
|
note.setObjectName("note_text")
|
|
layout.addWidget(note)
|
|
|
|
# 버튼
|
|
button_layout = QHBoxLayout()
|
|
save_button = QPushButton(tr('btn.save'))
|
|
save_button.setObjectName("btn_success")
|
|
save_button.clicked.connect(self.save_changes)
|
|
|
|
cancel_button = QPushButton(tr('btn.cancel'))
|
|
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 _refresh_overnight_note(self):
|
|
"""퇴근이 출근보다 이르면 익일 퇴근으로 해석됨을 표시."""
|
|
ci = self.clock_in_edit.time()
|
|
co = self.clock_out_edit.time()
|
|
if co >= ci:
|
|
self.overnight_note.setVisible(False)
|
|
return
|
|
total_min = ci.secsTo(co) // 60 + 24 * 60 # 익일까지
|
|
self.overnight_note.setText(tr('edit.overnight_note',
|
|
hours=total_min // 60, minutes=total_min % 60))
|
|
self.overnight_note.setVisible(True)
|
|
|
|
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,
|
|
tr('cal.time_error_title'),
|
|
tr('cal.time_error_body')
|
|
)
|
|
return
|
|
|
|
# 근무 시간 계산
|
|
from datetime import datetime, timedelta
|
|
from core.time_calculator import TimeCalculator, resolve_work_datetimes
|
|
|
|
# 해당 날짜의 datetime 객체 생성 (익일 퇴근이면 퇴근에 +1일)
|
|
clock_in_dt, clock_out_dt = resolve_work_datetimes(self.date_str, clock_in, clock_out)
|
|
|
|
# 총 근무시간 계산
|
|
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,
|
|
tr('cal.edit_done_title'),
|
|
tr('cal.edit_done_body',
|
|
date=self.date_str, clock_in=clock_in, clock_out=clock_out,
|
|
lunch=tr('cal.detail_lunch_used') if lunch_break else tr('cal.detail_lunch_unused'),
|
|
dinner=tr('cal.detail_dinner_used') if dinner_break else tr('cal.detail_dinner_unused'),
|
|
break_minutes=break_minutes, total_hours=total_hours, overtime_earned=overtime_earned)
|
|
)
|
|
|
|
self.accept()
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(
|
|
self,
|
|
tr('cal.edit_error_title'),
|
|
tr('cal.edit_error_body', error=str(e))
|
|
)
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
|
|
# 테스트 코드
|
|
if __name__ == "__main__":
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
app = QApplication(sys.argv)
|
|
dialog = CalendarView()
|
|
dialog.exec_()
|