KINDNICK 5fb8655a47 v2.11.0: UI 전면 다크 리디자인 + 라인 아이콘 + 적립 가드/삭제
- 모던 다크 미니멀 테마(NanumSquare 번들, 단일 accent #4DABF7, 8px radius, flat 버튼, 다크 기본값)
- 라인 아이콘 시스템(ui/icons.py, QtSvg) — 앱 전반 이모지 교체
- 다크 깨짐 수정: 테이블 헤더/코너 흰색, 도움말 탭 흰 라인, 트레이/미니위젯 메뉴
- fix: 자동 적립 OFF가 자동 퇴근 경로에서 무시되던 버그(게이팅)
- feat: 연장근무 적립 기록 삭제(우클릭)
- 테스트 3건 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:21:54 +09:00

430 lines
16 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)
schedule_button = QPushButton("스케줄")
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
schedule_button.clicked.connect(self._show_schedule)
button_layout.addWidget(schedule_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 _show_schedule(self):
from ui.schedule_view import ScheduleView
dlg = ScheduleView(self, self.db)
dlg.exec_()
# 닫고 돌아오면 잔액/리스트 갱신
self.load_data()
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(250, 82, 82)) # 사용 = 레드 (#FA5252)
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)
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
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
# 휴일/주말 검증 — 차감 의미 없으므로 차단
from datetime import datetime as _dt
date_dt = _dt.strptime(date, "%Y-%m-%d")
if date_dt.weekday() in (5, 6): # 토/일
QMessageBox.warning(
self,
"주말 등록 불가",
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
)
return
if self.db.is_holiday(date):
holiday = self.db.get_holiday(date)
name = (holiday or {}).get('name', '공휴일')
QMessageBox.warning(
self,
"공휴일 등록 불가",
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
)
return
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
existing_min = self.db.get_leave_minutes_for(date)
existing_days = existing_min / max(1, self.db.get_work_minutes())
if existing_days + days > 1.0001: # 부동소수점 여유
QMessageBox.warning(
self,
"중복 등록 초과",
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
)
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_()