1264 lines
47 KiB
Python
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)
|
|
|
|
|