# -*- coding: utf-8 -*- """ 기본 섹션 클래스 모듈 모든 섹션(지시, 고장, 작업, 기타)의 기반 클래스를 정의합니다. 이 모듈은 다음 기능을 제공합니다: - 테이블 뷰 통합 - 필드 설정 - CRUD 연동 - 필터링 및 검색 """ from datetime import date from typing import List, Any, Optional, Type from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, QPushButton, QLineEdit, QMenu, QAbstractItemView ) from PySide6.QtCore import Qt, Signal, QPoint from PySide6.QtGui import QAction import json from .base_widget import BaseWidget from core.logger import get_logger from database.crud import CRUDManager from database.models import BaseModel, SectionBase # 로거 설정 logger = get_logger(__name__) class FieldConfig: """ 필드 설정 클래스 섹션 테이블의 각 필드(컬럼) 설정을 정의합니다. Attributes: name: 필드 이름 (DB 컬럼명) label: 표시 레이블 width: 컬럼 너비 required: 필수 여부 visible: 표시 여부 editable: 편집 가능 여부 field_type: 필드 타입 (text, date, time, checkbox, dropdown, button) options: 드롭다운 옵션 (field_type이 dropdown인 경우) """ def __init__( self, name: str, label: str, width: int = 100, required: bool = False, visible: bool = True, editable: bool = True, field_type: str = "text", options: List[str] = None, display_format: str = None ): self.name = name self.label = label self.width = width self.required = required self.visible = visible self.editable = editable self.field_type = field_type self.options = options or [] self.display_format = display_format # 표시형식 (예: "full", "short", "month_day") class BaseSection(BaseWidget): """ 기본 섹션 클래스 모든 섹션(지시, 고장, 작업, 기타)이 상속받는 기반 클래스입니다. 테이블 기반의 데이터 표시 및 CRUD 기능을 제공합니다. Attributes: table_name: 데이터베이스 테이블 이름 model_class: 모델 클래스 fields: 필드 설정 리스트 crud: CRUD 관리자 Examples: >>> class InstructionSection(BaseSection): ... def __init__(self, parent=None): ... super().__init__(parent, "instructions", Instruction) ... self.setup_fields() """ # 시그널 record_selected = Signal(int) # 레코드 ID record_double_clicked = Signal(int) # 레코드 ID data_refreshed = Signal() def __init__( self, parent=None, table_name: str = "", model_class: Type[SectionBase] = None ): super().__init__(parent) self.table_name = table_name self.model_class = model_class self.fields: List[FieldConfig] = [] self.crud = CRUDManager() self.current_records: List[BaseModel] = [] self._current_team = self.config.current_team self._section_name = self._get_section_name() # 섹션 이름 (지시, 고장, 작업, 기타) # 기본 필드 설정 self._setup_default_fields() # UI 설정 self._setup_section_ui() # 시그널 연결 self._connect_signals() logger.info(f"섹션 초기화: {table_name}") def _post_init(self): """초기화 후 처리 (자식 클래스의 _setup_fields 후 호출)""" # 저장된 필드 설정 로드 self._load_field_settings() def _setup_default_fields(self): """기본 필드 설정 (모든 섹션 공통)""" self.fields = [ FieldConfig("created_date", "생성일", width=100, required=True, editable=False), FieldConfig("created_team", "생성팀", width=80, required=True, editable=False), ] # 설정에서 필드 표시 여부 읽어오기 self._load_field_visibility() def _load_field_visibility(self): """ 설정에서 필드 표시 여부 읽어오기 각 필드의 visible 속성을 설정에서 읽어와서 업데이트합니다. """ # 기본적으로 모든 필드는 표시됨 # 필요시 설정에서 읽어와서 업데이트할 수 있음 pass def _load_field_settings(self): """저장된 필드 설정 로드""" if not self._section_name: return try: # 저장된 설정을 필드에 적용 self.config.apply_field_settings_to_fields( self._current_team, self._section_name, self.fields ) logger.debug(f"필드 설정 로드 완료: {self._section_name}") except Exception as e: logger.error(f"필드 설정 로드 실패: {e}") def _get_section_name(self) -> str: """ 섹션 이름 반환 table_name을 기반으로 한글 섹션 이름을 반환합니다. Returns: 섹션 이름 (지시, 고장, 작업, 기타) """ name_map = { "instructions": "지시", "faults": "고장", "works": "작업", "miscs": "기타", } return name_map.get(self.table_name, self.table_name) def _setup_section_ui(self): """섹션 UI 설정""" layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) # 툴바 self._create_toolbar(layout) # 테이블 self._create_table(layout) def _create_toolbar(self, layout: QVBoxLayout): """툴바 생성""" toolbar = QWidget() toolbar_layout = QHBoxLayout(toolbar) toolbar_layout.setContentsMargins(0, 0, 0, 0) toolbar_layout.setSpacing(8) # 추가 버튼 self.add_btn = QPushButton("+ 추가") self.add_btn.setObjectName("addButton") self.add_btn.setCursor(Qt.PointingHandCursor) self.add_btn.clicked.connect(self.on_add_clicked) toolbar_layout.addWidget(self.add_btn) # 새로고침 버튼 self.refresh_btn = QPushButton("↻ 새로고침") self.refresh_btn.setCursor(Qt.PointingHandCursor) self.refresh_btn.clicked.connect(self.refresh_data) toolbar_layout.addWidget(self.refresh_btn) toolbar_layout.addStretch() # 검색 입력 self.search_input = QLineEdit() self.search_input.setPlaceholderText("검색...") self.search_input.setFixedWidth(200) self.search_input.textChanged.connect(self.on_search_changed) toolbar_layout.addWidget(self.search_input) # 필드 설정 저장 버튼 self.field_btn = QPushButton("💾 필드저장") self.field_btn.setCursor(Qt.PointingHandCursor) self.field_btn.setToolTip("현재 필드 설정(표시여부, 너비, 표시형식)을 저장합니다.\n우클릭으로 필드 표시/숨김, 드래그로 너비 조정 후 저장하세요.") self.field_btn.clicked.connect(self.save_field_settings) toolbar_layout.addWidget(self.field_btn) # 기록보기 버튼 self.history_btn = QPushButton("📋 기록보기") self.history_btn.setCursor(Qt.PointingHandCursor) self.history_btn.clicked.connect(self.show_history_dialog) toolbar_layout.addWidget(self.history_btn) layout.addWidget(toolbar) self._apply_toolbar_style() def _apply_toolbar_style(self): """툴바 스타일 적용""" theme = self.config.theme if theme == 'dark': btn_bg = "#334155" btn_hover = "#475569" input_bg = "#1e293b" input_border = "#475569" text_color = "#f8fafc" primary_bg = "#3b82f6" primary_hover = "#2563eb" else: btn_bg = "#e2e8f0" btn_hover = "#cbd5e1" input_bg = "#ffffff" input_border = "#e2e8f0" text_color = "#1e293b" primary_bg = "#3b82f6" primary_hover = "#2563eb" self.setStyleSheet(f""" QPushButton {{ background-color: {btn_bg}; color: {text_color}; border: none; border-radius: 6px; padding: 8px 16px; font-weight: 500; }} QPushButton:hover {{ background-color: {btn_hover}; }} #addButton {{ background-color: {primary_bg}; color: white; }} #addButton:hover {{ background-color: {primary_hover}; }} QLineEdit {{ background-color: {input_bg}; color: {text_color}; border: 1px solid {input_border}; border-radius: 6px; padding: 8px 12px; }} QLineEdit:focus {{ border-color: {primary_bg}; }} """) def _create_table(self, layout: QVBoxLayout): """테이블 생성""" self.table = QTableWidget() self.table.setObjectName("sectionTable") # 테이블 설정 self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setAlternatingRowColors(True) self.table.setShowGrid(False) self.table.setContextMenuPolicy(Qt.CustomContextMenu) # 행 높이 설정 (드래그로 조정 가능하게) self.table.verticalHeader().setVisible(True) # 행 헤더 표시 (드래그 조정용) self.table.verticalHeader().setDefaultSectionSize(40) self.table.verticalHeader().setMinimumSectionSize(36) self.table.verticalHeader().setSectionResizeMode(QHeaderView.Interactive) # 드래그로 조정 가능 self.table.verticalHeader().setSectionsMovable(False) # 행 이동 비활성화 # 편집 트리거 비활성화 (더블클릭 시 특정 필드만 편집 가능하게) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) # 현재 편집 중인 셀 정보 self._editing_cell = None # (row, col) 튜플 # 헤더 설정 header = self.table.horizontalHeader() header.setStretchLastSection(True) header.setSectionResizeMode(QHeaderView.Interactive) header.setContextMenuPolicy(Qt.CustomContextMenu) header.customContextMenuRequested.connect(self._show_header_context_menu) header.sectionEntered.connect(self._on_header_section_entered) header.sectionResized.connect(self._on_column_resized) # 시그널 연결 self.table.itemSelectionChanged.connect(self._on_selection_changed) self.table.itemDoubleClicked.connect(self._on_double_clicked) self.table.customContextMenuRequested.connect(self._show_context_menu) self.table.cellChanged.connect(self._on_cell_changed) layout.addWidget(self.table) self._apply_table_style() def _apply_table_style(self): """테이블 스타일 적용""" theme = self.config.theme if theme == 'dark': bg_color = "#0f172a" alt_bg = "#1e293b" header_bg = "#334155" text_color = "#f8fafc" selected_bg = "#3b82f6" border_color = "#334155" gridline_color = "#475569" # 다크 테마용 구분선 색상 else: bg_color = "#ffffff" alt_bg = "#f8fafc" header_bg = "#e2e8f0" text_color = "#1e293b" selected_bg = "#3b82f6" border_color = "#e2e8f0" gridline_color = "#cbd5e1" # 라이트 테마용 구분선 색상 self.table.setStyleSheet(f""" QTableWidget {{ background-color: {bg_color}; color: {text_color}; border: 1px solid {border_color}; border-radius: 8px; gridline-color: {gridline_color}; }} QTableWidget::item {{ padding: 8px; border-bottom: 1px solid {border_color}; border-right: 1px solid {gridline_color}; }} QTableWidget::item:selected {{ background-color: {selected_bg}; color: white; border-right: 1px solid {gridline_color}; }} QTableWidget::item:alternate {{ background-color: {alt_bg}; }} QHeaderView::section {{ background-color: {header_bg}; color: {text_color}; padding: 10px; border: none; border-bottom: 2px solid {border_color}; border-right: 1px solid {gridline_color}; font-weight: bold; }} QHeaderView::section:last {{ border-right: none; }} /* 위젯이 있는 셀에도 구분선 적용 */ QTableWidget QWidget {{ border-right: 1px solid {gridline_color}; }} """) def _connect_signals(self): """시그널 연결""" # 데이터 변경 시그널 self.signals.data_changed.connect(self._on_data_changed) # 팀 변경 시그널 self.signals.team_changed.connect(self._on_team_changed) # 필드 설정 변경 시그널 self.signals.data_changed.connect(self._on_field_settings_changed) # ======================================================================== # 데이터 관리 # ======================================================================== def refresh_data(self): """데이터 새로고침""" self.load_data() self.data_refreshed.emit() logger.debug(f"섹션 데이터 새로고침: {self.table_name}") def load_data(self, **filters): """ 데이터 로드 Args: **filters: 필터 조건 """ try: # 데이터 조회 (자식 클래스에서 구현) self.current_records = self._fetch_data(**filters) # 테이블 업데이트 self._update_table() except Exception as e: logger.error(f"데이터 로드 실패: {e}") self.show_error(f"데이터 로드 실패: {e}") def _fetch_data(self, **filters) -> List[BaseModel]: """ 데이터 조회 (자식 클래스에서 오버라이드) Returns: 모델 인스턴스 리스트 """ # 기본 구현: 전체 조회 _ = filters # 자식 클래스에서 사용 return [] def _update_table(self): """테이블 업데이트""" self.table.setRowCount(0) # 표시할 필드만 필터링 visible_fields = [f for f in self.fields if f.visible] # 컬럼 설정 self.table.setColumnCount(len(visible_fields)) # 헤더 아이템 설정 (툴팁 포함) for i, field in enumerate(visible_fields): # 헤더 아이템 생성 header_item = QTableWidgetItem(field.label) header_item.setToolTip(f"우클릭하여 '{field.label}' 필드를 숨기거나 표시할 수 있습니다.") self.table.setHorizontalHeaderItem(i, header_item) # 컬럼 너비 설정 self.table.setColumnWidth(i, field.width) # 데이터 채우기 (완료된 레코드는 제외) for record in self.current_records: # 완료된 레코드는 섹션에서 숨김 (DB에는 유지) if hasattr(record, 'is_completed') and record.is_completed: continue row = self.table.rowCount() self.table.insertRow(row) max_height = int(40 * 1.15) # 기본 높이 15% 증가 (46px) for col, field in enumerate(visible_fields): value = getattr(record, field.name, "") # 완료 필드는 버튼 위젯으로 표시 if field.name == "is_completed": self._set_completion_button(row, col, record) else: item = self._create_table_item(field, value, record) self.table.setItem(row, col, item) # 셀 내용에 따른 높이 계산 (특히 내용 필드) if item.text(): text = item.text() lines = text.count('\n') + 1 # 폰트 메트릭을 사용한 정확한 높이 계산 font_metrics = self.table.fontMetrics() text_width = font_metrics.horizontalAdvance(text) col_width = self.table.columnWidth(col) # 내용 필드인 경우 더 자세히 계산 is_content_field = field.name in ['fault_content', 'action_taken', 'instruction_content', 'work_details', 'report_content', 'content'] if is_content_field: # 내용 필드는 더 넓은 공간 필요 if col_width > 0: # 줄바꿈이 필요한 경우 chars_per_line = max(1, col_width // font_metrics.averageCharWidth()) lines = max(lines, (len(text) // chars_per_line) + 1) # 줄바꿈이 필요한 경우 elif text_width > col_width and col_width > 0: lines = max(lines, (text_width // col_width) + 1) # 각 줄당 높이 계산 (패딩 포함) line_height = font_metrics.height() + 16 # 폰트 높이 + 패딩 calculated_height = max(int(40 * 1.15), lines * line_height) # 최소 높이도 15% 증가 max_height = max(max_height, calculated_height) # 행 높이 설정 (내용에 맞게 자동 조정) self.table.setRowHeight(row, max_height) # 레코드 ID 저장 self.table.item(row, 0).setData(Qt.UserRole, record.id) def _create_table_item( self, field: FieldConfig, value: Any, record: BaseModel ) -> QTableWidgetItem: """ 테이블 아이템 생성 Args: field: 필드 설정 value: 필드 값 record: 레코드 (자식 클래스에서 사용 가능) Returns: QTableWidgetItem """ # 팀확인 필드는 특별 처리 if field.name == "team_confirmations": # 확인한 팀만 표시 try: confirmations = json.loads(value) if isinstance(value, str) else value if not isinstance(confirmations, dict): confirmations = {} confirmed_teams = [team for team, confirmed in confirmations.items() if confirmed] display_value = ", ".join(confirmed_teams) if confirmed_teams else "-" except (json.JSONDecodeError, TypeError): display_value = "-" else: # 값 포맷팅 display_value = self._format_value(field, value) item = QTableWidgetItem(display_value) item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter) # 왼쪽 정렬로 변경 (텍스트 가독성) # 텍스트 줄바꿈 활성화 (긴 텍스트 표시) item.setTextAlignment(item.textAlignment() | Qt.TextWordWrap) # 편집 불가능 설정 if not field.editable: item.setFlags(item.flags() & ~Qt.ItemIsEditable) # 레코드 정보 저장 (팀확인 위젯 생성 시 사용) if field.name == "team_confirmations": item.setData(Qt.UserRole + 1, record) # 레코드 저장 return item def _format_value(self, field: FieldConfig, value: Any) -> str: """ 값 포맷팅 Args: field: 필드 설정 value: 필드 값 Returns: 포맷된 문자열 """ if value is None: return "" if field.field_type == "date": if isinstance(value, date): # 표시형식에 따라 포맷 변경 if field.display_format == "short": # 26-01-04 return value.strftime("%y-%m-%d") elif field.display_format == "month_day": # 01-04 return value.strftime("%m-%d") else: # 기본값 또는 "full": 2026-01-04 return value.strftime("%Y-%m-%d") elif isinstance(value, str) and value: # 문자열인 경우 파싱 후 포맷 적용 try: from datetime import datetime parsed_date = datetime.strptime(value[:10], "%Y-%m-%d").date() if field.display_format == "short": # 26-01-04 return parsed_date.strftime("%y-%m-%d") elif field.display_format == "month_day": # 01-04 return parsed_date.strftime("%m-%d") else: # 기본값 또는 "full": 2026-01-04 return parsed_date.strftime("%Y-%m-%d") except Exception: return value[:10] # 파싱 실패 시 원본 반환 elif field.field_type == "time": if hasattr(value, 'strftime'): return value.strftime("%H:%M") elif isinstance(value, str) and value: return value[:5] # HH:MM elif field.field_type == "checkbox": return "✓" if value else "" elif field.name == "is_completed": # 완료 필드는 버튼으로 표시되므로 여기서는 빈 문자열 반환 return "" return str(value) # ======================================================================== # 이벤트 핸들러 # ======================================================================== def _on_selection_changed(self): """선택 변경 이벤트""" selected_items = self.table.selectedItems() if not selected_items: return row = selected_items[0].row() # 첫 번째 셀이 아이템인 경우 first_item = self.table.item(row, 0) if first_item: record_id = first_item.data(Qt.UserRole) if record_id: self.record_selected.emit(record_id) else: # 첫 번째 셀이 위젯인 경우 widget = self.table.cellWidget(row, 0) if widget: record_id = widget.property("record_id") if record_id: self.record_selected.emit(record_id) def _on_double_clicked(self, item: QTableWidgetItem): """더블클릭 이벤트 - 해당 필드만 편집 가능하게 변경""" row = item.row() col = item.column() # 레코드 ID 가져오기 record_id = None first_item = self.table.item(row, 0) if first_item: record_id = first_item.data(Qt.UserRole) else: widget = self.table.cellWidget(row, 0) if widget: record_id = widget.property("record_id") if not record_id: return # 현재 필드 정보 확인 visible_fields = [f for f in self.fields if f.visible] if col >= len(visible_fields): return field = visible_fields[col] # 레코드 찾기 record = None for r in self.current_records: if r.id == record_id: record = r break if not record: return # 팀확인 필드인 경우 다이얼로그 표시 if field.name == "team_confirmations": self._show_team_confirmation_dialog(record_id, record) return # 완료 필드인 경우 - 버튼이 있으면 버튼 클릭으로만 처리 # (더블클릭으로는 처리하지 않음) if field.name == "is_completed": return # 편집 불가능한 필드인 경우 다이얼로그로 전체 편집 if not field.editable: self.record_double_clicked.emit(record_id) self.on_edit_clicked(record_id) return # 편집 가능한 필드인 경우 해당 셀만 편집 모드로 전환 self._enable_cell_editing(row, col, field) def _show_context_menu(self, position: QPoint): """컨텍스트 메뉴 표시""" menu = QMenu(self) # 편집 edit_action = QAction("편집", self) edit_action.triggered.connect(lambda: self._context_edit()) menu.addAction(edit_action) # 삭제 delete_action = QAction("삭제", self) delete_action.triggered.connect(lambda: self._context_delete()) menu.addAction(delete_action) menu.exec(self.table.viewport().mapToGlobal(position)) def _context_edit(self): """컨텍스트 메뉴 - 편집""" selected = self.get_selected_record_id() if selected: self.on_edit_clicked(selected) def _context_delete(self): """컨텍스트 메뉴 - 삭제""" selected = self.get_selected_record_id() if selected: self.on_delete_clicked(selected) def _on_data_changed(self, table_name: str): """데이터 변경 이벤트""" if table_name == self.table_name: self.refresh_data() def _on_field_settings_changed(self, table_name: str): """필드 설정 변경 이벤트""" if table_name == "field_settings": # 필드 설정 다시 로드 self._load_field_settings() # 테이블 업데이트 self.refresh_data() def _on_team_changed(self, team: str): """팀 변경 이벤트""" self._current_team = team # 필드 설정 다시 로드 self._load_field_settings() # 테이블 업데이트 self.refresh_data() def on_search_changed(self, text: str): """검색어 변경 (자식 클래스에서 오버라이드)""" _ = text # 자식 클래스에서 사용 def _enable_cell_editing(self, row: int, col: int, field: 'FieldConfig'): """ 특정 셀을 편집 모드로 전환 Args: row: 행 인덱스 col: 열 인덱스 field: 필드 설정 """ # 이전 편집 중인 셀이 있으면 편집 해제 if self._editing_cell: prev_row, prev_col = self._editing_cell prev_item = self.table.item(prev_row, prev_col) if prev_item: prev_item.setFlags(prev_item.flags() & ~Qt.ItemIsEditable) # 해당 셀 편집 가능하게 설정 item = self.table.item(row, col) if item: item.setFlags(item.flags() | Qt.ItemIsEditable) self._editing_cell = (row, col) # 행 높이 증가 (편집 시 글자가 잘 보이도록) current_height = self.table.rowHeight(row) self.table.setRowHeight(row, max(current_height, 44)) # 편집 시작 self.table.editItem(item) logger.debug(f"셀 편집 모드 활성화: row={row}, col={col}, field={field.name}") def _on_cell_changed(self, row: int, col: int): """ 셀 값 변경 시 호출 Args: row: 행 인덱스 col: 열 인덱스 """ # 편집 중인 셀이 아니면 무시 if self._editing_cell != (row, col): return item = self.table.item(row, col) if not item: return # 레코드 ID 가져오기 record_id = None first_item = self.table.item(row, 0) if first_item: record_id = first_item.data(Qt.UserRole) else: widget = self.table.cellWidget(row, 0) if widget: record_id = widget.property("record_id") if not record_id: return # 필드 정보 가져오기 visible_fields = [f for f in self.fields if f.visible] if col >= len(visible_fields): return field = visible_fields[col] new_value = item.text() # 시그널 차단 (재귀 호출 방지) self.table.blockSignals(True) try: # 셀 편집 해제 item.setFlags(item.flags() & ~Qt.ItemIsEditable) self._editing_cell = None # 행 높이 원복 (15% 증가된 기본 높이) self.table.setRowHeight(row, int(40 * 1.15)) finally: # 시그널 차단 해제 self.table.blockSignals(False) # 값 업데이트 (자식 클래스에서 구현) self._update_field_value(record_id, field.name, new_value) logger.debug(f"셀 값 변경: record_id={record_id}, field={field.name}, value={new_value}") def _update_field_value(self, record_id: int, field_name: str, value: str): """ 필드 값 업데이트 (자식 클래스에서 오버라이드) Args: record_id: 레코드 ID field_name: 필드 이름 value: 새 값 """ # 기본 구현: 자식 클래스에서 오버라이드 _ = (record_id, field_name, value) # 자식 클래스에서 사용 # ======================================================================== # CRUD 액션 # ======================================================================== def on_add_clicked(self): """추가 버튼 클릭 (자식 클래스에서 오버라이드)""" logger.debug("추가 버튼 클릭") def on_edit_clicked(self, record_id: int): """편집 버튼 클릭 (자식 클래스에서 오버라이드)""" logger.debug(f"편집: {record_id}") def on_delete_clicked(self, record_id: int): """삭제 버튼 클릭 (자식 클래스에서 오버라이드)""" from .base_dialog import ConfirmDialog if ConfirmDialog.ask(self, "삭제 확인", "정말 삭제하시겠습니까?"): self._delete_record(record_id) def _delete_record(self, record_id: int): """레코드 삭제""" # 자식 클래스에서 구현 pass # ======================================================================== # 유틸리티 # ======================================================================== def get_selected_record_id(self) -> Optional[int]: """선택된 레코드 ID 반환""" selected_items = self.table.selectedItems() if not selected_items: return None row = selected_items[0].row() # 첫 번째 셀이 아이템인 경우 first_item = self.table.item(row, 0) if first_item: return first_item.data(Qt.UserRole) # 첫 번째 셀이 위젯인 경우 widget = self.table.cellWidget(row, 0) if widget: return widget.property("record_id") return None def get_selected_record(self) -> Optional[BaseModel]: """선택된 레코드 반환""" record_id = self.get_selected_record_id() if record_id: for record in self.current_records: if record.id == record_id: return record return None def save_field_settings(self): """현재 필드 설정(표시여부, 너비, 표시형식)을 저장""" from core.config import FieldSetting if not self._section_name: return try: field_settings = [] visible_fields = [f for f in self.fields if f.visible] for field in self.fields: # 현재 테이블에서 너비 가져오기 (표시된 필드만) width = field.width if field.visible: try: col_index = visible_fields.index(field) width = self.table.columnWidth(col_index) except (ValueError, IndexError): pass field_setting = FieldSetting( name=field.name, visible=field.visible, width=width, display_format=field.display_format ) field_settings.append(field_setting) # 설정 저장 self.config.save_field_settings( self._current_team, self._section_name, field_settings ) # 필드 객체에도 너비 업데이트 for field in self.fields: if field.visible: try: col_index = visible_fields.index(field) field.width = self.table.columnWidth(col_index) except (ValueError, IndexError): pass logger.info(f"필드 설정 저장 완료: {self._section_name} ({self._current_team})") self.signals.status_message.emit(f"'{self._section_name}' 필드 설정이 저장되었습니다.", 3000) except Exception as e: logger.error(f"필드 설정 저장 실패: {e}") self.show_error(f"필드 설정 저장 실패: {e}") def set_field_visible(self, field_name: str, visible: bool): """필드 표시/숨김 설정""" for field in self.fields: if field.name == field_name: field.visible = visible break self._update_table() def set_field_display_format(self, field_name: str, display_format: str): """필드 표시형식 설정""" for field in self.fields: if field.name == field_name: field.display_format = display_format break self._update_table() def _on_column_resized(self, logical_index: int, old_size: int, new_size: int): """컬럼 너비 변경 시 호출""" # 너비가 실제로 변경된 경우에만 처리 if old_size == new_size: return visible_fields = [f for f in self.fields if f.visible] if logical_index < len(visible_fields): field = visible_fields[logical_index] field.width = new_size logger.debug(f"컬럼 너비 변경: {field.label} = {new_size}px") def _on_header_section_entered(self, logical_index: int): """헤더 섹션 호버 시 툴팁 표시""" visible_fields = [f for f in self.fields if f.visible] if logical_index < len(visible_fields): field = visible_fields[logical_index] header_item = self.table.horizontalHeaderItem(logical_index) if header_item: header_item.setToolTip(f"우클릭하여 '{field.label}' 필드를 숨기거나 표시할 수 있습니다.") def _show_header_context_menu(self, position): """헤더 우클릭 시 컨텍스트 메뉴 표시""" header = self.table.horizontalHeader() col = header.logicalIndexAt(position) if col < 0: return # 표시된 필드 목록 visible_fields = [f for f in self.fields if f.visible] if col >= len(visible_fields): return field = visible_fields[col] # 숨긴 필드 목록 hidden_fields = [f for f in self.fields if not f.visible] # 컨텍스트 메뉴 생성 menu = QMenu(self) # 현재 필드 숨기기 hide_action = QAction(f"{field.label} 숨기기", self) hide_action.triggered.connect(lambda: self.set_field_visible(field.name, False)) menu.addAction(hide_action) # 표시형식 설정 (발생일 필드만) if field.field_type == "date" and field.name == "occurrence_date": menu.addSeparator() format_submenu = QMenu("표시형식", self) # 전체 날짜: 2026-01-04 full_action = QAction("2026-01-04", self) full_action.setCheckable(True) full_action.setChecked(field.display_format is None or field.display_format == "full") full_action.triggered.connect( lambda: self.set_field_display_format(field.name, "full") ) format_submenu.addAction(full_action) # 짧은 형식: 26-01-04 short_action = QAction("26-01-04", self) short_action.setCheckable(True) short_action.setChecked(field.display_format == "short") short_action.triggered.connect( lambda: self.set_field_display_format(field.name, "short") ) format_submenu.addAction(short_action) # 월-일만: 01-04 month_day_action = QAction("01-04(일)", self) month_day_action.setCheckable(True) month_day_action.setChecked(field.display_format == "month_day") month_day_action.triggered.connect( lambda: self.set_field_display_format(field.name, "month_day") ) format_submenu.addAction(month_day_action) menu.addMenu(format_submenu) # 숨긴 필드 보기 (하위 메뉴) if hidden_fields: menu.addSeparator() show_submenu = QMenu("숨긴 필드 보기", self) for hidden_field in hidden_fields: show_action = QAction(hidden_field.label, self) show_action.triggered.connect( lambda checked=False, name=hidden_field.name: self.set_field_visible(name, True) ) show_submenu.addAction(show_action) menu.addMenu(show_submenu) # 메뉴 표시 global_pos = header.mapToGlobal(position) menu.exec(global_pos) def show_history_dialog(self): """기록보기 다이얼로그 표시""" from ui.dialogs.history_dialog import HistoryDialog dialog = HistoryDialog(self, self.table_name, self.model_class) dialog.exec() def _show_team_confirmation_dialog(self, record_id: int, record: BaseModel): """팀확인 다이얼로그 표시""" from ui.dialogs.team_confirmation_dialog import TeamConfirmationDialog dialog = TeamConfirmationDialog(self, record) if dialog.exec() == 1: # QDialog.Accepted confirmations = dialog.get_confirmations() # DB 업데이트 self._update_team_confirmations(record_id, confirmations) self.refresh_data() def _update_team_confirmations(self, record_id: int, confirmations: dict): """팀확인 상태 업데이트""" # 자식 클래스에서 구현 pass def _handle_completion(self, record_id: int, record: BaseModel): """완료 처리""" # 모든 팀이 확인되었는지 확인 if hasattr(record, 'all_teams_confirmed'): if not record.all_teams_confirmed(): self.show_error("모든 팀이 확인해야 완료할 수 있습니다.") return # 완료 처리 self._mark_as_completed(record_id) self.refresh_data() def _mark_as_completed(self, record_id: int): """레코드를 완료로 표시""" # 자식 클래스에서 구현 pass def _set_completion_button(self, row: int, col: int, record: BaseModel): """완료 라벨 설정 (ClickableLabel 사용, 공간 효율 최대화)""" from ui.widgets.clickableLabel import ClickableLabel from PySide6.QtWidgets import QWidget, QHBoxLayout, QToolTip from core.constants import TEAMS import json # 완료 가능 조건 체크 및 비활성화 이유 수집 incomplete_reasons = [] # 1. 모든 팀이 확인되었는지 확인 all_confirmed = False confirmed_count = 0 if hasattr(record, 'team_confirmations'): try: confirmations = json.loads(record.team_confirmations) if isinstance(record.team_confirmations, str) else record.team_confirmations if isinstance(confirmations, dict): confirmed_count = sum(1 for team in TEAMS if confirmations.get(team, False)) all_confirmed = confirmed_count == len(TEAMS) except (json.JSONDecodeError, TypeError): pass if not all_confirmed: not_confirmed = len(TEAMS) - confirmed_count incomplete_reasons.append(f"모든 팀이 확인하지 않았습니다. ({not_confirmed}팀 미확인)") # 2. 조치팀이 설정되어 있는지 확인 (고장 섹션만) has_action_team = True if hasattr(record, 'action_team'): action_team = getattr(record, 'action_team', None) has_action_team = bool(action_team and str(action_team).strip()) if not has_action_team: incomplete_reasons.append("조치팀이 설정되지 않았습니다.") # 3. 조치내용이 있는지 확인 (고장 섹션만) has_action_content = True if hasattr(record, 'action_content'): action_content = getattr(record, 'action_content', None) has_action_content = bool(action_content and str(action_content).strip()) if not has_action_content: incomplete_reasons.append("조치내용이 입력되지 않았습니다.") # 완료 가능 여부 판단 can_complete = all_confirmed and has_action_team and has_action_content theme = self.config.theme unified_color = "#64748b" # 통일된 회색 계열 # ClickableLabel 생성 - "✓" 또는 "-" 표시 display_text = "✓" if can_complete else "-" label = ClickableLabel(display_text, enable_hover=True) label.setAlignment(Qt.AlignCenter) # 색상 설정 if can_complete: bg_color = "#22c55e" # 초록색 (완료 가능) text_color = "#ffffff" else: bg_color = unified_color text_color = "#94a3b8" if theme == 'dark' else "#cbd5e1" # 통일된 스타일 적용 (다른 필드와 동일하게) if theme == 'dark': label.setStyleSheet(f""" QLabel {{ background-color: {bg_color}; color: {text_color}; border: 1px solid rgba(255,255,255,0.2); border-radius: 0px; padding: 0px; margin: 0px; font-weight: 600; font-size: 14px; }} QLabel:hover {{ background-color: {bg_color}; border-color: rgba(255,255,255,0.4); }} """) else: label.setStyleSheet(f""" QLabel {{ background-color: {bg_color}; color: {text_color}; border: 1px solid rgba(0,0,0,0.1); border-radius: 0px; padding: 0px; margin: 0px; font-weight: 600; font-size: 14px; }} QLabel:hover {{ background-color: {bg_color}; border-color: rgba(0,0,0,0.2); }} """) # 툴팁 설정 (비활성화 이유 표시) if incomplete_reasons: # 번호를 매겨 이유 표시 tooltip_lines = ["[완료 불가 사유]"] for i, reason in enumerate(incomplete_reasons, 1): tooltip_lines.append(f"{i}. {reason}") label.setToolTip("\n".join(tooltip_lines)) else: label.setToolTip("클릭하여 완료 처리") # 클릭 이벤트 연결 if can_complete: label.clicked.connect(lambda r=record: self._handle_completion(r.id, r)) else: # 비활성화 상태에서 클릭 시 툴팁 표시 def show_incomplete_popup(): from PySide6.QtCore import QPoint from PySide6.QtGui import QCursor QToolTip.showText(QCursor.pos(), label.toolTip(), label) label.clicked.connect(show_incomplete_popup) # 레코드 정보 저장 label.setProperty("record_id", record.id) # 컨테이너 위젯으로 감싸기 (여백 없이) container = QWidget() container_layout = QHBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) container_layout.setSpacing(0) container_layout.addWidget(label) # 구분선 스타일 적용 gridline_color = "#475569" if theme == 'dark' else "#cbd5e1" container.setStyleSheet(f"border-right: 1px solid {gridline_color};") # 셀에 위젯 설정 self.table.setCellWidget(row, col, container)