handOver2/ui/dialogs/team_settings_dialog.py

781 lines
28 KiB
Python

# -*- coding: utf-8 -*-
"""
팀 인원 설정 다이얼로그
각 팀의 부팀장과 운용 인원을 설정합니다.
"""
from typing import List, Optional, Dict
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTabWidget,
QLabel, QPushButton, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox
)
from PySide6.QtCore import Qt, Signal, QSize, QTimer
from PySide6.QtGui import QColor, QPainter, QPen, QBrush
from ui.base.base_dialog import BaseDialog
from ui.components.custom_input import CustomLineEdit
from ui.components.custom_button import CustomButton
from ui.components.custom_checkbox import CustomCheckBox
from ui.styles.style_manager import StyleManager
from database.crud import CRUDManager
from database.models import TeamMember
from core.constants import TEAMS
from core.logger import get_logger
logger = get_logger(__name__)
class PositionBox(QWidget):
"""직책 표시 박스 (부팀장/운용)"""
position_toggled = Signal(int, str) # member_id, new_position
def __init__(self, member_id: int, position: str, parent=None):
super().__init__(parent)
self.member_id = member_id
self.position = position
self.style_manager = StyleManager()
self.setFixedSize(60, 30)
self.setCursor(Qt.PointingHandCursor)
def paintEvent(self, _event):
"""그리기"""
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
colors = self.style_manager.get_colors()
font = self.style_manager.get_font("dialog", "content")
# 직책에 따른 색상 구분
if self.position == "부팀장":
bg_color = QColor("#3b82f6") # 파란색
text_color = QColor("#ffffff")
else: # 운용
bg_color = QColor("#22c55e") # 초록색
text_color = QColor("#ffffff")
# 배경
rect = self.rect().adjusted(2, 2, -2, -2)
painter.setBrush(QBrush(bg_color))
painter.setPen(QPen(QColor(colors['border']), 1))
painter.drawRoundedRect(rect, 4, 4)
# 텍스트
painter.setPen(text_color)
painter.setFont(font)
painter.drawText(rect, Qt.AlignCenter, self.position)
def mousePressEvent(self, _event):
"""클릭으로 직책 토글"""
if _event.button() == Qt.LeftButton:
new_position = "운용" if self.position == "부팀장" else "부팀장"
self.position = new_position
self.position_toggled.emit(self.member_id, new_position)
self.update()
class NameLabel(QLabel):
"""이름 레이블 (더블클릭으로 수정)"""
name_changed = Signal(int, str) # member_id, new_name
def __init__(self, member_id: int, name: str, parent=None):
super().__init__(name, parent)
self.member_id = member_id
self._original_name = name
self._is_editing = False
self.edit_widget = None
self.style_manager = StyleManager()
self.setCursor(Qt.PointingHandCursor)
def mouseDoubleClickEvent(self, _event):
"""더블클릭으로 수정 모드"""
if self._is_editing:
return
self._is_editing = True
self._original_name = self.text()
# 다이얼로그 레벨로 편집 위젯 생성
dialog = self._find_dialog()
if not dialog:
return
# 편집 모드로 전환 (다이얼로그 위에 표시)
self.edit_widget = QLineEdit(self.text(), dialog)
# 리스트 항목의 전역 좌표로 위치 설정
# NameLabel의 부모(MemberListItem)를 통해 위치 계산
parent_widget = self.parent()
if parent_widget:
# MemberListItem의 전역 위치
item_global_pos = parent_widget.mapToGlobal(parent_widget.rect().topLeft())
# 다이얼로그의 전역 위치
dialog_global_pos = dialog.mapToGlobal(dialog.rect().topLeft())
# 상대 위치 계산
relative_x = item_global_pos.x() - dialog_global_pos.x()
relative_y = item_global_pos.y() - dialog_global_pos.y()
# NameLabel의 상대 위치 추가 (부모 위젯 내에서의 위치)
name_pos_in_parent = self.pos()
relative_x += name_pos_in_parent.x()
relative_y += name_pos_in_parent.y()
else:
# 폴백: 다이얼로그 중앙
relative_x = dialog.width() // 2 - 150
relative_y = dialog.height() // 2
# 다이얼로그 내 위치로 조정
self.edit_widget.setGeometry(
relative_x,
relative_y,
max(self.width(), 200), # 최소 너비
max(self.height(), 40) # 최소 높이
)
# 스타일 적용
colors = self.style_manager.get_colors()
input_font = self.style_manager.get_font("dialog", "input")
input_height = self.style_manager.calculate_input_height(
font=input_font, area="dialog", style="input"
)
self.edit_widget.setStyleSheet(f"""
QLineEdit {{
background-color: {colors['input_bg']};
color: {colors['input_text']};
border: 2px solid {colors['accent']};
border-radius: 6px;
padding: {input_height // 4}px 12px;
font-family: '{input_font.family()}';
font-size: {input_font.pointSize()}pt;
min-height: {input_height}px;
}}
QLineEdit:focus {{
border-color: {colors['accent']};
outline: none;
}}
""")
self.edit_widget.setFont(input_font)
# z-order 최상위로
self.edit_widget.raise_()
self.edit_widget.selectAll()
self.edit_widget.setFocus()
self.edit_widget.show()
# 엔터 키 처리 (다이얼로그 닫힘 방지)
def handle_return_pressed():
self._finish_edit()
self.edit_widget.returnPressed.connect(handle_return_pressed)
self.edit_widget.editingFinished.connect(self._finish_edit)
# keyPressEvent 오버라이드하여 엔터 키가 다이얼로그로 전파되지 않도록
original_key_press = self.edit_widget.keyPressEvent
def key_press_handler(event):
if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
self._finish_edit()
event.accept() # 이벤트 소비하여 전파 방지
else:
original_key_press(event)
self.edit_widget.keyPressEvent = key_press_handler
# focusOutEvent 오버라이드
original_focus_out = self.edit_widget.focusOutEvent
def focus_out_handler(event):
original_focus_out(event)
self._finish_edit()
self.edit_widget.focusOutEvent = focus_out_handler
def _find_dialog(self):
"""다이얼로그 찾기"""
parent = self.parent()
while parent:
from ui.base.base_dialog import BaseDialog
if isinstance(parent, BaseDialog):
return parent
parent = parent.parent()
return None
def _finish_edit(self):
"""편집 완료"""
if not self._is_editing or not self.edit_widget:
return
new_name = self.edit_widget.text().strip()
if new_name and new_name != self._original_name:
# 이름 변경 시그널 발생
self.name_changed.emit(self.member_id, new_name)
# 리스트 아이템 텍스트 업데이트
self.setText(new_name)
# 편집 위젯 제거
self.edit_widget.deleteLater()
self.edit_widget = None
self._is_editing = False
class MemberListItem(QWidget):
"""인원 리스트 항목 위젯"""
# 시그널
position_toggled = Signal(int, str) # member_id, new_position
name_changed = Signal(int, str) # member_id, new_name
delete_requested = Signal(int) # member_id
def __init__(self, member: TeamMember, group_number: int, parent=None):
super().__init__(parent)
self.member = member
self.group_number = group_number
self.style_manager = StyleManager()
self._setup_ui()
def _setup_ui(self):
"""UI 설정"""
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 4, 8, 4)
layout.setSpacing(8)
colors = self.style_manager.get_colors()
# 체크박스 (커스텀)
self.checkbox = CustomCheckBox()
self.checkbox.setFixedSize(24, 24)
self.checkbox.stateChanged.connect(self._on_checkbox_changed)
layout.addWidget(self.checkbox)
# 직책 박스
self.position_box = PositionBox(self.member.id, self.member.position, self)
self.position_box.position_toggled.connect(self._on_position_toggled)
layout.addWidget(self.position_box)
# 이름
self.name_label = NameLabel(self.member.id, self.member.name, self)
name_font = self.style_manager.get_font("dialog", "content")
self.name_label.setFont(name_font)
name_height = self.style_manager.calculate_label_height(
font=name_font, area="dialog", style="content"
)
self.name_label.setMinimumHeight(name_height)
self.name_label.name_changed.connect(self._on_name_changed)
layout.addWidget(self.name_label, 1)
# 그룹번호
if self.group_number > 0:
group_label = QLabel(f"그룹{self.group_number}")
group_font = self.style_manager.get_font("dialog", "content")
group_label.setFont(group_font)
group_label.setStyleSheet(f"color: {colors['text_tertiary']};")
layout.addWidget(group_label)
else:
layout.addWidget(QLabel("")) # 공간 확보
# 삭제 버튼
self.delete_btn = QPushButton("삭제")
self.delete_btn.setFixedSize(50, 28)
self.delete_btn.setStyleSheet(f"""
QPushButton {{
background-color: {colors['error']};
color: white;
border: none;
border-radius: 4px;
font-size: 11pt;
}}
QPushButton:hover {{
background-color: #dc2626;
}}
""")
self.delete_btn.clicked.connect(self._on_delete_clicked)
layout.addWidget(self.delete_btn)
def _on_position_toggled(self, member_id: int, new_position: str):
"""직책 토글"""
self.member.position = new_position
# 시그널 emit
self.position_toggled.emit(member_id, new_position)
def _on_name_changed(self, member_id: int, new_name: str):
"""이름 변경"""
self.member.name = new_name
self.name_label.setText(new_name)
# 시그널 emit
self.name_changed.emit(member_id, new_name)
def _on_delete_clicked(self):
"""삭제 버튼 클릭"""
# 시그널 emit
self.delete_requested.emit(self.member.id)
def set_group_color(self, color: str):
"""그룹 배경색 설정"""
self.setStyleSheet(f"background-color: {color}; border-radius: 4px;")
def _on_checkbox_changed(self, state):
"""체크박스 변경 시"""
self._notify_selection_change()
def _notify_selection_change(self):
"""체크박스 변경 알림"""
# MemberListWidget 찾기
parent = self.parent()
while parent:
if isinstance(parent, MemberListWidget):
parent.check_selection()
break
parent = parent.parent()
class MemberListWidget(QWidget):
"""인원 목록 위젯"""
member_changed = Signal()
def __init__(
self,
parent=None,
team: str = "",
position: Optional[str] = None,
max_members: int = 3
):
super().__init__(parent)
self.team = team
self.position = position
self.max_members = max_members
self.crud = CRUDManager()
self.style_manager = StyleManager()
self.members: List[TeamMember] = []
self.group_colors: Dict[int, str] = {}
self._setup_ui()
self._load_members()
def _setup_ui(self):
"""UI 설정"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
colors = self.style_manager.get_colors()
# 제목
if self.position:
title_text = f"{self.position} ({self.max_members}명)"
else:
title_text = "인원 목록"
title = QLabel(title_text)
title_font = self.style_manager.get_font("dialog", "label")
title.setFont(title_font)
title_height = self.style_manager.calculate_label_height(
font=title_font, area="dialog", style="label"
)
title.setStyleSheet(f"""
color: {colors['text_primary']};
font-weight: bold;
min-height: {title_height}px;
""")
layout.addWidget(title)
# 리스트
self.list_widget = QListWidget()
self.list_widget.setFont(self.style_manager.get_font("dialog", "content"))
self.list_widget.setMaximumHeight(300)
self.list_widget.setStyleSheet(f"""
QListWidget {{
background-color: {colors['bg_secondary']};
border: 1px solid {colors['border']};
border-radius: 6px;
}}
QListWidget::item {{
border-bottom: 1px solid {colors['border']};
}}
QListWidget::item:last {{
border-bottom: none;
}}
""")
layout.addWidget(self.list_widget)
# 입력 영역
input_layout = QHBoxLayout()
input_layout.setSpacing(8)
self.name_input = CustomLineEdit(placeholder="이름 입력")
# 엔터 키 이벤트 처리 (다이얼로그 닫힘 방지)
def handle_return_pressed():
self._add_member()
self.name_input.returnPressed.connect(handle_return_pressed)
# 엔터 키가 다이얼로그로 전파되지 않도록 처리
original_key_press = self.name_input.keyPressEvent
def key_press_handler(event):
if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
self._add_member()
event.accept()
else:
original_key_press(event)
self.name_input.keyPressEvent = key_press_handler
input_layout.addWidget(self.name_input, 1)
self.add_btn = CustomButton("추가", style_type="primary", fixed_height=36)
self.add_btn.clicked.connect(self._add_member)
input_layout.addWidget(self.add_btn)
self.group_btn = CustomButton("그룹묶기", style_type="outline", fixed_height=36)
self.group_btn.setEnabled(False)
self.group_btn.clicked.connect(self._toggle_group)
input_layout.addWidget(self.group_btn)
layout.addLayout(input_layout)
# 선택 변경 타이머 (너무 빈번한 호출 방지)
self._selection_timer = QTimer()
self._selection_timer.setSingleShot(True)
self._selection_timer.timeout.connect(self._update_group_button_state)
def _load_members(self):
"""인원 목록 로드"""
self.list_widget.clear()
self.members = []
# 디버깅: 조회 파라미터 로그
logger.debug("인원 목록 로드: 팀=%s, 직책=%s, 활성화만=%s",
self.team, self.position, True)
all_members = self.crud.get_team_members_by_team(
self.team,
position=None, # 모든 직책
active_only=True
)
# 디버깅: 조회 결과 로그
logger.debug("조회된 멤버 수: %d (팀=%s)", len(all_members), self.team)
if all_members:
logger.debug("멤버 목록: %s", [f"{m.name}({m.position})" for m in all_members])
# 그룹 번호 계산
group_map = self._calculate_groups(all_members)
# 모든 인원 표시 (직책 필터링 없음)
for member in all_members:
self.members.append(member)
group_number = group_map.get(member.id, 0)
# 리스트 항목 생성
item = QListWidgetItem()
item.setSizeHint(QSize(0, 40))
member_widget = MemberListItem(member, group_number, self)
member_widget.position_toggled.connect(self._on_position_toggled)
member_widget.name_changed.connect(self._on_name_changed)
member_widget.delete_requested.connect(self._on_delete_requested)
# 그룹 배경색 설정
if group_number > 0:
color = self._get_group_color(group_number)
member_widget.set_group_color(color)
self.list_widget.addItem(item)
self.list_widget.setItemWidget(item, member_widget)
self._update_group_button_state()
def _calculate_groups(self, members: List[TeamMember]) -> Dict[int, int]:
"""그룹 번호 계산"""
group_map = {}
group_counter = 1
for member in members:
if member.partner_id and member.id not in group_map:
# 파트너 찾기
partner = next((m for m in members if m.id == member.partner_id), None)
if partner and partner.id not in group_map:
group_num = group_counter
group_counter += 1
group_map[member.id] = group_num
group_map[partner.id] = group_num
return group_map
def _get_group_color(self, group_number: int) -> str:
"""그룹별 색상 반환"""
colors = [
"#e0f2fe", # 그룹1 - 연한 파랑
"#fef3c7", # 그룹2 - 연한 노랑
"#fce7f3", # 그룹3 - 연한 분홍
"#d1fae5", # 그룹4 - 연한 초록
"#e9d5ff", # 그룹5 - 연한 보라
]
theme = self.style_manager.config.theme
if theme == 'dark':
colors = [
"#1e3a5f", # 그룹1 - 어두운 파랑
"#5a4a1f", # 그룹2 - 어두운 노랑
"#5a2a4a", # 그룹3 - 어두운 분홍
"#1a4a2f", # 그룹4 - 어두운 초록
"#4a2a5a", # 그룹5 - 어두운 보라
]
return colors[(group_number - 1) % len(colors)]
def check_selection(self):
"""체크박스 변경 감지 (public 메서드)"""
self._selection_timer.start(100) # 100ms 후 호출
def _update_group_button_state(self):
"""체크된 아이템을 분석하여 그룹 버튼 활성화 여부 결정"""
checked_widgets = []
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
widget = self.list_widget.itemWidget(item)
if isinstance(widget, MemberListItem) and widget.checkbox.isChecked():
checked_widgets.append(widget)
# 초기화
self.group_btn.setEnabled(False)
self.group_btn.setText("그룹묶기")
# 조건: 정확히 2명 선택
if len(checked_widgets) != 2:
return
w1, w2 = checked_widgets[0], checked_widgets[1]
# DB에서 최신 데이터 가져오기
m1 = self.crud.get_team_member(w1.member.id)
m2 = self.crud.get_team_member(w2.member.id)
if not m1 or not m2:
return
# 위젯의 member 객체도 업데이트
w1.member = m1
w2.member = m2
# 조건: 부팀장 1명 + 운용 1명
positions = {m1.position, m2.position}
if "부팀장" not in positions or "운용" not in positions:
return
# 로직: 이미 짝궁인가? (양방향 체크)
is_partners = (m1.partner_id == m2.id) or (m2.partner_id == m1.id)
if is_partners:
# 이미 그룹임 -> 그룹 해제 모드
self.group_btn.setText("그룹해제")
self.group_btn.setEnabled(True)
else:
# 그룹이 아님 -> 그룹 묶기 모드
self.group_btn.setText("그룹묶기")
self.group_btn.setEnabled(True)
def _add_member(self):
"""인원 추가"""
name = self.name_input.text().strip()
if not name:
return
# 최대 인원 확인 (제한이 있는 경우만)
if self.max_members > 0 and len(self.members) >= self.max_members:
QMessageBox.warning(
self,
"인원 초과",
f"최대 {self.max_members}명까지 등록 가능합니다."
)
return
# 순서 결정
order = len(self.members)
# DB에 추가 (기본 부팀장)
try:
new_member = self.crud.create_team_member(
team=self.team,
position="부팀장", # 기본값
name=name,
order=order
)
logger.info("팀 인원 추가 성공: 팀=%s, 이름=%s, 직책=%s, ID=%s",
self.team, name, "부팀장", new_member.id if new_member else "None")
except Exception as e:
logger.error("팀 인원 추가 실패: 팀=%s, 이름=%s, 오류=%s", self.team, name, e)
QMessageBox.warning(
self,
"추가 실패",
f"인원 추가에 실패했습니다.\n오류: {str(e)}"
)
return
self.name_input.clear()
self.name_input.setFocus() # 포커스 유지
self._load_members()
self.member_changed.emit()
def _on_position_toggled(self, member_id: int, new_position: str):
"""직책 토글"""
self.crud.update_team_member(member_id, position=new_position)
self._load_members()
self.member_changed.emit()
def _on_name_changed(self, member_id: int, new_name: str):
"""이름 변경"""
self.crud.update_team_member(member_id, name=new_name)
self.member_changed.emit()
def _on_delete_requested(self, member_id: int):
"""삭제 요청"""
# 확인 메시지
reply = QMessageBox.question(
self,
"삭제 확인",
"정말 삭제하시겠습니까?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.crud.delete_team_member(member_id)
self._load_members()
self.member_changed.emit()
logger.info("팀 인원 삭제: ID %s", member_id)
def _toggle_group(self):
"""그룹 묶기/해제"""
checked_items = []
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
widget = self.list_widget.itemWidget(item)
if isinstance(widget, MemberListItem) and widget.checkbox.isChecked():
checked_items.append(widget.member)
if len(checked_items) != 2:
return
member1 = checked_items[0]
member2 = checked_items[1]
if self.group_btn.text() == "그룹해제":
# 그룹 해제
self.crud.set_partner(member1.id, None)
self.crud.set_partner(member2.id, None)
else:
# 그룹 묶기
self.crud.set_partner(member1.id, member2.id)
# 체크박스 해제
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
widget = self.list_widget.itemWidget(item)
if isinstance(widget, MemberListItem):
widget.checkbox.setChecked(False)
self._load_members()
self.member_changed.emit()
class TeamSettingsDialog(BaseDialog):
"""
팀 인원 설정 다이얼로그
각 팀의 부팀장과 운용 인원을 설정합니다.
"""
def __init__(self, parent=None):
super().__init__(
parent,
title="팀 인원 설정",
width=400,
height=600,
min_width=300,
min_height=500
)
self.style_manager = StyleManager()
self._setup_content()
self.add_button("닫기", self.close, primary=True)
def keyPressEvent(self, event):
"""키 이벤트 처리 - 편집 중일 때 엔터 키가 다이얼로그를 닫지 않도록"""
# 편집 중인 위젯이 있는지 확인
for tab_idx in range(self.tabs.count()):
tab = self.tabs.widget(tab_idx)
member_list = tab.findChild(MemberListWidget)
if member_list:
for i in range(member_list.list_widget.count()):
item = member_list.list_widget.item(i)
widget = member_list.list_widget.itemWidget(item)
if isinstance(widget, MemberListItem):
name_label = widget.name_label
if name_label._is_editing and name_label.edit_widget:
# 편집 중이면 엔터 키를 무시
if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
event.accept()
return
# 편집 중이 아니면 기본 동작 수행
super().keyPressEvent(event)
def _setup_content(self):
"""컨텐츠 설정"""
colors = self.style_manager.get_colors()
# 탭 위젯
self.tabs = QTabWidget()
tab_font = self.style_manager.get_font("dialog", "label")
self.tabs.setFont(tab_font)
# 탭 스타일 적용
self.tabs.setStyleSheet(f"""
QTabWidget::pane {{
border: 1px solid {colors['border']};
border-radius: 8px;
background-color: {colors['bg_secondary']};
}}
QTabBar::tab {{
background-color: {colors['bg_tertiary']};
color: {colors['text_secondary']};
padding: 8px 16px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
font-family: '{tab_font.family()}';
font-size: {tab_font.pointSize()}pt;
min-height: {self.style_manager.calculate_label_height(font=tab_font, area="dialog", style="label")}px;
}}
QTabBar::tab:selected {{
background-color: {colors['bg_secondary']};
color: {colors['text_primary']};
font-weight: bold;
}}
QTabBar::tab:hover {{
background-color: {colors['bg_hover']};
}}
""")
# 각 팀별 탭 생성
for team in TEAMS:
tab = self._create_team_tab(team)
self.tabs.addTab(tab, team)
self.content_layout.addWidget(self.tabs)
def _create_team_tab(self, team: str) -> QWidget:
"""팀별 탭 생성"""
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(16)
# 통합 인원 목록 (부팀장과 운용 모두)
member_list = MemberListWidget(
parent=tab,
team=team,
position=None, # 모든 직책
max_members=10 # 제한 없음
)
layout.addWidget(member_list, 1)
return tab