- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키 - 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용 - MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출 - 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등) - pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122) - README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
384 lines
14 KiB
Python
384 lines
14 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(tr('view.leave.title'))
|
|
title.setObjectName("dialog_title")
|
|
header_layout.addWidget(title)
|
|
header_layout.addStretch()
|
|
self.balance_label = QLabel(tr('view.leave.balance_zero'))
|
|
self.balance_label.setObjectName("badge_leave")
|
|
header_layout.addWidget(self.balance_label)
|
|
set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
|
|
set_balance_button.clicked.connect(self.set_balance)
|
|
header_layout.addWidget(set_balance_button)
|
|
layout.addLayout(header_layout)
|
|
|
|
# 사용 내역
|
|
used_group = QGroupBox(tr('view.leave.used_group'))
|
|
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([
|
|
tr('view.leave.col_date'),
|
|
tr('view.leave.col_type'),
|
|
tr('view.leave.col_used'),
|
|
tr('view.leave.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.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(tr('view.leave.btn_add'))
|
|
add_leave_button.clicked.connect(self.add_leave_record)
|
|
button_layout.addWidget(add_leave_button)
|
|
|
|
cal_button = QPushButton(tr('view.leave.btn_calendar'))
|
|
cal_button.clicked.connect(self._show_calendar)
|
|
button_layout.addWidget(cal_button)
|
|
|
|
close_button = QPushButton(tr('btn.close'))
|
|
close_button.clicked.connect(self.close)
|
|
button_layout.addWidget(close_button)
|
|
layout.addLayout(button_layout)
|
|
|
|
self.setLayout(layout)
|
|
|
|
def _show_calendar(self):
|
|
from ui.leave_calendar_view import LeaveCalendarView
|
|
dlg = LeaveCalendarView(self, self.db)
|
|
dlg.exec_()
|
|
|
|
def load_data(self):
|
|
"""데이터 로드"""
|
|
# 잔액 업데이트
|
|
balance = self.db.get_leave_balance()
|
|
hours = balance * 8
|
|
self.balance_label.setText(tr('view.leave.balance_fmt',
|
|
days=balance, hours=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(tr('btn.delete_short'), 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,
|
|
tr('msg.confirm_delete.title'),
|
|
tr('view.leave.delete_confirm_body',
|
|
date=date_item.text(), type=type_item.text(),
|
|
days=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,
|
|
tr('view.leave.set_title'),
|
|
tr('view.leave.set_prompt'),
|
|
current_hours,
|
|
0.0,
|
|
999.0,
|
|
1
|
|
)
|
|
|
|
if ok:
|
|
# 0.5시간 단위로 반올림
|
|
hours = round(hours * 2) / 2
|
|
# 시간을 일수로 변환
|
|
days = hours / 8.0
|
|
self.db.set_leave_balance(days)
|
|
QMessageBox.information(
|
|
self,
|
|
tr('view.leave.set_done_title'),
|
|
tr('view.leave.set_done_body', days=days, hours=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(tr('view.leave.add_title'))
|
|
self.setModal(True)
|
|
self.setMinimumWidth(360)
|
|
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(8)
|
|
layout.setContentsMargins(12, 10, 12, 10)
|
|
|
|
# 제목
|
|
title = QLabel(tr('view.leave.add_title'))
|
|
title.setObjectName("dialog_subtitle")
|
|
title.setAlignment(Qt.AlignCenter)
|
|
layout.addWidget(title)
|
|
|
|
# 날짜 + 구분 한 줄
|
|
row1 = QHBoxLayout()
|
|
date_label = QLabel(tr('view.leave.field_date'))
|
|
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(tr('view.leave.field_type'))
|
|
type_label.setObjectName("field_label")
|
|
type_label.setFixedWidth(40)
|
|
self.type_combo = QComboBox()
|
|
self.type_combo.addItem(tr('view.leave.type_annual'), "annual")
|
|
self.type_combo.addItem(tr('view.leave.type_half'), "half")
|
|
self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
|
|
self.type_combo.addItem(tr('view.leave.type_hourly'), "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(tr('view.leave.field_hours'))
|
|
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(' ' + tr('label.unit_hour'))
|
|
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(tr('view.leave.field_reason'))
|
|
memo_label.setObjectName("field_label")
|
|
memo_label.setFixedWidth(40)
|
|
self.memo_input = QLineEdit()
|
|
self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
|
|
memo_layout.addWidget(memo_label)
|
|
memo_layout.addWidget(self.memo_input)
|
|
layout.addLayout(memo_layout)
|
|
|
|
# 안내
|
|
info_label = QLabel(tr('view.leave.note_auto_deduct'))
|
|
info_label.setObjectName("note_text")
|
|
layout.addWidget(info_label)
|
|
|
|
# 버튼
|
|
button_layout = QHBoxLayout()
|
|
save_button = QPushButton(tr('btn.save'))
|
|
save_button.setObjectName("btn_primary")
|
|
save_button.clicked.connect(self.save_record)
|
|
button_layout.addWidget(save_button)
|
|
cancel_button = QPushButton(tr('btn.cancel'))
|
|
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,
|
|
tr('view.leave.short_title'),
|
|
tr('view.leave.short_body', balance=current_balance, req=days)
|
|
)
|
|
return
|
|
|
|
# 확인 메시지
|
|
hours = days * 8
|
|
reply = QMessageBox.question(
|
|
self,
|
|
tr('view.leave.confirm_title'),
|
|
tr('view.leave.confirm_body',
|
|
date=date, type=leave_type_name, days=days, hours=hours,
|
|
reason=(memo if memo else '-')),
|
|
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,
|
|
tr('view.leave.added_title'),
|
|
tr('view.leave.added_body', days=days, hours=hours)
|
|
)
|
|
self.accept()
|
|
except Exception as e:
|
|
QMessageBox.critical(
|
|
self,
|
|
tr('view.leave.error_title'),
|
|
tr('view.leave.error_body', err=str(e))
|
|
)
|
|
|
|
|
|
# 테스트 코드
|
|
if __name__ == "__main__":
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
app = QApplication(sys.argv)
|
|
dialog = LeaveView()
|
|
dialog.exec_()
|