Phase 1 \u2014 \ubbf8\ub9ac \uc5f0\ucc28 \ub4f1\ub85d
- DB: get_leave_minutes_for(date) / has_full_day_leave(date) /
get_leave_records_by_date(date) / get_leave_records_by_range(start, end)
- TimeCalculator.effective_work_minutes(date_obj, db): \uc5f0\ucc28 \ubd84\ub9cc\ud07c \uc815\uaddc \uadfc\ubb34 \ucc28\uac10
- update_display() 1Hz hot-path:
\u2022 \uc885\uc77c \uc5f0\ucc28 \ub4f1\ub85d\uc77c + \ucd9c\uadfc \uc548 \ud55c \uc0c1\ud0dc \u2192 "\ud83c\udf34 \uc624\ub298\uc740 \ud734\uac00" \uce74\ub4dc \ud45c\uc2dc, \uce74\uc6b4\ud2b8\ub2e4\uc6b4 \uc81c\uac70
\u2022 \uc885\uc77c \uc5f0\ucc28 + \ucd9c\uadfc override \u2192 \ud734\uc77c\ucc98\ub7fc \uc804\uccb4 \uc801\ub9bd
\u2022 \ubd80\ubd84 \uc5f0\ucc28(\ubc18\ucc28/\uc2dc\uac04) \u2192 leave_used_today \uacbd\ub85c\ub85c \uae30\uc874 \ub2e8\ucd95 \uacc4\uc0b0 \uc720\uc9c0
- \uc790\ub3d9 \ucd9c\uadfc\uac10\uc9c0 \uac00\ub4dc: load_today_data\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c\uc774\uba74 event_monitor \ud638\ucd9c \uc790\uccb4 \uc2a4\ud0b5
- \uc218\ub3d9 \ucd9c\uadfc \uac00\ub4dc: manual_clock_in\uc5d0\uc11c \uc885\uc77c \uc5f0\ucc28\uc77c \ud655\uc778 \ud504\ub86c\ud504\ud2b8
- AddLeaveDialog \uac80\uc99d \uac15\ud654:
\u2022 \ubbf8\ub798 1\ub144\uae4c\uc9c0 setMaximumDate
\u2022 \uc8fc\ub9d0/\uacf5\ud734\uc77c \ub4f1\ub85d \ucc28\ub2e8 (\uc774\ubbf8 \ube44\uadfc\ubb34\uc77c)
\u2022 \uac19\uc740 \ub0a0 1\uc77c \ucd08\uacfc \ub204\uc801 \ucc28\ub2e8
- leave_calendar_view: \uc608\uc815(\ud30c\ub791) / \uc0ac\uc6a9\uc644\ub8cc(\ub179/\ub178/\ubcf4) \uc0c9\uc0c1 \ubd84\ub9ac
Phase 2 \u2014 \ud1b5\ud569 \uc2a4\ucf00\uc904 + \ubc18\ubcf5 \uc5f0\ucc28
- recurring_leaves \ud14c\uc774\ube14 (pattern/leave_type/days/start/end/memo)
- core/recurring_leaves.py: weekly / biweekly / monthly \ud328\ud134 \ud30c\uc11c + expand_for_range/date
- get_leave_minutes_for() / has_full_day_leave()\uac00 \ubc18\ubcf5 \ud328\ud134\ub3c4 \ud568\uaed8 \ud569\uc0b0
- ui/recurring_leave_dialog.py: \ub9e4\uc8fc/\uaca9\uc8fc/\ub9e4\uc6d4 \uc785\ub825 + \uc785\ub825 \ub9ac\uc2a4\ud2b8 \uad00\ub9ac
- ui/schedule_view.py: \uc6d4\uac04 \uc2a4\ud50c\ub9ac\ud130 \ub808\uc774\uc544\uc6c3 (\uce98\ub9b0\ub354 + \uc0c1\uc138)
\u2022 \ud734\uc77c(\ube68\uac15) / \uc5f0\ucc28 \uc0ac\uc6a9(\ub179\u30fb\ub178\u30fb\ubcf4) / \uc608\uc815(\ud30c\ub791) / \ubc18\ubcf5(\ud68c\uc0c9) \uc0c9 \ucf54\ub4dc
\u2022 \ub0a0\uc9dc \ud074\ub9ad \u2192 \uc0c1\uc138 \ud328\ub110 (\ub3d9\uc77c\uc77c\uc790 \uad6c\uccb4 \uc5f0\ucc28 + \ubc18\ubcf5 \ub9e4\uce58)
\u2022 \ub9ac\uc2a4\ud2b8 \uc6b0\ud074\ub9ad \uc0ad\uc81c (\uad6c\uccb4 / \ubc18\ubcf5 \uad6c\ubd84)
\u2022 \uc6d4 \ubcc0\uacbd \uc2dc \uc790\ub3d9 reload
- \uc9c4\uc785\uc810: main_window.show_schedule(), tray menu '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904', LeaveView '\ud83d\uddd3\ufe0f \uc2a4\ucf00\uc904' \ubc84\ud2bc
Tests
- tests/test_recurring_leaves.py 32\uac1c (\ud328\ud134 \ud30c\uc2f1 / \ub9e4\uce6d / expand / describe)
- tests/test_database.py +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
- _integration_test.py +4 (S52B-S52E)
- pytest: 122 \u2192 175 \uc804\ubd80 green
- \ud1b5\ud569: 49 \u2192 53 \uc804\ubd80 green
- UI-5/UI-7 \uae30\uc874 \uace0\uc7a5 (v2.8.0 \ub514\uc790\uc778 \ub9ac\ub274\uc5bc \ub9c8\ub108)
430 lines
16 KiB
Python
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(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)
|
|
# 미래 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_()
|