""" 연장근무 상세 내역 뷰 """ from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QTableWidget, QTableWidgetItem, QHeaderView, QGroupBox, QMessageBox, QMenu, QAction, QDateEdit, QSpinBox, QLineEdit, QComboBox) from PyQt5.QtCore import Qt, QDate from PyQt5.QtGui import QColor 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 get_theme, ThemeColors, apply_dark_titlebar class OvertimeView(QDialog): """연장근무 상세 내역 다이얼로그""" def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db if db else Database() self.init_ui() self.load_data() apply_dark_titlebar(self) def init_ui(self): """UI 초기화""" self.setWindowTitle(tr('window.overtime_view')) self.setModal(True) self.setMinimumSize(800, 500) layout = QVBoxLayout() layout.setSpacing(6) layout.setContentsMargins(12, 10, 12, 10) # 제목 + 잔액 한 줄 header_layout = QHBoxLayout() title = QLabel(tr('view.overtime.title')) title.setObjectName("dialog_title") header_layout.addWidget(title) header_layout.addStretch() self.balance_label = QLabel(tr('view.overtime.balance_zero')) self.balance_label.setObjectName("badge_balance") header_layout.addWidget(self.balance_label) layout.addLayout(header_layout) # 적립 내역 earned_group = QGroupBox(tr('view.overtime.earned_group')) earned_layout = QVBoxLayout() earned_layout.setSpacing(4) earned_layout.setContentsMargins(8, 20, 8, 6) self.earned_table = QTableWidget() self.earned_table.setColumnCount(3) self.earned_table.setHorizontalHeaderLabels([ tr('view.overtime.col_date'), tr('view.overtime.col_earned'), tr('view.overtime.col_memo'), ]) self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.earned_table.setAlternatingRowColors(True) self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers) self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) earned_layout.addWidget(self.earned_table) add_earned_button = QPushButton(tr('view.overtime.btn_add_earned')) add_earned_button.clicked.connect(self.add_earned_record) earned_layout.addWidget(add_earned_button) earned_group.setLayout(earned_layout) layout.addWidget(earned_group) # 사용 내역 used_group = QGroupBox(tr('view.overtime.used_group')) used_layout = QVBoxLayout() used_layout.setSpacing(4) used_layout.setContentsMargins(8, 20, 8, 6) self.used_table = QTableWidget() self.used_table.setColumnCount(3) self.used_table.setHorizontalHeaderLabels([ tr('view.overtime.col_date'), tr('view.overtime.col_used'), tr('view.overtime.col_reason'), ]) self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.used_table.setAlternatingRowColors(True) self.used_table.setEditTriggers(QTableWidget.NoEditTriggers) self.used_table.setSelectionBehavior(QTableWidget.SelectRows) self.used_table.setContextMenuPolicy(Qt.CustomContextMenu) self.used_table.customContextMenuRequested.connect(self.show_used_context_menu) used_layout.addWidget(self.used_table) add_used_button = QPushButton(tr('view.overtime.btn_add_used')) add_used_button.clicked.connect(self.add_used_record) used_layout.addWidget(add_used_button) used_group.setLayout(used_layout) layout.addWidget(used_group) # 닫기 버튼 close_button = QPushButton(tr('btn.close')) close_button.clicked.connect(self.close) layout.addWidget(close_button) self.setLayout(layout) def load_data(self): """데이터 로드""" # 잔액 업데이트 balance = self.db.get_total_overtime_balance() hours = balance // 60 minutes = balance % 60 self.balance_label.setText(tr('view.overtime.balance_fmt', h=hours, m=minutes, total=balance)) # 적립 내역 로드 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo FROM overtime_bank ob LEFT JOIN work_records wr ON ob.work_record_id = wr.id ORDER BY ob.date DESC ''') earned_records = cursor.fetchall() manual_label = tr('msg.manual_added') self.earned_table.setRowCount(len(earned_records)) for i, record in enumerate(earned_records): date_item = QTableWidgetItem(record[0]) date_item.setTextAlignment(Qt.AlignCenter) minutes = record[1] hours = minutes // 60 mins = minutes % 60 if hours > 0: time_str = tr('view.break.duration_fmt', h=hours, m=mins) else: time_str = tr('view.break.duration_min_only', m=mins) time_item = QTableWidgetItem(time_str) time_item.setTextAlignment(Qt.AlignCenter) time_item.setForeground(QColor(39, 174, 96)) # 초록색 # work_record_id NULL이면 "수동 추가", 아니면 wr.memo memo_text = manual_label if record[2] is None else (record[3] or "") memo_item = QTableWidgetItem(memo_text) self.earned_table.setItem(i, 0, date_item) self.earned_table.setItem(i, 1, time_item) self.earned_table.setItem(i, 2, memo_item) # 사용 내역 로드 (잔액 조정 제외) cursor.execute(''' SELECT id, date, used_minutes, reason FROM overtime_usage WHERE COALESCE(reason, '') != '잔액 조정' ORDER BY date DESC ''') used_records = cursor.fetchall() self.used_table.setRowCount(len(used_records)) for i, record in enumerate(used_records): # ID를 숨겨진 데이터로 저장 date_item = QTableWidgetItem(record[1]) date_item.setTextAlignment(Qt.AlignCenter) date_item.setData(Qt.UserRole, record[0]) # ID 저장 minutes = record[2] hours = minutes // 60 mins = minutes % 60 if hours > 0: time_str = tr('view.break.duration_fmt', h=hours, m=mins) else: time_str = tr('view.break.duration_min_only', m=mins) time_item = QTableWidgetItem(time_str) time_item.setTextAlignment(Qt.AlignCenter) time_item.setForeground(QColor(231, 76, 60)) # 빨간색 reason_item = QTableWidgetItem(record[3] or "") self.used_table.setItem(i, 0, date_item) self.used_table.setItem(i, 1, time_item) self.used_table.setItem(i, 2, reason_item) conn.close() def show_used_context_menu(self, position): """사용 내역 우클릭 메뉴""" # 선택된 행이 있는지 확인 selected_rows = self.used_table.selectionModel().selectedRows() if not selected_rows: return # 컨텍스트 메뉴 생성 menu = QMenu(self) delete_action = QAction(tr('view.overtime.menu_delete'), self) delete_action.triggered.connect(self.delete_used_record) menu.addAction(delete_action) # 메뉴 표시 menu.exec_(self.used_table.viewport().mapToGlobal(position)) def delete_used_record(self): """사용 기록 삭제""" # 선택된 행 가져오기 selected_rows = self.used_table.selectionModel().selectedRows() if not selected_rows: return row = selected_rows[0].row() date_item = self.used_table.item(row, 0) time_item = self.used_table.item(row, 1) reason_item = self.used_table.item(row, 2) # ID 가져오기 usage_id = date_item.data(Qt.UserRole) # 확인 메시지 reply = QMessageBox.question( self, tr('msg.confirm_delete.title'), tr('view.overtime.delete_confirm_body', date=date_item.text(), time=time_item.text(), reason=reason_item.text()), QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: # 데이터베이스에서 삭제 conn = self.db.get_connection() cursor = conn.cursor() cursor.execute('DELETE FROM overtime_usage WHERE id = ?', (usage_id,)) conn.commit() conn.close() # 화면 새로고침 self.load_data() # 부모 윈도우의 잔액도 업데이트 if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): self.parent().update_overtime_balance() def add_earned_record(self): """수동 적립 추가""" dialog = AddOvertimeEarnedDialog(self, self.db) if dialog.exec_() == QDialog.Accepted: self.load_data() # 부모 윈도우 업데이트 if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): self.parent().update_overtime_balance() def add_used_record(self): """수동 사용 추가""" dialog = AddOvertimeUsedDialog(self, self.db) if dialog.exec_() == QDialog.Accepted: self.load_data() # 부모 윈도우 업데이트 if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): self.parent().update_overtime_balance() class AddOvertimeEarnedDialog(QDialog): """추가근무 수동 적립 다이얼로그""" def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db self.init_ui() apply_dark_titlebar(self) def init_ui(self): """UI 초기화""" self.setWindowTitle(tr('view.overtime.manual_earned_title')) self.setModal(True) self.setMinimumWidth(360) layout = QVBoxLayout() layout.setSpacing(8) layout.setContentsMargins(12, 10, 12, 10) # 제목 title = QLabel(tr('view.overtime.manual_earned_title')) title.setObjectName("dialog_subtitle") title.setAlignment(Qt.AlignCenter) layout.addWidget(title) # 날짜 date_layout = QHBoxLayout() date_label = QLabel(tr('view.overtime.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(60) self.date_edit = QDateEdit() self.date_edit.setDate(QDate.currentDate()) self.date_edit.setCalendarPopup(True) date_layout.addWidget(date_label) date_layout.addWidget(self.date_edit) layout.addLayout(date_layout) # 시간 (30분 단위) time_layout = QHBoxLayout() time_label = QLabel(tr('view.overtime.field_time')) time_label.setObjectName("field_label") time_label.setFixedWidth(60) self.hour_spin = QSpinBox() self.hour_spin.setRange(0, 23) self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix')) self.minute_combo = QComboBox() self.minute_combo.addItems([tr('view.overtime.minute_0'), tr('view.overtime.minute_30')]) time_layout.addWidget(time_label) time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.minute_combo) layout.addLayout(time_layout) # 메모 memo_layout = QHBoxLayout() memo_label = QLabel(tr('view.overtime.field_memo')) memo_label.setObjectName("field_label") memo_label.setFixedWidth(60) self.memo_edit = QLineEdit() self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo')) memo_layout.addWidget(memo_label) memo_layout.addWidget(self.memo_edit) layout.addLayout(memo_layout) # 버튼 button_layout = QHBoxLayout() save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save) 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 save(self): """저장""" # 시간 계산 (30분 단위) hours = self.hour_spin.value() minutes = 0 if self.minute_combo.currentIndex() == 0 else 30 total_minutes = hours * 60 + minutes if total_minutes == 0: QMessageBox.warning(self, tr('msg.input_error.title'), tr('view.overtime.zero_add_error')) return date = self.date_edit.date().toString("yyyy-MM-dd") memo = self.memo_edit.text().strip() # DB에 저장 (work_record_id는 NULL) self.db.add_overtime_earned(None, total_minutes, date) # 메모가 있으면 work_records 업데이트 (기존 메모 보존) if memo: record = self.db.get_work_record(date) if record: existing_memo = record.get('memo', '') or '' # 기존 메모가 있으면 줄바꿈 후 추가 if existing_memo: new_memo = f"{existing_memo}\n[수동 적립] {memo}" else: new_memo = f"[수동 적립] {memo}" conn = self.db.get_connection() cursor = conn.cursor() cursor.execute(''' UPDATE work_records SET memo = ? WHERE date = ? ''', (new_memo, date)) conn.commit() conn.close() QMessageBox.information( self, tr('msg.save_success.title'), tr('view.overtime.saved_earned', h=hours, m=minutes) ) self.accept() class AddOvertimeUsedDialog(QDialog): """추가근무 수동 사용 다이얼로그""" def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db self.init_ui() apply_dark_titlebar(self) def init_ui(self): """UI 초기화""" self.setWindowTitle(tr('view.overtime.manual_used_title')) self.setModal(True) self.setMinimumWidth(360) layout = QVBoxLayout() layout.setSpacing(8) layout.setContentsMargins(12, 10, 12, 10) # 제목 + 잔액 한 줄 header_layout = QHBoxLayout() title = QLabel(tr('view.overtime.manual_used_title')) title.setObjectName("dialog_subtitle") header_layout.addWidget(title) header_layout.addStretch() balance = self.db.get_total_overtime_balance() hours = balance // 60 minutes = balance % 60 if hours > 0: balance_text = tr('view.break.duration_fmt', h=hours, m=minutes) else: balance_text = tr('view.break.duration_min_only', m=minutes) balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}") balance_label.setObjectName("badge_balance") header_layout.addWidget(balance_label) layout.addLayout(header_layout) # 날짜 date_layout = QHBoxLayout() date_label = QLabel(tr('view.overtime.field_date')) date_label.setObjectName("field_label") date_label.setFixedWidth(60) self.date_edit = QDateEdit() self.date_edit.setDate(QDate.currentDate()) self.date_edit.setCalendarPopup(True) date_layout.addWidget(date_label) date_layout.addWidget(self.date_edit) layout.addLayout(date_layout) # 시간 (30분 단위) time_layout = QHBoxLayout() time_label = QLabel(tr('view.overtime.field_time')) time_label.setObjectName("field_label") time_label.setFixedWidth(60) self.hour_spin = QSpinBox() self.hour_spin.setRange(0, 23) self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix')) self.minute_combo = QComboBox() self.minute_combo.addItems([tr('view.overtime.minute_0'), tr('view.overtime.minute_30')]) time_layout.addWidget(time_label) time_layout.addWidget(self.hour_spin) time_layout.addWidget(self.minute_combo) layout.addLayout(time_layout) # 사유 reason_layout = QHBoxLayout() reason_label = QLabel(tr('view.overtime.field_reason')) reason_label.setObjectName("field_label") reason_label.setFixedWidth(60) self.reason_edit = QLineEdit() self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason')) reason_layout.addWidget(reason_label) reason_layout.addWidget(self.reason_edit) layout.addLayout(reason_layout) # 버튼 button_layout = QHBoxLayout() save_button = QPushButton(tr('btn.save')) save_button.setObjectName("btn_primary") save_button.clicked.connect(self.save) 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 save(self): """저장""" # 시간 계산 (30분 단위) hours = self.hour_spin.value() minutes = 0 if self.minute_combo.currentIndex() == 0 else 30 total_minutes = hours * 60 + minutes if total_minutes == 0: QMessageBox.warning(self, tr('msg.input_error.title'), tr('view.overtime.zero_use_error')) return # 잔액 확인 balance = self.db.get_total_overtime_balance() if total_minutes > balance: QMessageBox.warning( self, tr('view.overtime.balance_short_title'), tr('view.overtime.balance_short_body', req_h=hours, req_m=minutes, bal_h=balance // 60, bal_m=balance % 60) ) return date = self.date_edit.date().toString("yyyy-MM-dd") reason = self.reason_edit.text().strip() or tr('msg.manual_added') # DB에 저장 self.db.add_overtime_usage(None, total_minutes, date, reason) QMessageBox.information( self, tr('msg.save_success.title'), tr('view.overtime.saved_used', h=hours, m=minutes) ) self.accept() # 테스트 코드 if __name__ == "__main__": from PyQt5.QtWidgets import QApplication app = QApplication(sys.argv) dialog = OvertimeView() dialog.exec_()