handOver2/ui/sections/fault_section.py

3118 lines
122 KiB
Python

# -*- coding: utf-8 -*-
"""
고장 섹션 모듈
전동차 고장 정보를 관리하는 섹션입니다.
"""
from datetime import date, time, datetime
from typing import List, Optional, Any
from PySide6.QtWidgets import QDialog, QPushButton, QLabel, QWidget
from PySide6.QtCore import Qt, QPoint
from ui.base.base_section import BaseSection, FieldConfig
from ui.dialogs.input_dialog import SectionInputDialog
from ui.widgets.clickableLabel import ClickableLabel
from ui.components.popup_widget import PopupWidget
from database.models import Fault
from database.db_manager import DatabaseManager
from core.constants import DEVICE_CATEGORIES, FAULT_SOURCES
from core.logger import get_logger
logger = get_logger(__name__)
class FaultSection(BaseSection):
"""
고장 섹션
전동차 고장 정보를 표시하고 관리합니다.
필드:
- 발생일자, 편성, 호차, 고장코드, 장치분류, 발생역, 발생시간,
고장내용, 조치내용, 조치팀, 팀확인, 완료
"""
def __init__(self, parent=None):
super().__init__(parent, "faults", Fault)
# 필드 설정
self._setup_fields()
# 초기화 후 처리 (설정 로드)
self._post_init()
# 팝업 추적용
self._current_popup: Optional[PopupWidget] = None
self._current_team_popup: Optional[PopupWidget] = None # 조치팀/확인팀 팝업 추적
# 초기 데이터 로드
self.load_data()
logger.info("고장 섹션 초기화 완료")
def _setup_fields(self):
"""필드 설정"""
self.fields = [
FieldConfig("occurrence_date", "발생일", width=70, field_type="date"),
FieldConfig("column_number", "열번", width=60),
FieldConfig("train_number", "편성", width=50),
FieldConfig("car_number", "호차", width=50),
FieldConfig("fault_code", "고장코드", width=70),
FieldConfig("device_category", "장치분류", width=110),
FieldConfig("occurrence_station", "발생역", width=90),
FieldConfig("occurrence_time", "발생시간", width=90, field_type="time"),
FieldConfig("fault_content", "고장내용", width=300),
FieldConfig("action_content", "조치내용", width=300),
FieldConfig("fault_source", "고장출처", width=100),
FieldConfig("attachments", "자료", width=50), # 관련자료
FieldConfig("action_team", "조치팀", width=90),
FieldConfig("team_confirmations", "확인팀", width=110),
FieldConfig("is_completed", "완료", width=40, field_type="checkbox"),
]
def _fetch_data(self, **filters) -> List[Fault]:
"""데이터 조회"""
return self.crud.get_all_faults()
def on_add_clicked(self):
"""추가 버튼 클릭"""
dialog = FaultInputDialog(self)
if dialog.exec() == QDialog.Accepted:
data = dialog.get_data()
fault = self.crud.create_fault(**data)
# 조치 단계 저장
if fault and fault.id and hasattr(dialog, '_save_action_steps'):
dialog._save_action_steps(fault.id)
self.refresh_data()
def on_edit_clicked(self, record_id: int):
"""편집 버튼 클릭"""
record = self.crud.get_fault(record_id)
if record:
dialog = FaultInputDialog(self, record)
if dialog.exec() == QDialog.Accepted:
data = dialog.get_data()
self.crud.update_fault(record_id, **data)
# 조치 단계 저장
if hasattr(dialog, '_save_action_steps'):
dialog._save_action_steps(record_id)
self.refresh_data()
def _delete_record(self, record_id: int):
"""레코드 삭제"""
self.crud.delete_fault(record_id)
self.refresh_data()
def on_search_changed(self, text: str):
"""검색어 변경"""
if text:
results = self.crud.search_faults(text)
self.current_records = results
self._update_table()
else:
self.load_data()
def _update_team_confirmations(self, record_id: int, confirmations: dict):
"""팀확인 상태 업데이트"""
import json
self.crud.update_fault(record_id, team_confirmations=json.dumps(confirmations, ensure_ascii=False))
self.signals.data_changed.emit(self.table_name)
def _mark_as_completed(self, record_id: int):
"""레코드를 완료로 표시"""
from datetime import datetime
self.crud.update_fault(record_id, is_completed=True, completed_at=datetime.now())
self.signals.data_changed.emit(self.table_name)
def _on_double_clicked(self, item):
"""더블클릭 이벤트 오버라이드"""
row = item.row()
col = item.column()
# 위젯인 경우 (ClickableLabel 등)
widget = self.table.cellWidget(row, col)
if widget:
record_id = widget.property("record_id")
if not record_id:
# 첫 번째 셀에서 레코드 ID 가져오기
first_item = self.table.item(row, 0)
if first_item:
record_id = first_item.data(Qt.UserRole)
if record_id:
visible_fields = [f for f in self.fields if f.visible]
if col < len(visible_fields):
field = visible_fields[col]
record = self.crud.get_fault(record_id)
if record:
self._edit_field_inline(row, col, field, record)
return
# 일반 아이템인 경우 부모 클래스 처리
super()._on_double_clicked(item)
def _edit_field_inline(self, row: int, col: int, field: FieldConfig, record: Fault):
"""인라인 필드 편집"""
if field.name == "train_number":
self._edit_train_number(row, col, record)
elif field.name == "column_number":
self._edit_column_number(row, col, record)
elif field.name == "device_category":
self._edit_device_category(row, col, record)
elif field.name == "fault_code":
self._edit_fault_code(row, col, record)
elif field.name == "occurrence_station":
self._edit_occurrence_station(row, col, record)
elif field.name == "car_number":
self._edit_car_number(row, col, record)
elif field.name == "occurrence_date":
self._edit_occurrence_date(row, col, record)
elif field.name == "occurrence_time":
self._edit_occurrence_time(row, col, record)
elif field.name == "fault_source":
self._edit_fault_source(row, col, record)
elif field.name == "action_team":
self._edit_action_team(row, col, record)
elif field.name == "team_confirmations":
self._edit_team_confirmations(row, col, record)
elif field.name == "fault_content":
self._edit_fault_content(row, col, record)
elif field.name == "action_content":
self._edit_action_content(row, col, record)
else:
# 기본 편집 모드
self._enable_cell_editing(row, col, field)
def _edit_train_number(self, row: int, col: int, record: Fault):
"""편성번호 편집"""
from ui.dialogs.train_input_dialog import TrainInputDialog
dialog = TrainInputDialog(self, "", 0, date.today())
dialog.sinpyeong_toggle.set_state(True)
# 현재 편성번호 설정
if record.train_number:
# 편성번호에서 숫자 추출 (예: "151A" -> 51)
try:
train_num_str = record.train_number.replace("1", "").rstrip("AB")
train_num = int(train_num_str)
dialog._selected_train = train_num # type: ignore
dialog._selected_train_display = record.train_number # type: ignore
except Exception:
pass
if dialog.exec() == QDialog.Accepted:
data = dialog.get_data()
train_number = data.get("train_number", "")
if train_number:
self.crud.update_fault(record.id, train_number=train_number)
self.refresh_data()
def _edit_device_category(self, row: int, col: int, record: Fault):
"""장치분류 편집"""
from ui.components.custom_input import CustomComboBox
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox
dialog = QDialog(self)
dialog.setWindowTitle("장치분류 선택")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
combo = CustomComboBox(items=DEVICE_CATEGORIES, placeholder="장치분류 선택")
if record.device_category:
combo.set_selected_value(record.device_category)
layout.addWidget(combo)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
device_category = combo.get_selected_value() or ""
self.crud.update_fault(record.id, device_category=device_category)
self.refresh_data()
def _edit_fault_code(self, row: int, col: int, record: Fault):
"""고장코드 편집"""
from ui.components.custom_input import CustomLineEdit
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox
dialog = QDialog(self)
dialog.setWindowTitle("고장코드 입력")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
input_field = CustomLineEdit(placeholder="고장코드")
if record.fault_code:
input_field.setText(record.fault_code)
layout.addWidget(input_field)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
fault_code = input_field.text()
self.crud.update_fault(record.id, fault_code=fault_code)
self.refresh_data()
def _edit_occurrence_station(self, row: int, col: int, record: Fault):
"""발생역 편집"""
from ui.components.custom_input import CustomLineEdit
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox
dialog = QDialog(self)
dialog.setWindowTitle("발생역 입력")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
input_field = CustomLineEdit(placeholder="발생역")
if record.occurrence_station:
input_field.setText(record.occurrence_station)
layout.addWidget(input_field)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
occurrence_station = input_field.text()
self.crud.update_fault(record.id, occurrence_station=occurrence_station)
self.refresh_data()
def _edit_column_number(self, row: int, col: int, record: Fault):
"""열번 편집 (4개 스핀박스: 1000자리, 100자리, 10자리, 1자리)"""
from ui.components.popup_widget import PopupWidget
from PySide6.QtWidgets import QSpinBox, QHBoxLayout, QWidget, QLabel, QPushButton
# 기존 팝업이 있으면 닫기
if self._current_team_popup:
self._current_team_popup.hide_popup()
self._current_team_popup = None
# 현재 열번 파싱 (4자리 숫자)
current_column = record.column_number or ""
d1000, d100, d10, d1 = 1, 0, 0, 1 # 기본값
if current_column and len(current_column) == 4 and current_column.isdigit():
d1000 = int(current_column[0])
d100 = int(current_column[1])
d10 = int(current_column[2])
d1 = int(current_column[3])
# 팝업 생성
popup = PopupWidget(self, title="열번 선택", width=280, auto_hide=False)
self._current_team_popup = popup
# 스핀박스 컨테이너
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
theme = self.config.theme
spinbox_style_dark = """
QSpinBox {
background-color: #334155;
color: #f8fafc;
border: 1px solid #475569;
border-radius: 4px;
padding: 4px;
font-size: 14px;
font-weight: 600;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #475569;
border: none;
width: 14px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #64748b;
}
"""
spinbox_style_light = """
QSpinBox {
background-color: #f8fafc;
color: #1e293b;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 4px;
font-size: 14px;
font-weight: 600;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #e2e8f0;
border: none;
width: 14px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #cbd5e1;
}
"""
spinbox_style = spinbox_style_dark if theme == 'dark' else spinbox_style_light
# 1000자리 스핀박스 (1~9: 정기, 회송, 시운전, 구간, 임시열차)
spin_1000 = QSpinBox()
spin_1000.setRange(1, 9)
spin_1000.setValue(d1000)
spin_1000.setFixedWidth(45)
spin_1000.setStyleSheet(spinbox_style)
spin_1000.setToolTip(
"[1호선] 열차종별\n"
"정기: 1(상), 2(하)\n"
"회송: 3(상), 4(하)\n"
"시운전: 5(상), 6(하)\n"
"구간: 7(상), 8(하)\n"
"임시: 9"
)
# 100자리 스핀박스 (0~8)
spin_100 = QSpinBox()
spin_100.setRange(0, 8)
spin_100.setValue(d100)
spin_100.setFixedWidth(45)
spin_100.setStyleSheet(spinbox_style)
spin_100.setToolTip("[1호선] 100자리")
# 10자리 스핀박스 (0~9)
spin_10 = QSpinBox()
spin_10.setRange(0, 9)
spin_10.setValue(d10)
spin_10.setFixedWidth(45)
spin_10.setStyleSheet(spinbox_style)
spin_10.setToolTip("[1호선] 10자리")
# 1자리 스핀박스 (0~9)
spin_1 = QSpinBox()
spin_1.setRange(0, 9)
spin_1.setValue(d1)
spin_1.setFixedWidth(45)
spin_1.setStyleSheet(spinbox_style)
spin_1.setToolTip(
"[1호선] 1자리\n"
"홀수: 상행\n"
"짝수: 하행"
)
layout.addWidget(spin_1000)
layout.addWidget(spin_100)
layout.addWidget(spin_10)
layout.addWidget(spin_1)
popup.content_layout.addWidget(container)
# 확인 버튼
btn_container = QWidget()
btn_layout = QHBoxLayout(btn_container)
btn_layout.setContentsMargins(0, 8, 0, 0)
btn_layout.setSpacing(8)
confirm_btn = QPushButton("확인")
confirm_btn.setCursor(Qt.PointingHandCursor)
confirm_btn.setFixedWidth(60)
if theme == 'dark':
confirm_btn.setStyleSheet("""
QPushButton {
background-color: #2979ff;
color: #ffffff;
border: none;
border-radius: 4px;
padding: 6px 12px;
font-weight: 600;
}
QPushButton:hover {
background-color: #1e40af;
}
""")
else:
confirm_btn.setStyleSheet("""
QPushButton {
background-color: #2979ff;
color: #ffffff;
border: none;
border-radius: 4px;
padding: 6px 12px;
font-weight: 600;
}
QPushButton:hover {
background-color: #1e40af;
}
""")
def on_confirm():
# 4자리 열번 생성
column_number = f"{spin_1000.value()}{spin_100.value()}{spin_10.value()}{spin_1.value()}"
self.crud.update_fault(record.id, column_number=column_number)
self.refresh_data()
popup.hide_popup()
self._current_team_popup = None
confirm_btn.clicked.connect(on_confirm)
btn_layout.addStretch()
btn_layout.addWidget(confirm_btn)
popup.content_layout.addWidget(btn_container)
# 팝업이 마우스 밖으로 나가면 닫기
original_leave_event = popup.leaveEvent
def on_popup_leave(event):
if self._current_team_popup == popup:
popup.hide_popup()
self._current_team_popup = None
original_leave_event(event)
popup.leaveEvent = on_popup_leave
# 마우스 추적 활성화
popup.setMouseTracking(True)
popup.container.setMouseTracking(True)
# 셀 위치 기준으로 팝업 위치 계산
widget = self.table.cellWidget(row, col)
if widget:
widget_pos = widget.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(widget_pos.x(), widget_pos.y() + widget.height() + 5)
else:
rect = self.table.visualItemRect(self.table.item(row, col))
table_pos = self.table.viewport().mapToGlobal(rect.topLeft())
popup_pos = QPoint(table_pos.x(), table_pos.y() + rect.height() + 5)
popup.show_at(popup_pos)
def _edit_car_number(self, row: int, col: int, record: Fault):
"""호차 편집 (팝업 스핀박스)"""
from ui.components.popup_widget import PopupWidget
from PySide6.QtWidgets import QSpinBox, QHBoxLayout, QWidget, QLabel
# 기존 팝업이 있으면 닫기
if self._current_team_popup:
self._current_team_popup.hide_popup()
self._current_team_popup = None
current_car = int(record.car_number) if record.car_number else 1
# 팝업 생성
popup = PopupWidget(self, title="호차 선택", width=120, auto_hide=False)
self._current_team_popup = popup
# 스핀박스 컨테이너
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
# 스핀박스 생성 (1~8)
spinbox = QSpinBox()
spinbox.setRange(1, 8)
spinbox.setValue(current_car)
spinbox.setFixedWidth(60)
theme = self.config.theme
if theme == 'dark':
spinbox.setStyleSheet("""
QSpinBox {
background-color: #334155;
color: #f8fafc;
border: 1px solid #475569;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #475569;
border: none;
width: 16px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #64748b;
}
""")
else:
spinbox.setStyleSheet("""
QSpinBox {
background-color: #f8fafc;
color: #1e293b;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 4px 8px;
font-size: 14px;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #e2e8f0;
border: none;
width: 16px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background_color: #cbd5e1;
}
""")
# 값 변경 시 즉시 저장 (팝업은 닫지 않음)
def on_value_changed(value: int):
self.crud.update_fault(record.id, car_number=value)
# refresh_data()를 호출하면 테이블이 다시 그려지면서 팝업이 닫힐 수 있음
# 대신 시그널만 발생시켜 다른 곳에서 필요시 반영하도록 함
self.signals.data_changed.emit(self.table_name)
spinbox.valueChanged.connect(on_value_changed)
label = QLabel("호차")
if theme == 'dark':
label.setStyleSheet("color: #94a3b8;")
else:
label.setStyleSheet("color: #64748b;")
layout.addWidget(spinbox)
layout.addWidget(label)
layout.addStretch()
popup.content_layout.addWidget(container)
# 팝업이 마우스 밖으로 나가면 닫기
original_leave_event = popup.leaveEvent
def on_popup_leave(event):
if self._current_team_popup == popup:
popup.hide_popup()
self._current_team_popup = None
# 팝업 닫힐 때 테이블 새로고침
self.refresh_data()
original_leave_event(event)
popup.leaveEvent = on_popup_leave
# 마우스 추적 활성화
popup.setMouseTracking(True)
popup.container.setMouseTracking(True)
# 셀 위치 기준으로 팝업 위치 계산
widget = self.table.cellWidget(row, col)
if widget:
widget_pos = widget.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(widget_pos.x(), widget_pos.y() + widget.height() + 5)
else:
rect = self.table.visualItemRect(self.table.item(row, col))
table_pos = self.table.viewport().mapToGlobal(rect.topLeft())
popup_pos = QPoint(table_pos.x(), table_pos.y() + rect.height() + 5)
popup.show_at(popup_pos)
def _edit_occurrence_date(self, row: int, col: int, record: Fault):
"""발생일 편집 (캘린더 팝업)"""
from ui.components.custom_calendar import CustomCalendar
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox
from PySide6.QtCore import QDate
dialog = QDialog(self)
dialog.setWindowTitle("발생일 선택")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
# 캘린더 위젯 생성
calendar = CustomCalendar(show_range_toggle=False, show_time=False)
if record.occurrence_date:
qdate = QDate.fromString(record.occurrence_date.isoformat(), Qt.ISODate)
calendar.calendar.setSelectedDate(qdate)
layout.addWidget(calendar)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
selected_date = calendar.get_selected_date()
if selected_date:
self.crud.update_fault(record.id, occurrence_date=selected_date)
self.refresh_data()
def _edit_occurrence_time(self, row: int, col: int, record: Fault):
"""발생시간 편집 (시간 피커 팝업)"""
from ui.components.custom_calendar import TimeSelector
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox, QLabel
from datetime import time as dt_time
dialog = QDialog(self)
dialog.setWindowTitle("발생시간 선택")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
# 시간 선택 위젯 생성 (1분 단위로 스크롤 가능)
time_selector = TimeSelector(minute_step=1)
if record.occurrence_time:
if isinstance(record.occurrence_time, dt_time):
time_selector.set_time(record.occurrence_time)
elif isinstance(record.occurrence_time, str):
try:
t = dt_time.fromisoformat(record.occurrence_time)
time_selector.set_time(t)
except Exception:
pass
layout.addWidget(time_selector)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
selected_time = time_selector.get_time()
self.crud.update_fault(record.id, occurrence_time=selected_time)
self.refresh_data()
def _edit_fault_source(self, row: int, col: int, record: Fault):
"""고장출처 편집"""
# ClickableLabel에서 클릭 이벤트로 처리
widget = self.table.cellWidget(row, col)
if widget:
label = widget.findChild(ClickableLabel)
if label:
self._show_fault_source_popup(record, label)
def _edit_action_team(self, row: int, col: int, record: Fault):
"""조치팀 편집"""
# ClickableLabel에서 클릭 이벤트로 처리
widget = self.table.cellWidget(row, col)
if widget:
label = widget.findChild(ClickableLabel)
if label:
self._show_action_team_popup(record, label)
def _edit_team_confirmations(self, row: int, col: int, record: Fault):
"""확인팀 편집 (다중선택)"""
# ClickableLabel에서 클릭 이벤트로 처리
widget = self.table.cellWidget(row, col)
if widget:
label = widget.findChild(ClickableLabel)
if label:
self._show_team_confirmations_popup(record, label)
def _edit_fault_content(self, row: int, col: int, record: Fault):
"""고장내용 편집"""
from ui.components.custom_input import CustomTextEdit, LabeledInput
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox
dialog = QDialog(self)
dialog.setWindowTitle("고장내용 편집")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
# 텍스트 입력 필드
text_input = CustomTextEdit(placeholder="고장 내용을 입력하세요", min_height=150)
text_input.set_text(record.fault_content or "")
layout.addWidget(LabeledInput("고장내용", text_input, required=True))
# 버튼
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
new_content = text_input.get_text()
self.crud.update_fault(record.id, fault_content=new_content)
self.refresh_data()
def _edit_action_content(self, row: int, col: int, record: Fault):
"""조치내용 편집"""
from ui.components.custom_input import CustomTextEdit, LabeledInput
from PySide6.QtWidgets import QDialog, QVBoxLayout, QDialogButtonBox
dialog = QDialog(self)
dialog.setWindowTitle("조치내용 편집")
dialog.setModal(True)
layout = QVBoxLayout(dialog)
# 텍스트 입력 필드
text_input = CustomTextEdit(placeholder="조치 내용을 입력하세요", min_height=150)
text_input.set_text(record.action_content or "")
layout.addWidget(LabeledInput("조치내용", text_input))
# 버튼
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.Accepted:
new_content = text_input.get_text()
self.crud.update_fault(record.id, action_content=new_content)
self.refresh_data()
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))
self.table.setHorizontalHeaderLabels([f.label for f in visible_fields])
# 컬럼 너비 설정
for i, field in enumerate(visible_fields):
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)
# 관련자료 필드는 ClickableLabel로 표시
elif field.name == "attachments":
self._set_attachment_label(row, col, record)
# 편성, 열번, 장치분류, 고장코드, 발생역, 호차, 발생일, 발생시간, 고장출처는 ClickableLabel로 표시
elif field.name in ["train_number", "column_number", "device_category", "fault_code", "occurrence_station", "car_number", "occurrence_date", "occurrence_time", "fault_source"]:
self._set_clickable_label(row, col, field, value, record)
# 조치팀, 확인팀은 ClickableLabel로 표시
elif field.name in ["action_team", "team_confirmations"]:
self._set_team_label(row, col, field, value, record)
# 고장내용은 줄바꿈이 가능한 위젯으로 표시
elif field.name == "fault_content":
content_height = self._set_content_label(row, col, field, value, record)
if content_height:
max_height = max(max_height, content_height)
# 조치내용은 조치 단계 요약으로 표시
elif field.name == "action_content":
content_height = self._set_action_content_label(row, col, field, record)
if content_height:
max_height = max(max_height, content_height)
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 ['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)
max_height = max(max_height, calculated_height)
# 행 높이 설정
self.table.setRowHeight(row, max_height)
# 레코드 ID 저장 (첫 번째 셀에)
first_item = self.table.item(row, 0)
if first_item:
first_item.setData(Qt.UserRole, record.id)
else:
# 첫 번째 셀이 위젯인 경우 (완료 버튼 등)
widget = self.table.cellWidget(row, 0)
if widget:
widget.setProperty("record_id", record.id)
def _set_clickable_label(self, row: int, col: int, field: FieldConfig, value: Any, record: Fault):
"""ClickableLabel 설정"""
from PySide6.QtWidgets import QWidget, QHBoxLayout
# 편성번호는 중간 2글자만 표시 (예: 132B -> 32)
if field.name == "train_number" and value:
import re
train_str = str(value).strip()
# 숫자 부분에서 마지막 2자리 추출 (예: 132B -> 32, 101A -> 01)
match = re.match(r'^\d*(\d{2})[AB]?$', train_str)
if match:
display_value = match.group(1)
else:
# 다른 형식인 경우 원본 표시
display_value = train_str
else:
display_value = self._format_value(field, value) if value else ""
# ClickableLabel 생성
label = ClickableLabel(display_value or "미지정", enable_hover=True)
label.setAlignment(Qt.AlignCenter) # 중앙 정렬
label.setWordWrap(True) # 텍스트 잘림 방지 (줄바꿈 허용)
# 통일된 색상 적용 (알록달록하지 않게)
theme = self.config.theme
# 모든 필드에 동일한 색상 사용
unified_color = "#64748b" # 통일된 회색 계열
if theme == 'dark':
label.setStyleSheet(f"""
QLabel {{
background-color: {unified_color};
color: #ffffff;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 0px;
padding: 0px;
margin: 0px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {unified_color};
border-color: rgba(255,255,255,0.4);
}}
""")
else:
label.setStyleSheet(f"""
QLabel {{
background-color: {unified_color};
color: #ffffff;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 0px;
padding: 0px;
margin: 0px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {unified_color};
border-color: rgba(0,0,0,0.2);
}}
""")
# 호버 시 팝업 표시 (편성, 장치분류, 고장코드만, 고장출처는 제외)
if field.name in ["train_number", "device_category", "fault_code"]:
def on_hover_enter():
if value: # 값이 있을 때만 팝업 표시
self._show_fault_history_popup(field.name, value, record, label)
def on_hover_leave():
if self._current_popup:
self._current_popup.hide_popup()
self._current_popup = None
label.hover_entered.connect(on_hover_enter)
label.hover_left.connect(on_hover_leave)
# 더블클릭 시 편집
def on_double_clicked():
self._edit_field_inline(row, col, field, record)
label.double_clicked.connect(on_double_clicked)
# 고장출처는 클릭으로도 편집 가능
if field.name == "fault_source":
def on_clicked():
self._edit_field_inline(row, col, field, record)
label.clicked.connect(on_clicked)
# 레코드 정보 저장
label.setProperty("record_id", record.id)
label.setProperty("field_name", field.name)
label.setProperty("field_value", value or "")
# 컨테이너 위젯으로 감싸서 구분선 적용 (여백 없이)
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)
# 기본 높이 반환
return int(40 * 1.15)
def _set_action_content_label(self, row: int, col: int, field: FieldConfig, record: Fault) -> int:
"""조치내용 라벨 설정 (조치 단계 요약 표시)
Returns:
계산된 셀 높이 (픽셀)
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout
# 조치 단계 조회
db = DatabaseManager()
steps = db.fetch_all(
"SELECT step_number, action_content, action_team, created_at FROM action_steps WHERE fault_id = ? ORDER BY step_number",
(record.id,)
)
if not steps:
# 조치 단계가 없으면 기존 action_content 표시
display_value = record.action_content or ""
return self._set_content_label(row, col, field, display_value, record)
# 조치 단계 요약 생성
step_texts = []
for step in steps:
step_num = step.get('step_number', 0)
content = step.get('action_content', '')
team = step.get('action_team', '')
created_at = step.get('created_at', '')
# 시간 포맷팅
if isinstance(created_at, str):
try:
dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
time_str = dt.strftime("%m/%d %H:%M")
except:
time_str = ""
else:
time_str = ""
# 요약 텍스트 생성
summary = f"[{step_num}]"
if team:
summary += f" {team}"
if time_str:
summary += f" ({time_str})"
if content:
# 내용이 길면 잘라서 표시
content_preview = content[:50] + "..." if len(content) > 50 else content
summary += f": {content_preview}"
step_texts.append(summary)
display_value = "\n".join(step_texts)
# ClickableLabel 생성
label = ClickableLabel(display_value, enable_hover=False, enable_double_click=True)
label.setWordWrap(True)
label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
# 레코드 정보 저장
label.setProperty("record_id", record.id)
label.setProperty("field_name", field.name)
# 더블클릭 시 조치 단계 다이얼로그 열기
def on_double_clicked():
dialog = FaultInputDialog(self, record)
dialog.exec()
self.refresh_data()
label.double_clicked.connect(on_double_clicked)
# 컨테이너 위젯으로 감싸기
container = QWidget()
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(8, 4, 8, 4)
container_layout.setSpacing(0)
container_layout.addWidget(label)
# 스타일 적용
theme = self.config.theme
gridline_color = "#475569" if theme == 'dark' else "#cbd5e1"
text_color = "#f8fafc" if theme == 'dark' else "#1e293b"
label.setStyleSheet(f"""
QLabel {{
color: {text_color};
font-size: 11pt;
background-color: transparent;
}}
""")
container.setStyleSheet(f"border-right: 1px solid {gridline_color};")
# 셀에 위젯 설정
self.table.setCellWidget(row, col, container)
# 높이 계산
font_metrics = self.table.fontMetrics()
lines = len(step_texts)
line_height = font_metrics.height() + 8
calculated_height = max(int(40 * 1.15), lines * line_height)
return calculated_height
def _set_content_label(self, row: int, col: int, field: FieldConfig, value: Any, record: Fault) -> int:
"""고장내용 라벨 설정 (줄바꿈 지원, ClickableLabel 사용)
Returns:
계산된 셀 높이 (픽셀)
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout
display_value = self._format_value(field, value) if value else ""
# ClickableLabel 생성 (줄바꿈 지원)
label = ClickableLabel(display_value or "", enable_hover=False, enable_double_click=True, enable_right_click=True)
label.setWordWrap(True) # 자동 줄바꿈 활성화
label.setAlignment(Qt.AlignLeft | Qt.AlignTop) # 왼쪽 위 정렬
label.setTextInteractionFlags(Qt.TextSelectableByMouse) # 텍스트 선택 가능
# 레코드 정보 저장
label.setProperty("record_id", record.id)
label.setProperty("field_name", field.name)
label.setProperty("field_value", value or "")
# 더블클릭 시 편집
def on_double_clicked():
self._edit_field_inline(row, col, field, record)
label.double_clicked.connect(on_double_clicked)
# 우클릭 컨텍스트 메뉴
def on_right_clicked(pos):
from PySide6.QtWidgets import QMenu
menu = QMenu(self)
edit_action = menu.addAction("편집")
edit_action.triggered.connect(lambda: self._edit_field_inline(row, col, field, record))
menu.exec_(label.mapToGlobal(pos))
label.right_clicked.connect(on_right_clicked)
# 컨테이너 위젯으로 감싸서 구분선 적용
container = QWidget()
container_layout = QVBoxLayout(container)
container_layout.setContentsMargins(8, 4, 8, 4)
container_layout.setSpacing(0)
container_layout.addWidget(label)
# 구분선 및 텍스트 색상 스타일 적용
theme = self.config.theme
gridline_color = "#475569" if theme == 'dark' else "#cbd5e1"
# 텍스트 색상: 다크 테마는 밝은 색, 라이트 테마는 어두운 색
text_color = "#f8fafc" if theme == 'dark' else "#1e293b"
# 높이 계산 및 줄바꿈 여부 확인 (텍스트 길이에 따라)
font_metrics = self.table.fontMetrics()
base_font_size = 13 # 기본 폰트 크기 (테이블 기본값)
col_width = self.table.columnWidth(col)
needs_wrap = False
calculated_height = int(40 * 1.15) # 기본 높이
if display_value and col_width > 0:
# 컬럼 너비에 맞춰 줄 수 계산 (패딩 고려)
available_width = col_width - 16 # 좌우 패딩 8px씩
text_width = font_metrics.horizontalAdvance(display_value)
# 줄바꿈이 필요한지 확인
if text_width > available_width:
needs_wrap = True
# 줄바꿈 시 폰트 크기를 2포인트 작게 (11px)
wrap_font_size = base_font_size - 2
# 작은 폰트로 다시 계산
from PySide6.QtGui import QFont, QFontMetrics
small_font = QFont(self.table.font())
small_font.setPointSize(wrap_font_size)
small_font_metrics = QFontMetrics(small_font)
# 작은 폰트 기준으로 줄 수 계산
chars_per_line = max(1, available_width // small_font_metrics.averageCharWidth())
lines = max(1, (len(display_value) // chars_per_line) + 1)
line_height = small_font_metrics.height() + 6 # 줄 간격 (작은 폰트 고려)
calculated_height = max(int(40 * 1.15), lines * line_height + 8) # 상하 패딩
else:
# 한 줄로 표시 가능
calculated_height = int(40 * 1.15)
# 라벨에 텍스트 색상 및 폰트 크기 적용
if needs_wrap:
wrap_font_size = base_font_size - 2
label.setStyleSheet(f"""
QLabel {{
color: {text_color};
background-color: transparent;
font-size: {wrap_font_size}px;
}}
""")
else:
label.setStyleSheet(f"""
QLabel {{
color: {text_color};
background-color: transparent;
}}
""")
container.setStyleSheet(f"border-right: 1px solid {gridline_color};")
# 셀에 위젯 설정
self.table.setCellWidget(row, col, container)
return calculated_height
def _set_fault_source_label(self, row: int, col: int, field: FieldConfig, value: Any, record: Fault):
"""고장출처 라벨 설정"""
from PySide6.QtWidgets import QWidget, QHBoxLayout
display_value = value if value else ""
chip_color = "#8b5cf6" # 고장출처 보라색
# ClickableLabel 생성
if not display_value:
# 미지정이면 빈칸처럼 보이게
label = ClickableLabel(" ", enable_hover=False) # 공백 하나
else:
label = ClickableLabel(display_value or "미지정", enable_hover=False)
label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
# 칩 스타일 배경색 적용
theme = self.config.theme
# 고장출처가 미지정이면 투명하게
if not display_value:
bg_color = "transparent"
text_color = "transparent"
border_color = "transparent"
else:
bg_color = chip_color
text_color = "#ffffff"
border_color = "rgba(255,255,255,0.2)" if theme == 'dark' else "rgba(0,0,0,0.1)"
if theme == 'dark':
label.setStyleSheet(f"""
QLabel {{
background-color: {bg_color};
color: {text_color};
border: 1px solid {border_color};
border-radius: 14px;
padding: 4px 10px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {bg_color if bg_color != 'transparent' else chip_color};
border-color: rgba(255,255,255,0.4);
}}
""")
else:
label.setStyleSheet(f"""
QLabel {{
background-color: {bg_color};
color: {text_color};
border: 1px solid {border_color};
border-radius: 14px;
padding: 4px 10px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {bg_color if bg_color != 'transparent' else chip_color};
border-color: rgba(0,0,0,0.2);
}}
""")
# 클릭/더블클릭 이벤트 연결
def on_clicked():
self._show_fault_source_popup(record, label)
def on_double_clicked():
self._edit_field_inline(row, col, field, record)
label.clicked.connect(on_clicked)
label.double_clicked.connect(on_double_clicked)
# 레코드 정보 저장
label.setProperty("record_id", record.id)
label.setProperty("field_name", field.name)
# 컨테이너 위젯으로 감싸서 구분선 적용
container = QWidget()
container_layout = QHBoxLayout(container)
container_layout.setContentsMargins(8, 0, 8, 0)
container_layout.setSpacing(4)
container_layout.addWidget(label)
# 고장출처인 경우 리셋 버튼 추가
if display_value:
from PySide6.QtWidgets import QToolButton
reset_btn = QToolButton()
reset_btn.setText("")
reset_btn.setToolTip("고장출처 리셋")
reset_btn.setFixedSize(20, 20)
reset_btn.setCursor(Qt.PointingHandCursor)
# 리셋 버튼 스타일
if theme == 'dark':
reset_btn.setStyleSheet("""
QToolButton {
background-color: transparent;
color: #94a3b8;
border: none;
border-radius: 4px;
font-size: 12px;
}
QToolButton:hover {
background-color: #475569;
color: #f8fafc;
}
""")
else:
reset_btn.setStyleSheet("""
QToolButton {
background-color: transparent;
color: #64748b;
border: none;
border-radius: 4px;
font-size: 12px;
}
QToolButton:hover {
background-color: #e2e8f0;
color: #1e293b;
}
""")
# 리셋 버튼 클릭 시 고장출처 리셋
def on_reset_clicked():
self.crud.update_fault(record.id, fault_source="")
self.refresh_data()
reset_btn.clicked.connect(on_reset_clicked)
container_layout.addWidget(reset_btn)
# 구분선 스타일 적용
gridline_color = "#475569" if theme == 'dark' else "#cbd5e1"
container.setStyleSheet(f"border-right: 1px solid {gridline_color};")
# 셀에 위젯 설정
self.table.setCellWidget(row, col, container)
def _set_team_label(self, row: int, col: int, field: FieldConfig, value: Any, record: Fault):
"""조치팀/확인팀 라벨 설정 (공간 효율 최대화)"""
from PySide6.QtWidgets import QWidget, QHBoxLayout
import json
theme = self.config.theme
unified_color = "#64748b" # 통일된 회색 계열 (다른 필드와 동일)
if field.name == "action_team":
# 조치팀: 숫자만 표시 (예: "1팀" -> "1")
if value:
display_value = value.replace("", "")
else:
display_value = "-"
else: # team_confirmations
# 확인팀: 확인된 팀 숫자만 표시 (예: "1 2 4")
try:
confirmations = json.loads(value) if isinstance(value, str) else value
if isinstance(confirmations, dict):
# 팀 이름에서 숫자만 추출하여 정렬
confirmed_nums = sorted([
team.replace("", "")
for team, confirmed in confirmations.items()
if confirmed
])
display_value = " ".join(confirmed_nums) if confirmed_nums else "-"
else:
display_value = "-"
except Exception:
display_value = "-"
# ClickableLabel 생성
label = ClickableLabel(display_value, enable_hover=False)
label.setAlignment(Qt.AlignCenter)
# 통일된 스타일 적용 (다른 필드와 동일하게)
if theme == 'dark':
label.setStyleSheet(f"""
QLabel {{
background-color: {unified_color};
color: #ffffff;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 0px;
padding: 0px;
margin: 0px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {unified_color};
border-color: rgba(255,255,255,0.4);
}}
""")
else:
label.setStyleSheet(f"""
QLabel {{
background-color: {unified_color};
color: #ffffff;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 0px;
padding: 0px;
margin: 0px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {unified_color};
border-color: rgba(0,0,0,0.2);
}}
""")
# 클릭/더블클릭 이벤트 연결
def on_clicked():
if field.name == "action_team":
self._show_action_team_popup(record, label)
elif field.name == "team_confirmations":
self._show_team_confirmations_popup(record, label)
def on_double_clicked():
self._edit_field_inline(row, col, field, record)
label.clicked.connect(on_clicked)
label.double_clicked.connect(on_double_clicked)
# 레코드 정보 저장
label.setProperty("record_id", record.id)
label.setProperty("field_name", field.name)
# 컨테이너 위젯으로 감싸서 구분선 적용 (여백 없이)
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)
def _set_attachment_label(self, row: int, col: int, record: Fault):
"""관련자료 라벨 설정 (공간 효율 최대화)"""
from PySide6.QtWidgets import QWidget, QHBoxLayout
from services.storage_service import get_storage_service
theme = self.config.theme
storage = get_storage_service()
# 첨부파일 개수 조회
count = storage.get_attachment_count("faults", record.id)
# 표시 텍스트: 첨부 파일 개수 또는 "+"
if count > 0:
display_value = str(count)
# 파일이 있으면 강조 색상
bg_color = "#3b82f6" # 파란색
else:
display_value = "+"
bg_color = "#64748b" # 통일된 회색
# ClickableLabel 생성
label = ClickableLabel(display_value, enable_hover=True)
label.setAlignment(Qt.AlignCenter)
# 스타일 적용
if theme == 'dark':
label.setStyleSheet(f"""
QLabel {{
background-color: {bg_color};
color: #ffffff;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 0px;
padding: 0px;
margin: 0px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {bg_color};
border-color: rgba(255,255,255,0.4);
}}
""")
else:
label.setStyleSheet(f"""
QLabel {{
background-color: {bg_color};
color: #ffffff;
border: 1px solid rgba(0,0,0,0.1);
border-radius: 0px;
padding: 0px;
margin: 0px;
font-weight: 600;
font-size: 12px;
}}
QLabel:hover {{
background-color: {bg_color};
border-color: rgba(0,0,0,0.2);
}}
""")
# 클릭 시 첨부파일 다이얼로그 열기
def on_clicked():
self._show_attachment_dialog(record)
label.clicked.connect(on_clicked)
# 툴팁
if count > 0:
label.setToolTip(f"첨부파일 {count}개 (클릭하여 보기)")
else:
label.setToolTip("클릭하여 첨부파일 추가")
# 레코드 정보 저장
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)
def _show_attachment_dialog(self, record: Fault):
"""첨부파일 다이얼로그 표시"""
from ui.dialogs.attachment_dialog import AttachmentDialog
device_category = record.device_category or ""
# 레코드 정보 (파일명 생성용)
record_info = {
"occurrence_date": record.occurrence_date,
"column_number": record.column_number or "",
"train_number": record.train_number or "",
"car_number": record.car_number or "",
"device_category": device_category,
"fault_content": record.fault_content or "",
}
dialog = AttachmentDialog(
self,
record_type="faults",
record_id=record.id,
device_category=device_category,
record_info=record_info
)
dialog.exec()
# 다이얼로그 닫힌 후 테이블 새로고침 (첨부파일 개수 업데이트)
self.refresh_data()
def _show_fault_source_popup(self, record: Fault, label: ClickableLabel):
"""고장출처 선택 팝업 표시 (단일선택)"""
from ui.components.chips.choice_chip_button import ChoiceChipButton
from ui.components.popup_widget import PopupWidget
from PySide6.QtWidgets import QVBoxLayout, QWidget, QButtonGroup
# 기존 팝업이 있으면 닫기
if self._current_team_popup:
self._current_team_popup.hide_popup()
self._current_team_popup = None
current_source = record.fault_source or ""
# 팝업 생성 (세로 배치에 맞게 너비 조정)
popup = PopupWidget(self, title="고장출처 선택", width=200, auto_hide=False)
self._current_team_popup = popup
# 칩 그룹 생성 (단일선택용 QButtonGroup)
button_group = QButtonGroup()
button_group.setExclusive(True) # 단일선택
chip_container = QWidget()
chip_layout = QVBoxLayout(chip_container) # 세로 배치
chip_layout.setContentsMargins(0, 0, 0, 0)
chip_layout.setSpacing(6)
def on_source_selected(source_key: str):
"""출처 선택 시 바로 처리하고 팝업 닫기"""
self.crud.update_fault(record.id, fault_source=source_key)
self.refresh_data()
popup.hide_popup()
self._current_team_popup = None
for source in FAULT_SOURCES:
# 선택된 출처는 회색, 선택되지 않은 출처는 파란색
chip_bg = "#64748b" if source == current_source else "#2979ff"
chip = ChoiceChipButton(
text=source,
key=source,
bg=chip_bg
)
chip.setMinimumWidth(160) # 최소 너비 설정으로 텍스트 전체 표시
button_group.addButton(chip)
chip_layout.addWidget(chip)
# 현재 선택된 출처 설정
if source == current_source:
chip.setChecked(True)
# 칩 클릭 시 색상 변경 및 처리
def make_chip_handler(source_key: str):
def handler():
# 모든 칩 색상 업데이트
for btn in button_group.buttons():
if isinstance(btn, ChoiceChipButton):
if btn.key == source_key:
btn.set_bg("#64748b") # 선택된 출처는 회색
else:
btn.set_bg("#2979ff") # 선택되지 않은 출처는 파란색
on_source_selected(source_key)
return handler
chip.clicked_key.connect(make_chip_handler(source))
popup.content_layout.addWidget(chip_container)
# 팝업 크기 자동 조정 (세로 배치에 맞게)
chip_container.adjustSize()
popup_width = 200 # 고정 너비
popup.container.setFixedWidth(popup_width)
# 팝업이 마우스 밖으로 나가면 닫기
original_leave_event = popup.leaveEvent
def on_popup_leave(event):
if self._current_team_popup == popup:
popup.hide_popup()
self._current_team_popup = None
original_leave_event(event)
popup.leaveEvent = on_popup_leave
# 마우스 추적 활성화
popup.setMouseTracking(True)
popup.container.setMouseTracking(True)
# 라벨 위치 기준으로 팝업 위치 계산
label_pos = label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x(), label_pos.y() + label.height() + 5)
popup.show_at(popup_pos)
def _show_action_team_popup(self, record: Fault, label: ClickableLabel):
"""조치팀 선택 팝업 표시 (단일선택)"""
from ui.components.chips.choice_chip_button import ChoiceChipButton
from ui.components.popup_widget import PopupWidget
from core.constants import TEAMS
from PySide6.QtWidgets import QHBoxLayout, QWidget, QButtonGroup
# 기존 팝업이 있으면 닫기
if self._current_team_popup:
self._current_team_popup.hide_popup()
self._current_team_popup = None
current_team = record.action_team or ""
# 팝업 생성
popup = PopupWidget(self, title="조치팀 선택", width=400, auto_hide=False)
self._current_team_popup = popup
# 칩 그룹 생성 (단일선택용 QButtonGroup)
button_group = QButtonGroup()
button_group.setExclusive(True) # 단일선택
chip_container = QWidget()
chip_layout = QHBoxLayout(chip_container)
chip_layout.setContentsMargins(0, 0, 0, 0)
chip_layout.setSpacing(8)
def on_team_selected(team_key: str):
"""팀 선택 시 바로 처리하고 팝업 닫기"""
self.crud.update_fault(record.id, action_team=team_key)
self.refresh_data()
popup.hide_popup()
self._current_team_popup = None
for team in TEAMS:
# 선택된 팀은 회색, 선택되지 않은 팀은 파란색
chip_bg = "#64748b" if team == current_team else "#2979ff"
chip = ChoiceChipButton(
text=team,
key=team,
bg=chip_bg
)
button_group.addButton(chip)
chip_layout.addWidget(chip)
# 현재 선택된 팀 설정
if team == current_team:
chip.setChecked(True)
# 칩 클릭 시 색상 변경 및 처리
def make_chip_handler(team_key: str):
def handler():
# 모든 칩 색상 업데이트
for btn in button_group.buttons():
if isinstance(btn, ChoiceChipButton):
if btn.key == team_key:
btn.set_bg("#64748b") # 선택된 팀은 회색
else:
btn.set_bg("#2979ff") # 선택되지 않은 팀은 파란색
on_team_selected(team_key)
return handler
chip.clicked_key.connect(make_chip_handler(team))
chip_layout.addStretch()
popup.content_layout.addWidget(chip_container)
# 팝업 크기 자동 조정 (칩 개수에 맞게)
chip_container.adjustSize()
chip_width = chip_container.sizeHint().width()
popup_width = max(200, min(400, chip_width + 40)) # 최소 200, 최대 400, 여백 40
popup.container.setFixedWidth(popup_width)
# 팝업이 마우스 밖으로 나가면 닫기
original_leave_event = popup.leaveEvent
def on_popup_leave(event):
if self._current_team_popup == popup:
popup.hide_popup()
self._current_team_popup = None
original_leave_event(event)
popup.leaveEvent = on_popup_leave
# 마우스 추적 활성화
popup.setMouseTracking(True)
popup.container.setMouseTracking(True)
# 라벨 위치 기준으로 팝업 위치 계산
label_pos = label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x(), label_pos.y() + label.height() + 5)
popup.show_at(popup_pos)
def _show_team_confirmations_popup(self, record: Fault, label: ClickableLabel):
"""확인팀 선택 팝업 표시 (다중선택)"""
from ui.components.chips.choice_chip_button import ChoiceChipButton
from ui.components.popup_widget import PopupWidget
from core.constants import TEAMS
from PySide6.QtWidgets import QHBoxLayout, QWidget
import json
# 기존 팝업이 있으면 닫기
if self._current_team_popup:
self._current_team_popup.hide_popup()
self._current_team_popup = None
# 현재 확인된 팀 찾기
current_confirmations = {}
try:
confirmations = json.loads(record.team_confirmations) if isinstance(record.team_confirmations, str) else record.team_confirmations
if isinstance(confirmations, dict):
current_confirmations = confirmations
except Exception:
pass
# 팝업 생성
popup = PopupWidget(self, title="확인팀 선택", width=320, auto_hide=False)
self._current_team_popup = popup
# 칩 그룹 생성 (다중선택용)
chip_group = {}
chip_container = QWidget()
chip_layout = QHBoxLayout(chip_container)
chip_layout.setContentsMargins(0, 0, 0, 0)
chip_layout.setSpacing(8)
def update_confirmations():
"""확인 상태 업데이트"""
confirmations = {team: chip.isChecked() for team, chip in chip_group.items()}
self._update_team_confirmations(record.id, confirmations)
self.refresh_data()
for team in TEAMS:
# 선택된 팀은 회색, 선택되지 않은 팀은 청록색
is_checked = current_confirmations.get(team, False)
chip_bg = "#64748b" if is_checked else "#00bfa5"
chip = ChoiceChipButton(
text=team,
key=team,
bg=chip_bg
)
chip_group[team] = chip
chip_layout.addWidget(chip)
# 현재 확인된 팀 설정
if is_checked:
chip.setChecked(True)
# 칩 토글 시 색상 변경 및 업데이트
def make_toggle_handler(team_key: str):
def handler(_key: str, checked: bool):
chip = chip_group[team_key]
if checked:
chip.set_bg("#64748b") # 선택된 팀은 회색
else:
chip.set_bg("#00bfa5") # 선택되지 않은 팀은 청록색
update_confirmations()
return handler
chip.toggled_key.connect(make_toggle_handler(team))
chip_layout.addStretch()
popup.content_layout.addWidget(chip_container)
# 팝업 크기 자동 조정 (칩 개수에 맞게)
chip_container.adjustSize()
chip_width = chip_container.sizeHint().width()
popup_width = max(200, min(400, chip_width + 40)) # 최소 200, 최대 400, 여백 40
popup.container.setFixedWidth(popup_width)
# 팝업이 마우스 밖으로 나가면 닫기
original_leave_event = popup.leaveEvent
def on_popup_leave(event):
if self._current_team_popup == popup:
popup.hide_popup()
self._current_team_popup = None
original_leave_event(event)
popup.leaveEvent = on_popup_leave
# 마우스 추적 활성화
popup.setMouseTracking(True)
popup.container.setMouseTracking(True)
# 라벨 위치 기준으로 팝업 위치 계산
label_pos = label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x(), label_pos.y() + label.height() + 5)
popup.show_at(popup_pos)
def _create_fault_history_popup(self, field_name: str, field_value: str, current_record: Fault) -> PopupWidget:
"""고장 기록 팝업 생성"""
faults: List[Fault] = []
title = ""
if field_name == "train_number" and field_value:
faults = self.crud.get_faults_by_train(field_value, limit=20, months_back=3)
title = f"편성 {field_value} 고장 기록"
elif field_name == "device_category" and field_value:
faults = self.crud.get_faults_by_device(field_value, limit=20, months_back=3)
title = f"장치분류 {field_value} 고장 기록"
elif field_name == "fault_code" and field_value:
faults = self.crud.get_faults_by_code(field_value, limit=20, months_back=3)
title = f"고장코드 {field_value} 고장 기록"
# 현재 레코드 제외
faults = [f for f in faults if f.id != current_record.id]
# 팝업 생성
popup = PopupWidget(self, title=title, width=320, auto_hide=False)
if not faults:
popup.add_text("최근 3달 이내 관련 기록이 없습니다.", is_secondary=True)
else:
popup.add_text(f"{len(faults)}건의 기록이 있습니다.", is_secondary=True)
# 각 고장 기록을 버튼으로 표시
for fault in faults[:10]: # 최대 10개만 표시
# 날짜 포맷팅 (date 객체 또는 문자열 처리)
if fault.occurrence_date:
if isinstance(fault.occurrence_date, date):
date_str = fault.occurrence_date.strftime("%Y-%m-%d")
elif isinstance(fault.occurrence_date, str):
date_str = fault.occurrence_date[:10] if len(fault.occurrence_date) >= 10 else fault.occurrence_date
else:
date_str = str(fault.occurrence_date)
else:
date_str = "-"
# 시간 포맷팅 (time 객체 또는 문자열 처리)
if fault.occurrence_time:
if hasattr(fault.occurrence_time, 'strftime'):
time_str = fault.occurrence_time.strftime("%H:%M")
elif isinstance(fault.occurrence_time, str):
# 문자열인 경우 (예: "14:30:00" 또는 "14:30")
time_str = fault.occurrence_time[:5] if len(fault.occurrence_time) >= 5 else fault.occurrence_time
else:
time_str = str(fault.occurrence_time)
else:
time_str = "-"
summary = f"{date_str} {time_str} | {fault.fault_content[:30] if fault.fault_content else '-'}"
btn = QPushButton(summary)
btn.setCursor(Qt.PointingHandCursor)
btn.setProperty("fault_id", fault.id)
btn.setProperty("filter_type", field_name)
btn.setProperty("filter_value", field_value)
btn.clicked.connect(lambda checked=False, f=fault, ft=field_name, fv=field_value:
self._open_fault_dialog_with_filter(f.id, ft, fv))
# 버튼 스타일
theme = self.config.theme
if theme == 'dark':
btn.setStyleSheet("""
QPushButton {
background-color: #334155;
color: #f8fafc;
border: 1px solid #475569;
border-radius: 6px;
padding: 8px;
text-align: left;
}
QPushButton:hover {
background-color: #475569;
border-color: #64748b;
}
""")
else:
btn.setStyleSheet("""
QPushButton {
background-color: #f8fafc;
color: #1e293b;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
text-align: left;
}
QPushButton:hover {
background-color: #e2e8f0;
border-color: #cbd5e1;
}
""")
popup.content_layout.addWidget(btn)
return popup
def _show_fault_history_popup(self, field_name: str, field_value: str, record: Fault, label: ClickableLabel):
"""고장 기록 팝업 표시"""
if not field_value:
return
popup = self._create_fault_history_popup(field_name, field_value, record)
# 라벨 위치 기준으로 팝업 위치 계산
label_pos = label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x() + label.width() + 10, label_pos.y())
popup.show_at(popup_pos)
# 팝업을 라벨과 연결하여 마우스 이탈 시 닫기
self._current_popup = popup
def _open_fault_dialog_with_filter(self, fault_id: int, filter_type: str = "", filter_value: str = ""):
"""필터가 적용된 상태로 고장 다이얼로그 열기"""
# 사용하지 않는 매개변수 무시
_ = (filter_type, filter_value)
# 고장 다이얼로그 열기
record = self.crud.get_fault(fault_id)
if record:
dialog = FaultInputDialog(self, record)
dialog.exec()
# 팝업 닫기
self._close_current_popup()
class FaultInputDialog(SectionInputDialog):
"""
고장 입력 다이얼로그
개선된 레이아웃:
- 상단: 고장내용/조치내용을 크게 좌우 배치
- 하단: 날짜, 편성, 호차 등 기타 정보
- 키보드 탭 네비게이션 지원
- ClickableLabel 팝업 방식의 편성 선택
"""
def __init__(self, parent=None, record: Fault = None):
super().__init__(
parent,
title="고장 추가" if record is None else "고장 편집",
record=record,
width=900,
height=700
)
self._selected_train: Optional[str] = None
self._selected_source: Optional[str] = None
self._current_popup = None # 현재 열린 팝업 추적
self._setup_improved_layout()
self._setup_tab_order()
if record:
self._load_record(record)
def _setup_improved_layout(self):
"""개선된 레이아웃 설정"""
from ui.components.custom_input import CustomLineEdit, CustomTextEdit, CustomComboBox, LabeledInput
from PySide6.QtWidgets import (
QHBoxLayout, QVBoxLayout, QWidget, QGridLayout,
QSpinBox, QFrame, QSizePolicy
)
from PySide6.QtCore import QDate, QTime
from PySide6.QtGui import QFont
theme = self.config.theme
is_dark = theme == 'dark'
# =====================================================================
# 상단 영역: 고장내용 / 조치내용 (좌우 크게 배치)
# =====================================================================
content_area = QWidget()
content_layout = QHBoxLayout(content_area)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(16)
# 고장내용 (좌측)
fault_container = QWidget()
fault_layout = QVBoxLayout(fault_container)
fault_layout.setContentsMargins(0, 0, 0, 0)
fault_layout.setSpacing(4)
fault_label = self._create_section_label("고장내용 *", is_dark)
fault_layout.addWidget(fault_label)
self.fault_content_input = CustomTextEdit(
placeholder="고장 내용을 입력하세요\n예: TCU 고장 발생. 추진력 감소 현상.",
min_height=180
)
fault_layout.addWidget(self.fault_content_input)
content_layout.addWidget(fault_container, 1)
# 조치내용 (우측) - 단계별 리스트
action_container = QWidget()
action_layout = QVBoxLayout(action_container)
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(4)
# 헤더 (라벨 + 추가 버튼)
action_header = QWidget()
action_header_layout = QHBoxLayout(action_header)
action_header_layout.setContentsMargins(0, 0, 0, 0)
action_header_layout.setSpacing(8)
action_label = self._create_section_label("조치내용", is_dark)
action_header_layout.addWidget(action_label)
from PySide6.QtWidgets import QPushButton
add_action_btn = QPushButton("+ 추가")
add_action_btn.setFixedHeight(28)
add_action_btn.setCursor(Qt.PointingHandCursor)
add_action_btn.clicked.connect(self._add_action_step)
add_action_btn.setStyleSheet("""
QPushButton {
background-color: #3b82f6;
color: white;
border: none;
border-radius: 6px;
padding: 4px 12px;
font-size: 11pt;
font-weight: bold;
}
QPushButton:hover {
background-color: #2563eb;
}
""")
action_header_layout.addWidget(add_action_btn)
action_header_layout.addStretch()
action_layout.addWidget(action_header)
# 조치 단계 리스트 스크롤 영역
from PySide6.QtWidgets import QScrollArea
self.action_steps_scroll = QScrollArea()
self.action_steps_scroll.setWidgetResizable(True)
self.action_steps_scroll.setMinimumHeight(180)
self.action_steps_scroll.setMaximumHeight(400)
# 다크모드 배경색 설정
theme = self.config.theme
scroll_bg = "#0f172a" if theme == 'dark' else "#ffffff"
self.action_steps_scroll.setStyleSheet(f"""
QScrollArea {{
border: none;
background-color: {scroll_bg};
}}
""")
self.action_steps_container = QWidget()
# 컨테이너 배경색도 설정
self.action_steps_container.setStyleSheet(f"""
QWidget {{
background-color: {scroll_bg};
}}
""")
self.action_steps_layout = QVBoxLayout(self.action_steps_container)
self.action_steps_layout.setContentsMargins(0, 0, 0, 0)
self.action_steps_layout.setSpacing(8)
self.action_steps_layout.addStretch()
self.action_steps_scroll.setWidget(self.action_steps_container)
action_layout.addWidget(self.action_steps_scroll)
# 조치 단계 리스트 저장
self.action_steps = [] # [(step_number, content, team, created_at), ...]
content_layout.addWidget(action_container, 1)
self.content_layout.addWidget(content_area)
# =====================================================================
# 구분선
# =====================================================================
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setStyleSheet(f"background-color: {'#475569' if is_dark else '#e2e8f0'};")
separator.setFixedHeight(1)
self.content_layout.addWidget(separator)
# =====================================================================
# 하단 영역: 날짜, 시간, 편성, 호차 등 기타 정보
# =====================================================================
info_area = QWidget()
info_layout = QGridLayout(info_area)
info_layout.setContentsMargins(0, 8, 0, 0)
info_layout.setSpacing(12)
info_layout.setColumnStretch(0, 1)
info_layout.setColumnStretch(1, 1)
info_layout.setColumnStretch(2, 1)
info_layout.setColumnStretch(3, 1)
row = 0
# 첫 번째 행: 발생일자, 발생시간, 열번, 편성
# 발생일자 (클릭 시 캘린더)
self.date_label = self._create_clickable_field(
"발생일자", date.today().strftime("%Y-%m-%d"), self._show_date_picker
)
info_layout.addWidget(self._wrap_with_label("발생일자", self.date_label), row, 0)
# 발생시간 (스핀박스)
time_widget = self._create_time_spinbox()
info_layout.addWidget(self._wrap_with_label("발생시간", time_widget), row, 1)
# 열번 (4개 스핀박스)
column_widget = self._create_column_spinbox()
info_layout.addWidget(self._wrap_with_label("열번", column_widget), row, 2)
# 편성 (클릭 시 팝업 칩 선택)
self.train_label = self._create_clickable_field(
"편성 선택", "선택", self._show_train_popup
)
info_layout.addWidget(self._wrap_with_label("편성 *", self.train_label), row, 3)
row += 1
# 두 번째 행: 호차, 발생역, 장치분류, 고장코드
# 호차 (스핀박스 1~8)
car_widget = self._create_car_spinbox()
info_layout.addWidget(self._wrap_with_label("호차", car_widget), row, 0)
# 발생역
self.station_input = CustomLineEdit(placeholder="발생역")
self.station_input.setMinimumHeight(36)
self.station_input.textChanged.connect(self._estimate_time_from_column_station)
info_layout.addWidget(self._wrap_with_label("발생역", self.station_input), row, 1)
# 장치분류
self.device_input = CustomComboBox(items=DEVICE_CATEGORIES, placeholder="장치분류")
self.device_input.setMinimumHeight(36)
info_layout.addWidget(self._wrap_with_label("장치분류", self.device_input), row, 2)
# 고장코드
self.code_input = CustomLineEdit(placeholder="고장코드")
self.code_input.setMinimumHeight(36)
info_layout.addWidget(self._wrap_with_label("고장코드", self.code_input), row, 3)
row += 1
# 세 번째 행: 고장출처, 조치팀
# 고장출처 (클릭 시 팝업 칩 선택)
self.source_label = self._create_clickable_field(
"고장출처 선택", "선택", self._show_source_popup
)
info_layout.addWidget(self._wrap_with_label("고장출처", self.source_label), row, 0)
# 조치팀 (클릭 시 팝업 칩 선택)
self.team_label = self._create_clickable_field(
"조치팀 선택", "선택", self._show_team_popup
)
info_layout.addWidget(self._wrap_with_label("조치팀", self.team_label), row, 1)
# 빈 공간
info_layout.addWidget(QWidget(), row, 2)
info_layout.addWidget(QWidget(), row, 3)
self.content_layout.addWidget(info_area)
# 스트레치 추가
self.content_layout.addStretch()
def _create_section_label(self, text: str, is_dark: bool) -> QLabel:
"""섹션 라벨 생성"""
from PySide6.QtWidgets import QLabel
from PySide6.QtGui import QFont
label = QLabel(text)
label.setFont(QFont("GmarketSans", 11, QFont.Bold))
label.setStyleSheet(f"color: {'#f8fafc' if is_dark else '#1e293b'}; min-height: 24px;")
return label
def _wrap_with_label(self, label_text: str, widget) -> QWidget:
"""라벨과 위젯을 감싸는 컨테이너 생성"""
from PySide6.QtWidgets import QVBoxLayout, QWidget, QLabel
from PySide6.QtGui import QFont
container = QWidget()
layout = QVBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
theme = self.config.theme
is_dark = theme == 'dark'
label = QLabel(label_text)
label.setFont(QFont("GmarketSans", 9))
label.setStyleSheet(f"color: {'#94a3b8' if is_dark else '#64748b'}; min-height: 16px;")
layout.addWidget(label)
layout.addWidget(widget)
return container
def _create_clickable_field(self, placeholder: str, default_text: str, click_handler) -> ClickableLabel:
"""클릭 가능한 필드 생성"""
theme = self.config.theme
is_dark = theme == 'dark'
label = ClickableLabel(default_text, enable_hover=True)
label.setMinimumHeight(36)
label.setAlignment(Qt.AlignCenter)
if is_dark:
label.setStyleSheet("""
QLabel {
border: 1px solid #475569;
border-radius: 6px;
padding: 6px 12px;
background-color: #334155;
color: #f8fafc;
font-size: 12px;
}
QLabel:hover {
background-color: #475569;
border-color: #64748b;
}
""")
else:
label.setStyleSheet("""
QLabel {
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 6px 12px;
background-color: #f8fafc;
color: #1e293b;
font-size: 12px;
}
QLabel:hover {
background-color: #e2e8f0;
border-color: #94a3b8;
}
""")
label.clicked.connect(click_handler)
return label
def _create_time_spinbox(self) -> QWidget:
"""시간 입력 스핀박스 생성 (시:분)"""
from PySide6.QtWidgets import QHBoxLayout, QWidget, QSpinBox, QLabel
from datetime import datetime
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
theme = self.config.theme
spinbox_style = self._get_spinbox_style()
# 시간 스핀박스
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setValue(datetime.now().hour)
self.hour_spin.setFixedWidth(50)
self.hour_spin.setStyleSheet(spinbox_style)
layout.addWidget(self.hour_spin)
# 구분자
colon = QLabel(":")
colon.setStyleSheet(f"color: {'#f8fafc' if theme == 'dark' else '#1e293b'};")
layout.addWidget(colon)
# 분 스핀박스
self.minute_spin = QSpinBox()
self.minute_spin.setRange(0, 59)
self.minute_spin.setValue(datetime.now().minute)
self.minute_spin.setFixedWidth(50)
self.minute_spin.setStyleSheet(spinbox_style)
layout.addWidget(self.minute_spin)
layout.addStretch()
return container
def _create_column_spinbox(self) -> QWidget:
"""열번 입력 스핀박스 생성 (4자리)"""
from PySide6.QtWidgets import QHBoxLayout, QWidget, QSpinBox
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
spinbox_style = self._get_spinbox_style()
# 1000자리 (1~9)
self.column_1000 = QSpinBox()
self.column_1000.setRange(1, 9)
self.column_1000.setValue(1)
self.column_1000.setFixedWidth(40)
self.column_1000.setStyleSheet(spinbox_style)
self.column_1000.setToolTip("열차종별: 1,2=정기, 3,4=회송, 5,6=시운전, 7,8=구간, 9=임시")
layout.addWidget(self.column_1000)
# 100자리 (0~8)
self.column_100 = QSpinBox()
self.column_100.setRange(0, 8)
self.column_100.setValue(0)
self.column_100.setFixedWidth(40)
self.column_100.setStyleSheet(spinbox_style)
layout.addWidget(self.column_100)
# 10자리 (0~9)
self.column_10 = QSpinBox()
self.column_10.setRange(0, 9)
self.column_10.setValue(0)
self.column_10.setFixedWidth(40)
self.column_10.setStyleSheet(spinbox_style)
layout.addWidget(self.column_10)
# 1자리 (0~9)
self.column_1 = QSpinBox()
self.column_1.setRange(0, 9)
self.column_1.setValue(1)
self.column_1.setFixedWidth(40)
self.column_1.setStyleSheet(spinbox_style)
self.column_1.setToolTip("홀수=상행, 짝수=하행")
layout.addWidget(self.column_1)
# 열번 변경 시 발생시각 자동 추정
def on_column_changed():
self._estimate_time_from_column_station()
self.column_1000.valueChanged.connect(on_column_changed)
self.column_100.valueChanged.connect(on_column_changed)
self.column_10.valueChanged.connect(on_column_changed)
self.column_1.valueChanged.connect(on_column_changed)
layout.addStretch()
return container
def _create_car_spinbox(self) -> QWidget:
"""호차 입력 스핀박스 생성 (1~8)"""
from PySide6.QtWidgets import QHBoxLayout, QWidget, QSpinBox, QLabel
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
spinbox_style = self._get_spinbox_style()
self.car_spin = QSpinBox()
self.car_spin.setRange(1, 8)
self.car_spin.setValue(1)
self.car_spin.setFixedWidth(50)
self.car_spin.setStyleSheet(spinbox_style)
layout.addWidget(self.car_spin)
theme = self.config.theme
suffix = QLabel("호차")
suffix.setStyleSheet(f"color: {'#94a3b8' if theme == 'dark' else '#64748b'};")
layout.addWidget(suffix)
layout.addStretch()
return container
def _get_spinbox_style(self) -> str:
"""스핀박스 스타일 반환"""
theme = self.config.theme
if theme == 'dark':
return """
QSpinBox {
background-color: #334155;
color: #f8fafc;
border: 1px solid #475569;
border-radius: 4px;
padding: 4px;
font-size: 12px;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #475569;
border: none;
width: 14px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #64748b;
}
"""
else:
return """
QSpinBox {
background-color: #f8fafc;
color: #1e293b;
border: 1px solid #cbd5e1;
border-radius: 4px;
padding: 4px;
font-size: 12px;
}
QSpinBox::up-button, QSpinBox::down-button {
background-color: #e2e8f0;
border: none;
width: 14px;
}
QSpinBox::up-button:hover, QSpinBox::down-button:hover {
background-color: #cbd5e1;
}
"""
def _setup_tab_order(self):
"""탭 순서 설정"""
# 고장내용 -> 날짜 -> 시간 -> 열번 -> 편성 -> 호차 -> 발생역 -> 장치분류 -> 고장코드
self.setTabOrder(self.fault_content_input, self.hour_spin)
self.setTabOrder(self.hour_spin, self.minute_spin)
self.setTabOrder(self.minute_spin, self.column_1000)
self.setTabOrder(self.column_1000, self.column_100)
self.setTabOrder(self.column_100, self.column_10)
self.setTabOrder(self.column_10, self.column_1)
self.setTabOrder(self.column_1, self.car_spin)
self.setTabOrder(self.car_spin, self.station_input)
self.setTabOrder(self.station_input, self.device_input)
self.setTabOrder(self.device_input, self.code_input)
def _close_current_popup(self):
"""현재 열린 팝업 닫기"""
if self._current_popup:
# BaseDialog인 경우 accept() 또는 close() 사용
if hasattr(self._current_popup, 'accept'):
self._current_popup.accept()
elif hasattr(self._current_popup, 'hide_popup'):
self._current_popup.hide_popup()
else:
self._current_popup.close()
self._current_popup = None
def _show_date_picker(self):
"""날짜 선택기 표시"""
from ui.components.custom_calendar import CustomCalendar
from ui.components.popup_widget import PopupWidget
self._close_current_popup()
popup = PopupWidget(self, title="발생일 선택", width=300, auto_hide=False)
self._current_popup = popup
calendar = CustomCalendar(show_range_toggle=False, show_time=False)
# 현재 선택된 날짜 설정
try:
current_date_str = self.date_label.text()
current_date = datetime.strptime(current_date_str, "%Y-%m-%d").date()
calendar.set_date(current_date)
except Exception:
calendar.set_date(date.today())
def on_date_selected(selected_date):
self.date_label.setText(selected_date.strftime("%Y-%m-%d"))
# 날짜 변경 시 시간 재추정
self._estimate_time_from_column_station()
if hasattr(popup, 'hide_popup'):
popup.hide_popup()
else:
popup.close()
self._current_popup = None
calendar.date_selected.connect(on_date_selected)
popup.content_layout.addWidget(calendar)
# 팝업 위치
label_pos = self.date_label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x(), label_pos.y() + self.date_label.height() + 5)
popup.show_at(popup_pos)
def _estimate_time_from_column_station(self):
"""열번과 역명으로 발생시각 자동 추정"""
try:
from core.dia_data_loader import estimate_time_by_column_station
# 열번 조합
column_number = f"{self.column_1000.value()}{self.column_100.value()}{self.column_10.value()}{self.column_1.value()}"
# 발생역 가져오기
station = self.station_input.text().strip()
if not station:
return
# 발생일자 가져오기
occurrence_date = None
try:
date_str = self.date_label.text()
occurrence_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except Exception:
occurrence_date = date.today()
# 시간 추정
estimated_time = estimate_time_by_column_station(
column_number, station, occurrence_date
)
if estimated_time:
self.hour_spin.setValue(estimated_time.hour)
self.minute_spin.setValue(estimated_time.minute)
except Exception as e:
logger.debug(f"시간 추정 실패: {e}")
def _show_train_popup(self):
"""편성 선택 팝업 표시 (칩 방식) - train_formations 테이블 사용"""
from ui.components.chips.choice_chip_button import ChoiceChipButton
from ui.base.base_dialog import BaseDialog
from PySide6.QtWidgets import (
QGridLayout, QWidget, QButtonGroup, QScrollArea,
QHBoxLayout, QVBoxLayout, QLabel
)
self._close_current_popup()
popup = BaseDialog(
self,
title="편성 선택",
width=500,
height=500,
min_width=400,
min_height=400,
modal=True,
frameless=True,
resizable=True
)
self._current_popup = popup
# 데이터베이스에서 편성 목록 가져오기
from database.common_db_manager import CommonDatabaseManager
db = CommonDatabaseManager()
all_formations = db.fetch_all(
"SELECT train_number, depot FROM train_formations ORDER BY train_number"
)
# 배속지 필터 영역 (필터 칩 사용)
filter_widget = QWidget()
filter_layout = QVBoxLayout(filter_widget)
filter_layout.setContentsMargins(0, 0, 0, 0)
filter_layout.setSpacing(8)
filter_label = QLabel("배속지:")
filter_label.setStyleSheet("color: #e0e0e0; font-size: 11pt; font-weight: bold;")
filter_layout.addWidget(filter_label)
depot_chip_container = QWidget()
depot_chip_layout = QHBoxLayout(depot_chip_container)
depot_chip_layout.setContentsMargins(0, 0, 0, 0)
depot_chip_layout.setSpacing(8)
depot_filter_group = QButtonGroup()
depot_filter_group.setExclusive(True)
selected_depot = ["전체"] # 선택된 배속지 저장 (리스트로 참조 전달)
def create_depot_chip(text: str, key: str):
chip = ChoiceChipButton(text=text, key=key, bg="#3b82f6")
depot_filter_group.addButton(chip)
depot_chip_layout.addWidget(chip)
if key == "전체":
chip.setChecked(True)
def make_depot_handler(depot_key: str):
def handler():
selected_depot[0] = depot_key
# 선택된 칩 색상 변경
for btn in depot_filter_group.buttons():
if isinstance(btn, ChoiceChipButton):
if btn.key == depot_key:
btn.set_bg("#64748b")
else:
btn.set_bg("#3b82f6")
update_chips(depot_key)
return handler
chip.clicked_key.connect(make_depot_handler(key))
return chip
create_depot_chip("전체", "전체")
create_depot_chip("신평", "신평")
create_depot_chip("노포", "노포")
depot_chip_layout.addStretch()
filter_layout.addWidget(depot_chip_container)
popup.content_layout.addWidget(filter_widget)
# 스크롤 가능한 영역
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setMaximumHeight(350)
scroll.setStyleSheet("QScrollArea { border: none; background-color: transparent; }")
chip_container = QWidget()
chip_layout = QGridLayout(chip_container)
chip_layout.setContentsMargins(8, 8, 8, 8)
chip_layout.setSpacing(4)
current_train = self._selected_train or ""
def update_chips(depot_filter_value: str = "전체"):
"""칩 목록 업데이트"""
# 기존 칩 제거
while chip_layout.count():
item = chip_layout.takeAt(0)
if item:
widget = item.widget()
if widget:
widget.deleteLater()
# 버튼 그룹 초기화
button_group = QButtonGroup()
button_group.setExclusive(True)
# 필터링된 편성 목록
if depot_filter_value == "전체":
filtered_formations = all_formations
else:
filtered_formations = [
f for f in all_formations
if f.get('depot') == depot_filter_value
]
# 선택된 편성을 앞쪽으로 정렬
if current_train:
selected_formations = [
f for f in filtered_formations
if f.get('train_number') == current_train
]
unselected_formations = [
f for f in filtered_formations
if f.get('train_number') != current_train
]
# 선택된 편성을 앞에, 나머지를 뒤에 배치
sorted_formations = selected_formations + unselected_formations
else:
sorted_formations = filtered_formations
# 편성번호를 그리드로 배치 (5열)
col_count = 5
for idx, formation in enumerate(sorted_formations):
train_number = formation.get('train_number', '')
if not train_number:
continue
row_idx = idx // col_count
col_idx = idx % col_count
is_selected = train_number == current_train
chip_bg = "#64748b" if is_selected else "#3b82f6"
chip = ChoiceChipButton(text=train_number, key=train_number, bg=chip_bg)
chip.setMinimumWidth(70)
button_group.addButton(chip)
chip_layout.addWidget(chip, row_idx, col_idx)
if is_selected:
chip.setChecked(True)
# 클로저 문제 해결: train_number를 명시적으로 캡처
def make_handler(tn: str):
def handler():
self._selected_train = tn
self.train_label.setText(tn)
popup.accept()
self._current_popup = None
return handler
chip.clicked_key.connect(make_handler(train_number))
# 빈 공간이 있으면 stretch 추가
chip_layout.setRowStretch(chip_layout.rowCount(), 1)
# 초기 칩 로드
update_chips()
scroll.setWidget(chip_container)
popup.content_layout.addWidget(scroll, 1)
# 다이얼로그 스타일 적용
popup.setStyleSheet("""
QDialog {
background-color: #0a0a0a;
}
#dialogContainer {
background-color: #1a1a1a;
border: 1px solid #333333;
border-radius: 16px;
}
QLabel {
color: #e0e0e0;
font-family: 'GmarketSans';
font-size: 11pt;
}
QScrollArea {
border: none;
background-color: transparent;
}
""")
# 팝업 위치 (화면 중앙)
popup.exec()
def _show_source_popup(self):
"""고장출처 선택 팝업 표시"""
from ui.components.chips.choice_chip_button import ChoiceChipButton
from ui.components.popup_widget import PopupWidget
from PySide6.QtWidgets import QVBoxLayout, QWidget, QButtonGroup
self._close_current_popup()
popup = PopupWidget(self, title="고장출처 선택", width=180, auto_hide=False)
self._current_popup = popup
button_group = QButtonGroup()
button_group.setExclusive(True)
chip_container = QWidget()
chip_layout = QVBoxLayout(chip_container)
chip_layout.setContentsMargins(0, 0, 0, 0)
chip_layout.setSpacing(4)
current_source = self._selected_source or ""
for source in FAULT_SOURCES:
is_selected = source == current_source
chip_bg = "#64748b" if is_selected else "#8b5cf6"
chip = ChoiceChipButton(text=source, key=source, bg=chip_bg)
chip.setMinimumWidth(150)
button_group.addButton(chip)
chip_layout.addWidget(chip)
if is_selected:
chip.setChecked(True)
def make_handler(source_key: str):
def handler():
self._selected_source = source_key
self.source_label.setText(source_key)
popup.hide_popup()
self._current_popup = None
return handler
chip.clicked_key.connect(make_handler(source))
popup.content_layout.addWidget(chip_container)
label_pos = self.source_label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x(), label_pos.y() + self.source_label.height() + 5)
popup.show_at(popup_pos)
def _show_team_popup(self):
"""조치팀 선택 팝업 표시"""
from ui.components.chips.choice_chip_button import ChoiceChipButton
from ui.components.popup_widget import PopupWidget
from core.constants import TEAMS
from PySide6.QtWidgets import QHBoxLayout, QWidget, QButtonGroup
self._close_current_popup()
popup = PopupWidget(self, title="조치팀 선택", width=280, auto_hide=False)
self._current_popup = popup
button_group = QButtonGroup()
button_group.setExclusive(True)
chip_container = QWidget()
chip_layout = QHBoxLayout(chip_container)
chip_layout.setContentsMargins(0, 0, 0, 0)
chip_layout.setSpacing(4)
current_team = self.team_label.text() if self.team_label.text() != "선택" else ""
for team in TEAMS:
is_selected = team == current_team
chip_bg = "#64748b" if is_selected else "#10b981"
chip = ChoiceChipButton(text=team, key=team, bg=chip_bg)
button_group.addButton(chip)
chip_layout.addWidget(chip)
if is_selected:
chip.setChecked(True)
def make_handler(team_key: str):
def handler():
self.team_label.setText(team_key)
popup.hide_popup()
self._current_popup = None
return handler
chip.clicked_key.connect(make_handler(team))
popup.content_layout.addWidget(chip_container)
label_pos = self.team_label.mapToGlobal(QPoint(0, 0))
popup_pos = QPoint(label_pos.x(), label_pos.y() + self.team_label.height() + 5)
popup.show_at(popup_pos)
def _load_record(self, record: Fault):
"""레코드 데이터 로드"""
# 날짜
if record.occurrence_date:
if isinstance(record.occurrence_date, date):
self.date_label.setText(record.occurrence_date.strftime("%Y-%m-%d"))
# 시간
if record.occurrence_time:
if isinstance(record.occurrence_time, time):
self.hour_spin.setValue(record.occurrence_time.hour)
self.minute_spin.setValue(record.occurrence_time.minute)
# 열번
column = record.column_number or ""
if len(column) == 4 and column.isdigit():
self.column_1000.setValue(int(column[0]))
self.column_100.setValue(int(column[1]))
self.column_10.setValue(int(column[2]))
self.column_1.setValue(int(column[3]))
# 편성번호
if record.train_number:
self._selected_train = record.train_number
self.train_label.setText(record.train_number)
# 호차
if record.car_number:
try:
self.car_spin.setValue(int(record.car_number))
except ValueError:
pass
# 발생역
self.station_input.setText(record.occurrence_station or "")
# 장치분류
if record.device_category:
self.device_input.set_selected_value(record.device_category)
# 고장코드
self.code_input.setText(record.fault_code or "")
# 고장출처
if record.fault_source:
self._selected_source = record.fault_source
self.source_label.setText(record.fault_source)
# 조치팀
if record.action_team:
self.team_label.setText(record.action_team)
# 고장내용
self.fault_content_input.set_text(record.fault_content or "")
# 조치 단계 로드
self._load_action_steps(record.id if record.id else None)
def get_data(self) -> dict:
"""입력 데이터 반환"""
# 날짜
try:
occurrence_date = datetime.strptime(self.date_label.text(), "%Y-%m-%d").date()
except ValueError:
occurrence_date = date.today()
# 시간
occurrence_time = time(self.hour_spin.value(), self.minute_spin.value())
# 열번
column_number = f"{self.column_1000.value()}{self.column_100.value()}{self.column_10.value()}{self.column_1.value()}"
# 조치팀
action_team = self.team_label.text() if self.team_label.text() != "선택" else ""
return {
"created_date": date.today().isoformat(),
"created_team": self.config.current_team,
"occurrence_date": occurrence_date.isoformat(),
"occurrence_time": occurrence_time.isoformat(),
"column_number": column_number,
"train_number": self._selected_train or "",
"car_number": str(self.car_spin.value()),
"device_category": self.device_input.get_selected_value() or "",
"fault_code": self.code_input.text(),
"occurrence_station": self.station_input.text(),
"fault_source": self._selected_source or "",
"action_team": action_team,
"fault_content": self.fault_content_input.get_text(),
"action_content": "", # 조치 단계로 관리하므로 빈 문자열
}
def validate(self) -> bool:
"""입력 유효성 검사"""
if not self._selected_train:
self.signals.status_message.emit("편성번호를 선택해주세요.", 3000)
return False
if not self.fault_content_input.get_text().strip():
self.signals.status_message.emit("고장내용을 입력해주세요.", 3000)
return False
return True
def _add_action_step(self):
"""조치 단계 추가"""
from ui.dialogs.action_step_dialog import ActionStepDialog
dialog = ActionStepDialog(self)
if dialog.exec():
step_data = dialog.get_data()
step_number = len(self.action_steps) + 1
self.action_steps.append({
'step_number': step_number,
'action_content': step_data.get('action_content', ''),
'action_team': step_data.get('action_team', ''),
'created_at': datetime.now()
})
self._refresh_action_steps_display()
def _edit_action_step(self, step_index: int):
"""조치 단계 편집"""
from ui.dialogs.action_step_dialog import ActionStepDialog
if step_index < 0 or step_index >= len(self.action_steps):
return
step_data = self.action_steps[step_index]
dialog = ActionStepDialog(self, step_data)
if dialog.exec():
updated_data = dialog.get_data()
self.action_steps[step_index].update({
'action_content': updated_data.get('action_content', ''),
'action_team': updated_data.get('action_team', ''),
})
self._refresh_action_steps_display()
def _delete_action_step(self, step_index: int):
"""조치 단계 삭제"""
if step_index < 0 or step_index >= len(self.action_steps):
return
from PySide6.QtWidgets import QMessageBox
reply = QMessageBox.question(
self,
"삭제 확인",
"정말 이 조치 단계를 삭제하시겠습니까?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.action_steps.pop(step_index)
# 단계 번호 재정렬
for i, step in enumerate(self.action_steps):
step['step_number'] = i + 1
self._refresh_action_steps_display()
def _refresh_action_steps_display(self):
"""조치 단계 표시 새로고침"""
# 기존 위젯 제거
while self.action_steps_layout.count() > 1: # stretch 제외
item = self.action_steps_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
# 조치 단계 위젯 추가
for idx, step in enumerate(self.action_steps):
step_widget = self._create_action_step_widget(idx, step)
self.action_steps_layout.insertWidget(idx, step_widget)
def _create_action_step_widget(self, step_index: int, step_data: dict) -> QWidget:
"""조치 단계 위젯 생성"""
from PySide6.QtWidgets import QFrame, QHBoxLayout, QVBoxLayout, QPushButton, QLabel
step_widget = QFrame()
step_widget.setFrameShape(QFrame.Box)
step_layout = QVBoxLayout(step_widget)
step_layout.setContentsMargins(12, 8, 12, 8)
step_layout.setSpacing(4)
theme = self.config.theme
bg_color = "#1e293b" if theme == 'dark' else "#f8fafc"
border_color = "#334155" if theme == 'dark' else "#e2e8f0"
text_color = "#f8fafc" if theme == 'dark' else "#1e293b"
secondary_color = "#94a3b8" if theme == 'dark' else "#64748b"
step_widget.setStyleSheet(f"""
QFrame {{
background-color: {bg_color};
border: 1px solid {border_color};
border-radius: 8px;
}}
""")
# 헤더 (단계 번호, 조치팀, 시간, 버튼)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(8)
step_number_label = QLabel(f"단계 {step_data.get('step_number', step_index + 1)}")
step_number_label.setStyleSheet(f"color: {text_color}; font-weight: bold; font-size: 11pt;")
header_layout.addWidget(step_number_label)
team_label = QLabel(step_data.get('action_team', ''))
team_label.setStyleSheet(f"color: {secondary_color}; font-size: 10pt;")
header_layout.addWidget(team_label)
created_at = step_data.get('created_at')
if isinstance(created_at, datetime):
time_str = created_at.strftime("%Y-%m-%d %H:%M")
elif isinstance(created_at, str):
time_str = created_at
else:
time_str = datetime.now().strftime("%Y-%m-%d %H:%M")
time_label = QLabel(time_str)
time_label.setStyleSheet(f"color: {secondary_color}; font-size: 9pt;")
header_layout.addWidget(time_label)
header_layout.addStretch()
# 편집/삭제 버튼 (ClickableLabel로 변경)
edit_label = ClickableLabel("편집", enable_hover=True)
edit_label.setCursor(Qt.PointingHandCursor)
edit_label.clicked.connect(lambda: self._edit_action_step(step_index))
edit_label.setStyleSheet(f"""
QLabel {{
background-color: #64748b;
color: white;
border: none;
border-radius: 4px;
padding: 2px 8px;
font-size: 10pt;
font-weight: 500;
}}
QLabel:hover {{
background-color: #475569;
}}
""")
header_layout.addWidget(edit_label)
delete_label = ClickableLabel("삭제", enable_hover=True)
delete_label.setCursor(Qt.PointingHandCursor)
delete_label.clicked.connect(lambda: self._delete_action_step(step_index))
delete_label.setStyleSheet(f"""
QLabel {{
background-color: #ef4444;
color: white;
border: none;
border-radius: 4px;
padding: 2px 8px;
font-size: 10pt;
font-weight: 500;
}}
QLabel:hover {{
background-color: #dc2626;
}}
""")
header_layout.addWidget(delete_label)
step_layout.addLayout(header_layout)
# 조치 내용
content_label = QLabel(step_data.get('action_content', ''))
content_label.setWordWrap(True)
content_label.setStyleSheet(f"color: {text_color}; font-size: 11pt; padding: 4px 0;")
step_layout.addWidget(content_label)
return step_widget
def _load_action_steps(self, fault_id: Optional[int]):
"""조치 단계 로드"""
self.action_steps = []
if not fault_id:
return
db = DatabaseManager()
steps = db.fetch_all(
"SELECT * FROM action_steps WHERE fault_id = ? ORDER BY step_number",
(fault_id,)
)
for step in steps:
created_at_str = step.get('created_at')
if isinstance(created_at_str, str):
try:
created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
except:
created_at = datetime.now()
else:
created_at = datetime.now()
self.action_steps.append({
'id': step.get('id'),
'step_number': step.get('step_number', 0),
'action_content': step.get('action_content', ''),
'action_team': step.get('action_team', ''),
'created_at': created_at
})
self._refresh_action_steps_display()
def _save_action_steps(self, fault_id: int):
"""조치 단계 저장"""
db = DatabaseManager()
# 기존 조치 단계 삭제
db.execute("DELETE FROM action_steps WHERE fault_id = ?", (fault_id,))
# 새로운 조치 단계 저장
for step in self.action_steps:
db.execute("""
INSERT INTO action_steps (fault_id, step_number, action_content, action_team)
VALUES (?, ?, ?, ?)
""", (
fault_id,
step.get('step_number', 0),
step.get('action_content', ''),
step.get('action_team', '')
))