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

372 lines
13 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, 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_()