KINDNICK ff71886fd7 v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱
- 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+ 아키텍처 반영
2026-04-30 19:30:47 +09:00

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_()