# -*- 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