Some checks failed
CI / test (push) Has been cancelled
핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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>
508 lines
18 KiB
Python
508 lines
18 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("연장근무 내역")
|
||
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_()
|