handOver2/ui/base/base_section.py

1264 lines
47 KiB
Python

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