Clock_out_Time_Calculator/ui/overtime_view.py
KINDNICK 5fb8655a47 v2.11.0: UI 전면 다크 리디자인 + 라인 아이콘 + 적립 가드/삭제
- 모던 다크 미니멀 테마(NanumSquare 번들, 단일 accent #4DABF7, 8px radius, flat 버튼, 다크 기본값)
- 라인 아이콘 시스템(ui/icons.py, QtSvg) — 앱 전반 이모지 교체
- 다크 깨짐 수정: 테이블 헤더/코너 흰색, 도움말 탭 흰 라인, 트레이/미니위젯 메뉴
- fix: 자동 적립 OFF가 자동 퇴근 경로에서 무시되던 버그(게이팅)
- feat: 연장근무 적립 기록 삭제(우클릭)
- 테스트 3건 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:21:54 +09:00

572 lines
21 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)
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
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, ob.id
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)
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
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(81, 207, 102)) # 적립 = 그린 (#51CF66)
# 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(250, 82, 82)) # 사용 = 레드 (#FA5252)
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 show_earned_context_menu(self, position):
"""적립 내역 우클릭 메뉴 (삭제)."""
selected_rows = self.earned_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_earned_record)
menu.addAction(delete_action)
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
def delete_earned_record(self):
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
selected_rows = self.earned_table.selectionModel().selectedRows()
if not selected_rows:
return
row = selected_rows[0].row()
date_item = self.earned_table.item(row, 0)
time_item = self.earned_table.item(row, 1)
# 행에 저장된 overtime_bank.id
bank_id = date_item.data(Qt.UserRole)
if bank_id is None:
return
reply = QMessageBox.question(
self,
tr('msg.confirm_delete.title'),
tr('view.overtime.delete_earned_confirm_body',
date=date_item.text(), time=time_item.text()),
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_overtime_earned(bank_id)
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_()