Clock_out_Time_Calculator/ui/overtime_view.py
KINDNICK ff71886fd7 v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키
- 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용
- MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출
- 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등)
- pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122)
- README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
2026-04-30 19:30:47 +09:00

529 lines
19 KiB
Python

"""
연장근무 상세 내역 뷰
"""
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_()