Clock_out_Time_Calculator/ui/overtime_view.py
KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의)
- Windows 이벤트 뷰어 자동 출퇴근 감지
- 30분 단위 연장근무 적립/사용 시스템
- 1.0/0.5/0.25일 연차·반차·반반차
- 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출
- 한국 공휴일 자동 등록 (음력 포함, holidays 패키지)
- matplotlib 차트 기반 주간/월간/패턴 통계
- 미니 위젯 + 시스템 트레이 통합
- 한국어/English i18n
- 자가 업데이트 (updater.exe + Gitea Releases)

아키텍처:
- core/ (db, time_calculator, notifier, i18n, version, settings_keys)
- ui/ (main_window + 9 dialogs + 3 controllers)
- utils/ (backup, lock_detector, debug_log, updater_client, time_format)
- tests/ (66 pytest 단위) + 통합/i18n GUI 검증

CI/CD:
- .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트
- .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부

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

508 lines
18 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, 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("연장근무 내역")
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔액: 0분")
self.balance_label.setObjectName("badge_balance")
header_layout.addWidget(self.balance_label)
layout.addLayout(header_layout)
# 적립 내역
earned_group = QGroupBox("💰 적립 내역")
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(["날짜", "적립", "메모"])
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(" 수동 적립")
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("📤 사용 내역")
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(["날짜", "사용", "사유"])
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(" 수동 사용")
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("닫기")
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(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
# 적립 내역 로드
conn = self.db.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT ob.date, ob.earned_minutes,
CASE
WHEN ob.work_record_id IS NULL THEN '수동 추가'
ELSE COALESCE(wr.memo, '')
END as 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()
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
time_str = f"{hours}시간 {mins}" if hours > 0 else f"{mins}"
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색
memo_item = QTableWidgetItem(record[2] or "")
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
time_str = f"{hours}시간 {mins}" if hours > 0 else f"{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("❌ 삭제", 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,
"삭제 확인",
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"시간: {time_item.text()}\n"
f"사유: {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("추가근무 수동 적립")
self.setModal(True)
self.setMinimumWidth(360)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("추가근무 수동 적립")
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
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("시간:")
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "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("메모:")
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(60)
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("선택사항")
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_edit)
layout.addLayout(memo_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
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 save(self):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
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,
"저장 완료",
f"{hours}시간 {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("추가근무 수동 사용")
self.setModal(True)
self.setMinimumWidth(360)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("추가근무 수동 사용")
title.setObjectName("dialog_subtitle")
header_layout.addWidget(title)
header_layout.addStretch()
balance = self.db.get_total_overtime_balance()
hours = balance // 60
minutes = balance % 60
balance_label = QLabel(f"잔액: {hours}시간 {minutes}")
balance_label.setObjectName("badge_balance")
header_layout.addWidget(balance_label)
layout.addLayout(header_layout)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
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("시간:")
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "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("사유:")
reason_label.setObjectName("field_label")
reason_label.setFixedWidth(60)
self.reason_edit = QLineEdit()
self.reason_edit.setPlaceholderText("예: 개인 사유")
reason_layout.addWidget(reason_label)
reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
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 save(self):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
return
# 잔액 확인
balance = self.db.get_total_overtime_balance()
if total_minutes > balance:
QMessageBox.warning(
self,
"잔액 부족",
f"사용 가능한 시간이 부족합니다.\n\n"
f"요청: {hours}시간 {minutes}\n"
f"잔액: {balance // 60}시간 {balance % 60}"
)
return
date = self.date_edit.date().toString("yyyy-MM-dd")
reason = self.reason_edit.text().strip() or "수동 사용"
# DB에 저장
self.db.add_overtime_usage(None, total_minutes, date, reason)
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
)
self.accept()
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = OvertimeView()
dialog.exec_()