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>
372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""
|
||
연차 상세 내역 뷰
|
||
"""
|
||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QPushButton, QTableWidget, QTableWidgetItem,
|
||
QHeaderView, QGroupBox, QMessageBox, QInputDialog,
|
||
QLineEdit, QDateEdit, QComboBox, QDoubleSpinBox,
|
||
QMenu, QAction)
|
||
from PyQt5.QtCore import Qt, QDate
|
||
from PyQt5.QtGui import QColor
|
||
from datetime import datetime
|
||
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 apply_dark_titlebar
|
||
|
||
|
||
class LeaveView(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.leave_view'))
|
||
self.setModal(True)
|
||
self.setMinimumSize(700, 450)
|
||
|
||
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_leave")
|
||
header_layout.addWidget(self.balance_label)
|
||
set_balance_button = QPushButton("잔여 설정")
|
||
set_balance_button.clicked.connect(self.set_balance)
|
||
header_layout.addWidget(set_balance_button)
|
||
layout.addLayout(header_layout)
|
||
|
||
# 사용 내역
|
||
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(4)
|
||
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.ResizeToContents)
|
||
self.used_table.horizontalHeader().setSectionResizeMode(3, 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_context_menu)
|
||
used_layout.addWidget(self.used_table)
|
||
|
||
used_group.setLayout(used_layout)
|
||
layout.addWidget(used_group)
|
||
|
||
# 버튼들
|
||
button_layout = QHBoxLayout()
|
||
add_leave_button = QPushButton("➕ 연차 사용 추가")
|
||
add_leave_button.clicked.connect(self.add_leave_record)
|
||
button_layout.addWidget(add_leave_button)
|
||
close_button = QPushButton("닫기")
|
||
close_button.clicked.connect(self.close)
|
||
button_layout.addWidget(close_button)
|
||
layout.addLayout(button_layout)
|
||
|
||
self.setLayout(layout)
|
||
|
||
def load_data(self):
|
||
"""데이터 로드"""
|
||
# 잔액 업데이트
|
||
balance = self.db.get_leave_balance()
|
||
hours = balance * 8
|
||
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
|
||
|
||
# 사용 내역 로드 (잔액 조정 제외)
|
||
records = self.db.get_leave_records(exclude_bulk=True)
|
||
|
||
self.used_table.setRowCount(len(records))
|
||
for i, record in enumerate(records):
|
||
date_item = QTableWidgetItem(record['date'])
|
||
date_item.setTextAlignment(Qt.AlignCenter)
|
||
date_item.setData(Qt.UserRole, record['id'])
|
||
|
||
type_item = QTableWidgetItem(record['leave_type'])
|
||
type_item.setTextAlignment(Qt.AlignCenter)
|
||
|
||
days = record['days']
|
||
hours = days * 8
|
||
if days == 1.0:
|
||
days_str = "1일"
|
||
elif days == 0.5:
|
||
days_str = "0.5일 (4시간)"
|
||
elif hours < 8:
|
||
days_str = f"{days}일 ({hours}시간)"
|
||
else:
|
||
days_str = f"{days}일"
|
||
days_item = QTableWidgetItem(days_str)
|
||
days_item.setTextAlignment(Qt.AlignCenter)
|
||
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||
|
||
memo_item = QTableWidgetItem(record['memo'] or "")
|
||
|
||
self.used_table.setItem(i, 0, date_item)
|
||
self.used_table.setItem(i, 1, type_item)
|
||
self.used_table.setItem(i, 2, days_item)
|
||
self.used_table.setItem(i, 3, memo_item)
|
||
|
||
def show_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_leave_record)
|
||
menu.addAction(delete_action)
|
||
menu.exec_(self.used_table.viewport().mapToGlobal(position))
|
||
|
||
def delete_leave_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)
|
||
type_item = self.used_table.item(row, 1)
|
||
days_item = self.used_table.item(row, 2)
|
||
|
||
leave_id = date_item.data(Qt.UserRole)
|
||
|
||
reply = QMessageBox.question(
|
||
self,
|
||
"삭제 확인",
|
||
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
|
||
f"날짜: {date_item.text()}\n"
|
||
f"구분: {type_item.text()}\n"
|
||
f"사용: {days_item.text()}",
|
||
QMessageBox.Yes | QMessageBox.No
|
||
)
|
||
|
||
if reply == QMessageBox.Yes:
|
||
self.db.delete_leave_record(leave_id)
|
||
self.load_data()
|
||
|
||
def set_balance(self):
|
||
"""연차 개수 설정 (시간 단위)"""
|
||
current_balance = self.db.get_leave_balance()
|
||
current_hours = current_balance * 8
|
||
|
||
hours, ok = QInputDialog.getDouble(
|
||
self,
|
||
"연차 시간 설정",
|
||
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
|
||
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
|
||
current_hours,
|
||
0.0,
|
||
999.0,
|
||
1 # 소수점 첫째자리까지 (0.5 단위)
|
||
)
|
||
|
||
if ok:
|
||
# 0.5시간 단위로 반올림
|
||
hours = round(hours * 2) / 2
|
||
# 시간을 일수로 변환
|
||
days = hours / 8.0
|
||
self.db.set_leave_balance(days)
|
||
QMessageBox.information(
|
||
self,
|
||
"설정 완료",
|
||
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
|
||
)
|
||
self.load_data()
|
||
|
||
def add_leave_record(self):
|
||
"""연차 사용 기록 추가 다이얼로그"""
|
||
dialog = AddLeaveDialog(self, self.db)
|
||
if dialog.exec_() == QDialog.Accepted:
|
||
self.load_data()
|
||
|
||
|
||
class AddLeaveDialog(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)
|
||
|
||
# 날짜 + 구분 한 줄
|
||
row1 = QHBoxLayout()
|
||
date_label = QLabel("날짜:")
|
||
date_label.setObjectName("field_label")
|
||
date_label.setFixedWidth(40)
|
||
self.date_edit = QDateEdit()
|
||
self.date_edit.setDate(QDate.currentDate())
|
||
self.date_edit.setCalendarPopup(True)
|
||
row1.addWidget(date_label)
|
||
row1.addWidget(self.date_edit)
|
||
row1.addSpacing(8)
|
||
type_label = QLabel("구분:")
|
||
type_label.setObjectName("field_label")
|
||
type_label.setFixedWidth(40)
|
||
self.type_combo = QComboBox()
|
||
self.type_combo.addItem("연차", "annual")
|
||
self.type_combo.addItem("반차", "half")
|
||
self.type_combo.addItem("반반차", "quarter")
|
||
self.type_combo.addItem("시간", "hourly")
|
||
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
|
||
row1.addWidget(type_label)
|
||
row1.addWidget(self.type_combo)
|
||
layout.addLayout(row1)
|
||
|
||
# 사용 시간 (시간 연차용)
|
||
hours_layout = QHBoxLayout()
|
||
hours_label = QLabel("시간:")
|
||
hours_label.setObjectName("field_label")
|
||
hours_label.setFixedWidth(40)
|
||
self.hours_spin = QDoubleSpinBox()
|
||
self.hours_spin.setRange(0.5, 8.0)
|
||
self.hours_spin.setSingleStep(0.5)
|
||
self.hours_spin.setValue(1.0)
|
||
self.hours_spin.setSuffix(" 시간")
|
||
self.hours_spin.setEnabled(False)
|
||
hours_layout.addWidget(hours_label)
|
||
hours_layout.addWidget(self.hours_spin)
|
||
layout.addLayout(hours_layout)
|
||
|
||
# 사유
|
||
memo_layout = QHBoxLayout()
|
||
memo_label = QLabel("사유:")
|
||
memo_label.setObjectName("field_label")
|
||
memo_label.setFixedWidth(40)
|
||
self.memo_input = QLineEdit()
|
||
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
|
||
memo_layout.addWidget(memo_label)
|
||
memo_layout.addWidget(self.memo_input)
|
||
layout.addLayout(memo_layout)
|
||
|
||
# 안내
|
||
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
|
||
info_label.setObjectName("note_text")
|
||
layout.addWidget(info_label)
|
||
|
||
# 버튼
|
||
button_layout = QHBoxLayout()
|
||
save_button = QPushButton("저장")
|
||
save_button.setObjectName("btn_primary")
|
||
save_button.clicked.connect(self.save_record)
|
||
button_layout.addWidget(save_button)
|
||
cancel_button = QPushButton("취소")
|
||
cancel_button.clicked.connect(self.reject)
|
||
button_layout.addWidget(cancel_button)
|
||
layout.addLayout(button_layout)
|
||
|
||
self.setLayout(layout)
|
||
|
||
def on_type_changed(self, index):
|
||
"""연차 유형 변경 시"""
|
||
leave_type = self.type_combo.currentData()
|
||
# 시간 연차일 때만 시간 입력 활성화
|
||
self.hours_spin.setEnabled(leave_type == "hourly")
|
||
|
||
def save_record(self):
|
||
"""기록 저장"""
|
||
date = self.date_edit.date().toString("yyyy-MM-dd")
|
||
leave_type = self.type_combo.currentData()
|
||
leave_type_name = self.type_combo.currentText()
|
||
memo = self.memo_input.text().strip()
|
||
|
||
# 사용 일수 계산
|
||
if leave_type == "annual":
|
||
days = 1.0
|
||
elif leave_type == "half":
|
||
days = 0.5
|
||
elif leave_type == "quarter":
|
||
days = 0.25
|
||
elif leave_type == "hourly":
|
||
hours = self.hours_spin.value()
|
||
days = hours / 8.0
|
||
else:
|
||
days = 1.0
|
||
|
||
# 잔여 연차 확인
|
||
current_balance = self.db.get_leave_balance()
|
||
if current_balance < days:
|
||
QMessageBox.warning(
|
||
self,
|
||
"잔여 연차 부족",
|
||
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
|
||
)
|
||
return
|
||
|
||
# 확인 메시지
|
||
hours = days * 8
|
||
reply = QMessageBox.question(
|
||
self,
|
||
"연차 사용 기록 추가",
|
||
f"날짜: {date}\n"
|
||
f"구분: {leave_type_name}\n"
|
||
f"사용: {days}일 ({hours}시간)\n"
|
||
f"사유: {memo if memo else '(없음)'}\n\n"
|
||
f"이 기록을 추가하시겠습니까?",
|
||
QMessageBox.Yes | QMessageBox.No
|
||
)
|
||
|
||
if reply == QMessageBox.Yes:
|
||
try:
|
||
# 연차 사용 기록 추가 (파라미터 순서: days, date, leave_type, memo)
|
||
self.db.use_leave(days, date, leave_type_name, memo)
|
||
QMessageBox.information(
|
||
self,
|
||
"추가 완료",
|
||
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
|
||
)
|
||
self.accept()
|
||
except Exception as e:
|
||
QMessageBox.critical(
|
||
self,
|
||
"오류",
|
||
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
|
||
)
|
||
|
||
|
||
# 테스트 코드
|
||
if __name__ == "__main__":
|
||
from PyQt5.QtWidgets import QApplication
|
||
|
||
app = QApplication(sys.argv)
|
||
dialog = LeaveView()
|
||
dialog.exec_()
|