handOver2/ui/dialogs/history_dialog.py

1777 lines
69 KiB
Python

# -*- coding: utf-8 -*-
"""
HistoryDialog (재설계 전체 코드)
요구사항 반영:
1) 검색: 라벨 제거, 1줄 입력 + 돋보기/리셋 아이콘 버튼, 검색 칩 내장(검색도 카드로 처리)
2) 각 필터: 카드(QFrame) + 카드 클릭 시 QMenu 팝업(별도 다이얼로그 없음)
3) 활성 필터 칩: "별도 영역" 제거, 각 카드 내부에 표시
4) 제목 섹션 삭제
5) 날짜 필터: QMenu 내부에 캘린더(기간 선택) 위젯 완전 구현 (단일 캘린더 범위 선택 + 하단 적용/해제)
6) 장치/편성 필터: 트리(계통→장치→부품) + 체크 + 검색, 선택 즉시 칩 반영
주의:
- 본 코드는 HistoryDialog 단일 파일로 "바로 복붙" 가능한 형태를 목표로 했습니다.
- 다만, 프로젝트에 이미 존재하는 클래스/모듈:
BaseDialog, StyleManager, FlowLayout, FilterChipButton(=filter_chip_button.py), CRUDManager, models 등은
기존 프로젝트 구조를 그대로 사용합니다.
- 장치/편성 트리 데이터는 "예시 데이터"를 기본 제공하며,
실제 DB/모델 기반으로 채우도록 _load_device_tree_data(), _load_train_tree_data()에서 교체하세요.
"""
from __future__ import annotations
from typing import List, Type, Dict, Any, Optional, Callable, Tuple
from datetime import date, datetime, timedelta
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFrame, QLabel, QLineEdit, QToolButton,
QSplitter, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView,
QAbstractItemView, QMenu, QWidgetAction, QPushButton, QCalendarWidget,
QTreeWidget, QTreeWidgetItem, QSizePolicy
)
from PySide6.QtCore import Qt, Signal, QPoint, QTimer, QDate
from PySide6.QtGui import QAction, QTextCharFormat, QColor, QPainter, QFont
from ui.base.base_dialog import BaseDialog
from ui.styles.style_manager import StyleManager
from ui.components.flow_layout import FlowLayout
from ui.components.chips.filter_chip_button import FilterChipButton
from ui.components.chips.chip_base_button import ChipTheme
from ui.widgets.clickableLabel import ClickableLabel
from database.crud import CRUDManager
from database.models import BaseModel, SectionBase
from core.constants import TEAMS
from core.logger import get_logger
import json
logger = get_logger(__name__)
# =========================================================
# 공용: 카드 베이스 (클릭하면 QMenu 팝업)
# =========================================================
class FilterCard(QFrame):
"""
필터 카드:
- 카드 자체 클릭 시 QMenu 팝업을 카드 하단에 띄움
- 내부에 chips_area(FlowLayout) 포함
- 제목은 ClickableLabel로 클릭 가능하며 hover 시 힌트 표시
"""
# 필터 타입별 배경색 (다크 테마 기준)
FILTER_COLORS = {
"date": "#1e3a5f", # 날짜: 진한 파란색
"team": "#3d2e4f", # 팀: 보라색 계열
"status": "#2d4a2d", # 상태: 초록색 계열
"device": "#4a3d2e", # 장치: 갈색 계열
"train": "#3d4a5f", # 편성: 청록색 계열
"default": "#1e293b", # 기본: 다크 그레이
}
# 필터 타입별 사용방법 힌트
FILTER_HINTS = {
"date": "날짜 필터\n• 날짜를 2번 클릭하여 기간 선택\n• 빠른 기간 버튼(30일, 90일 등) 클릭 시 즉시 적용",
"team": "팀 필터\n• 체크박스로 여러 팀 선택 가능\n• 선택 즉시 필터 적용",
"status": "상태 필터\n• 완료/진행중 중 선택\n• 선택 즉시 필터 적용",
"device": "장치 필터\n• 트리 구조에서 장치 선택\n• 검색 기능으로 빠르게 찾기\n• 선택 즉시 필터 적용",
"train": "편성 필터\n• 트리 구조에서 편성 선택\n• 검색 기능으로 빠르게 찾기\n• 선택 즉시 필터 적용",
"default": "필터 카드\n• 카드 클릭 시 필터 메뉴 표시\n• 선택한 필터는 칩으로 표시",
}
def __init__(
self,
title: str,
icon: str,
style_manager: StyleManager,
popup_builder: Optional[Callable[[QMenu], None]] = None,
filter_type: str = "default",
parent: Optional[QWidget] = None
):
super().__init__(parent)
self.style_manager = style_manager
self._popup_builder = popup_builder
self.filter_type = filter_type
self.setObjectName(f"FilterCard_{filter_type}")
self.setCursor(Qt.PointingHandCursor)
self._build_ui(title, icon)
self._apply_style()
def _build_ui(self, title: str, icon: str):
lay = QVBoxLayout(self)
lay.setContentsMargins(10, 10, 10, 10)
lay.setSpacing(8)
header = QWidget(self)
header_lay = QHBoxLayout(header)
header_lay.setContentsMargins(0, 0, 0, 0)
header_lay.setSpacing(8)
self.icon_label = QLabel(icon)
self.icon_label.setFixedWidth(18)
self.icon_label.setAlignment(Qt.AlignCenter)
# 제목을 ClickableLabel로 변경
self.title_label = ClickableLabel(
title,
parent=self,
enable_click=True,
enable_double_click=False,
enable_hover=True
)
self.title_label.setAlignment(Qt.AlignVCenter | Qt.AlignLeft)
# hover 시 힌트 표시
hint_text = self.FILTER_HINTS.get(self.filter_type, self.FILTER_HINTS["default"])
self.title_label.set_hover_text(hint_text)
# 클릭 시 팝업 표시
self.title_label.clicked.connect(self.show_popup)
header_lay.addWidget(self.icon_label, 0)
header_lay.addWidget(self.title_label, 1)
lay.addWidget(header)
self.chips_area = QWidget(self)
self.chips_area.setObjectName("CardChipsArea")
self.chips_area.setMinimumHeight(28)
self.flow_layout = FlowLayout(self.chips_area, margin=0, h_spacing=6, v_spacing=6)
lay.addWidget(self.chips_area)
self.empty_hint = QLabel("없음")
self.empty_hint.setObjectName("CardEmptyHint")
self.empty_hint.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
lay.addWidget(self.empty_hint)
self.sync_empty_hint()
def _apply_style(self):
colors = self.style_manager.get_colors()
# 필터 카드는 작은 공간에 들어가므로 더 작은 폰트 사용
label_font = self.style_manager.get_font("dialog", "label")
# 폰트 크기를 10pt로 제한 (다이얼로그 기본 12pt보다 작게)
card_font_size = min(10, label_font.pointSize() - 2)
# 필터 타입별 배경색 가져오기
bg_color = self.FILTER_COLORS.get(self.filter_type, self.FILTER_COLORS["default"])
# 다크/라이트 테마에 따라 색상 조정
theme = self.style_manager.config.theme
if theme == "light":
# 라이트 테마: 색상을 밝게 조정
bg_color = self._lighten_color(bg_color)
self.setStyleSheet(f"""
QFrame#FilterCard_{self.filter_type} {{
background-color: {bg_color};
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
}}
QFrame#FilterCard_{self.filter_type}:hover {{
border: 1px solid rgba(255,255,255,0.30);
background-color: {self._adjust_brightness(bg_color, 1.15)};
}}
QFrame#FilterCard_{self.filter_type} QLabel {{
background: transparent;
color: {colors.get('text_primary', '#f0f0f0')};
font-family: '{label_font.family()}';
font-size: {card_font_size}pt;
min-height: {max(18, card_font_size + 4)}px;
}}
QLabel#CardEmptyHint {{
color: rgba(255,255,255,0.65);
font-size: 10px;
min-height: 16px;
}}
""")
def _lighten_color(self, hex_color: str) -> str:
"""라이트 테마용 색상을 밝게 조정"""
# 간단한 밝기 조정 (실제로는 HSL 변환 권장)
if hex_color.startswith("#"):
r = int(hex_color[1:3], 16)
g = int(hex_color[3:5], 16)
b = int(hex_color[5:7], 16)
# 밝게 조정
r = min(255, int(r * 2.5))
g = min(255, int(g * 2.5))
b = min(255, int(b * 2.5))
return f"#{r:02x}{g:02x}{b:02x}"
return hex_color
def _adjust_brightness(self, hex_color: str, factor: float) -> str:
"""색상 밝기 조정"""
if hex_color.startswith("#"):
r = int(hex_color[1:3], 16)
g = int(hex_color[3:5], 16)
b = int(hex_color[5:7], 16)
r = min(255, int(r * factor))
g = min(255, int(g * factor))
b = min(255, int(b * factor))
return f"#{r:02x}{g:02x}{b:02x}"
return hex_color
def set_popup_builder(self, builder: Callable[[QMenu], None]):
self._popup_builder = builder
def show_popup(self):
if not self._popup_builder:
return
menu = QMenu(self)
self._popup_builder(menu)
# 카드 하단에 표시
global_pos = self.mapToGlobal(QPoint(10, self.height() - 6))
menu.exec(global_pos)
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.show_popup()
event.accept()
return
super().mousePressEvent(event)
def sync_empty_hint(self):
# FlowLayout에 실제 칩이 있는지 여부는 외부에서 dict로 관리하므로,
# 여기서는 간단히 chips_area 내 자식 FilterChipButton 개수로 판단
chips = self.chips_area.findChildren(FilterChipButton)
self.empty_hint.setVisible(len(chips) == 0)
# =========================================================
# 검색 카드: 입력 + 돋보기/리셋 + 검색 칩 내장
# =========================================================
class SearchCard(QFrame):
"""
검색 카드:
- QLineEdit 1줄
- 돋보기/리셋 아이콘 버튼(QToolButton)
- 검색 칩 내장(FlowLayout)
"""
search_requested = Signal(str) # 검색 실행 요청
reset_requested = Signal() # 전체 초기화 요청(필터+검색)
search_cleared = Signal() # 검색만 제거 요청
# 최소 높이 상수
MIN_INPUT_HEIGHT = 32
MIN_BUTTON_SIZE = 28
MIN_CHIPS_HEIGHT = 32
MIN_CARD_HEIGHT = 100
def __init__(self, style_manager: StyleManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self.style_manager = style_manager
self.setObjectName("SearchCard")
self._build_ui()
self._apply_style()
def _build_ui(self):
lay = QVBoxLayout(self)
lay.setContentsMargins(12, 10, 12, 10)
lay.setSpacing(8)
# 상단 입력줄
row = QWidget(self)
row.setMinimumHeight(self.MIN_INPUT_HEIGHT)
row_lay = QHBoxLayout(row)
row_lay.setContentsMargins(0, 0, 0, 0)
row_lay.setSpacing(4)
self.input = QLineEdit()
self.input.setPlaceholderText("검색어 입력…")
self.input.setMinimumHeight(self.MIN_INPUT_HEIGHT)
self.input.returnPressed.connect(self._on_search_clicked)
row_lay.addWidget(self.input, 1)
self.btn_search = QToolButton()
self.btn_search.setText("🔍")
self.btn_search.setToolTip("검색")
self.btn_search.setMinimumSize(self.MIN_BUTTON_SIZE, self.MIN_BUTTON_SIZE)
self.btn_search.clicked.connect(self._on_search_clicked)
row_lay.addWidget(self.btn_search, 0)
self.btn_reset = QToolButton()
self.btn_reset.setText("")
self.btn_reset.setToolTip("초기화")
self.btn_reset.setMinimumSize(self.MIN_BUTTON_SIZE, self.MIN_BUTTON_SIZE)
self.btn_reset.clicked.connect(lambda: self.reset_requested.emit())
row_lay.addWidget(self.btn_reset, 0)
lay.addWidget(row)
# 검색 칩 영역 (검색 없음 라벨과 통합)
self.chips_container = QWidget(self)
self.chips_container.setMinimumHeight(self.MIN_CHIPS_HEIGHT)
chips_lay = QHBoxLayout(self.chips_container)
chips_lay.setContentsMargins(0, 0, 0, 0)
chips_lay.setSpacing(0)
self.chips_area = QWidget(self.chips_container)
self.flow_layout = FlowLayout(self.chips_area, margin=0, h_spacing=6, v_spacing=4)
chips_lay.addWidget(self.chips_area, 1)
lay.addWidget(self.chips_container)
self.empty_hint = QLabel("검색 없음")
self.empty_hint.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
self.empty_hint.setMinimumHeight(20)
lay.addWidget(self.empty_hint)
# 카드 최소 높이 설정
self.setMinimumHeight(self.MIN_CARD_HEIGHT)
self.sync_empty_hint()
def _apply_style(self):
colors = self.style_manager.get_colors()
label_font = self.style_manager.get_font("dialog", "label")
card_font_size = max(9, min(10, label_font.pointSize() - 2))
input_font = self.style_manager.get_font("dialog", "input")
input_font_size = max(10, min(11, input_font.pointSize() - 1))
self.setStyleSheet(f"""
QFrame#SearchCard {{
background-color: {colors['bg_secondary']};
border: 1px solid rgba(255,255,255,0.10);
border-radius: 10px;
}}
QFrame#SearchCard QLineEdit {{
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
background: rgba(0,0,0,0.2);
padding: 6px 10px;
color: {colors.get('text_primary', '#f0f0f0')};
font-family: '{input_font.family()}';
font-size: {input_font_size}pt;
min-height: {self.MIN_INPUT_HEIGHT - 12}px;
}}
QFrame#SearchCard QLineEdit:focus {{
border: 1px solid rgba(100,150,255,0.5);
}}
QFrame#SearchCard QToolButton {{
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
background: rgba(255,255,255,0.05);
padding: 4px;
color: {colors.get('text_primary', '#f0f0f0')};
font-size: {card_font_size + 2}pt;
min-width: {self.MIN_BUTTON_SIZE}px;
min-height: {self.MIN_BUTTON_SIZE}px;
}}
QFrame#SearchCard QToolButton:hover {{
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.2);
}}
QFrame#SearchCard QLabel {{
background: transparent;
color: {colors.get('text_secondary', '#888888')};
font-family: '{label_font.family()}';
font-size: {card_font_size}pt;
}}
""")
def _on_search_clicked(self):
text = self.input.text().strip()
self.search_requested.emit(text)
def clear_input(self):
self.input.clear()
def sync_empty_hint(self):
chips = self.chips_area.findChildren(FilterChipButton)
self.empty_hint.setVisible(len(chips) == 0)
self.chips_area.setVisible(len(chips) > 0)
# =========================================================
# 커스텀 캘린더 위젯: 날짜 아래 특정 문자 표시 지원
# =========================================================
class CustomCalendarWidget(QCalendarWidget):
"""
날짜 아래에 특정 문자를 표시할 수 있는 커스텀 캘린더 위젯
근무조는 날짜를 그릴 때마다 동적으로 계산됩니다.
"""
def __init__(self, parent=None):
super().__init__(parent)
# 날짜별 표시할 문자 딕셔너리: {date: "DC", ...}
# (수동으로 설정한 마커만 저장, 근무조는 동적 계산)
self.date_markers: Dict[date, str] = {}
# 근무조 계산 기준일 설정
self.BASE_DATE = date(2026, 1, 5) # 기준일: 2026-01-05 (주간 A조, 야간 B조 → AB)
self.BASE_DAY_SHIFT = "A" # 기준일의 주간조
self.BASE_NIGHT_SHIFT = "B" # 기준일의 야간조
# 주간조 순서: A → D → C → B (4일 주기)
self.DAY_SHIFTS = ["A", "D", "C", "B"]
# 야간조 순서: B → A → D → C (4일 주기)
self.NIGHT_SHIFTS = ["B", "A", "D", "C"]
# 기준일의 인덱스
self.base_day_idx = self.DAY_SHIFTS.index(self.BASE_DAY_SHIFT)
self.base_night_idx = self.NIGHT_SHIFTS.index(self.BASE_NIGHT_SHIFT)
def calculate_shift_marker(self, target_date: date) -> str:
"""
특정 날짜의 근무조 마커를 계산
Args:
target_date: 계산할 날짜
Returns:
근무조 마커 문자열 (예: "AB", "DA", "CD")
"""
# 기준일로부터 경과 일수 계산
days_diff = (target_date - self.BASE_DATE).days
# 주간조 계산 (4일 주기)
day_shift_idx = (self.base_day_idx + days_diff) % 4
day_shift = self.DAY_SHIFTS[day_shift_idx]
# 야간조 계산 (4일 주기)
night_shift_idx = (self.base_night_idx + days_diff) % 4
night_shift = self.NIGHT_SHIFTS[night_shift_idx]
# 마커 생성 (주간조 + 야간조)
return f"{day_shift}{night_shift}"
def set_date_marker(self, target_date: date, marker: str):
"""
특정 날짜에 마커 문자 설정
Args:
target_date: 날짜
marker: 표시할 문자 (예: "DC", "AB", "AC")
"""
self.date_markers[target_date] = marker
self.updateCells()
def set_date_markers(self, markers: Dict[date, str]):
"""
여러 날짜의 마커를 한번에 설정
Args:
markers: {date: "marker"} 딕셔너리
"""
self.date_markers.update(markers)
self.updateCells()
def clear_date_marker(self, target_date: date):
"""특정 날짜의 마커 제거"""
self.date_markers.pop(target_date, None)
self.updateCells()
def clear_all_markers(self):
"""모든 마커 제거"""
self.date_markers.clear()
self.updateCells()
def paintCell(self, painter: QPainter, rect, qdate: QDate):
"""날짜 셀 그리기 오버라이드"""
# 기본 그리기
super().paintCell(painter, rect, qdate)
# 날짜 아래에 마커 표시
target_date = qdate.toPython()
# 마커 결정: 수동 설정된 마커가 있으면 사용, 없으면 근무조 자동 계산
if target_date in self.date_markers:
marker = self.date_markers[target_date]
else:
# 근무조를 동적으로 계산
marker = self.calculate_shift_marker(target_date)
if marker:
painter.save()
# 더 큰 폰트로 마커 표시
marker_font = QFont(painter.font())
marker_font.setPointSize(9) # 7에서 9로 증가
marker_font.setBold(True) # 굵게 표시
painter.setFont(marker_font)
# 날짜 텍스트 아래 중앙에 마커 표시
# 날짜 숫자가 보통 상단 중앙에 있으므로, 그 아래에 마커 표시
marker_y = rect.top() + rect.height() * 0.65 # 날짜 아래 약간
marker_rect = rect.adjusted(0, int(marker_y - rect.top()), 0, 0)
painter.setPen(QColor(100, 150, 255)) # 파란색 계열
painter.drawText(marker_rect, Qt.AlignHCenter | Qt.AlignTop, marker)
painter.restore()
# =========================================================
# 날짜 범위 선택: QMenu 내부 위젯(단일 캘린더 범위 선택)
# =========================================================
class RangeCalendarWidget(QWidget):
"""
단일 캘린더에서 날짜 범위를 선택:
- 순서에 상관없이 2번 클릭 시 자동으로 기간 설정
- 첫 클릭이 종료일이어도 정상 동작
- 선택 범위를 캘린더에 하이라이트
"""
range_changed = Signal(object, object) # (date|None, date|None)
range_applied = Signal(object, object) # (date|None, date|None) - 범위 완성 시 자동 적용
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.start_date: Optional[date] = None
self.end_date: Optional[date] = None
self._click_count = 0 # 클릭 횟수 추적
self._fmt_normal = QTextCharFormat()
self._fmt_selected = QTextCharFormat()
self._fmt_selected.setBackground(QColor(80, 160, 255, 80)) # 반투명 하이라이트
self._fmt_endpoints = QTextCharFormat()
self._fmt_endpoints.setBackground(QColor(80, 160, 255, 140))
lay = QVBoxLayout(self)
lay.setContentsMargins(8, 8, 8, 8)
lay.setSpacing(8)
self.calendar = CustomCalendarWidget()
self.calendar.setGridVisible(True)
self.calendar.clicked.connect(self._on_clicked)
lay.addWidget(self.calendar, 1)
# 날짜별 마커 데이터 (예시: 실제로는 DB나 설정에서 가져와야 함)
self._date_markers: Dict[date, str] = {}
self.label = QLabel("범위를 선택하세요: 날짜를 2번 클릭하세요")
self.label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
lay.addWidget(self.label)
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
self.btn_today = QPushButton("오늘")
self.btn_30d = QPushButton("최근 30일")
self.btn_90d = QPushButton("최근 90일")
self.btn_180d = QPushButton("최근 180일")
self.btn_365d = QPushButton("최근 365일")
self.btn_clear = QPushButton("해제")
# 빠른 기간 버튼 클릭 시 바로 적용되도록 수정
self.btn_today.clicked.connect(lambda: self.set_range(date.today(), date.today(), auto_apply=True))
self.btn_30d.clicked.connect(lambda: self.set_range(date.today() - timedelta(days=29), date.today(), auto_apply=True))
self.btn_90d.clicked.connect(lambda: self.set_range(date.today() - timedelta(days=89), date.today(), auto_apply=True))
self.btn_180d.clicked.connect(lambda: self.set_range(date.today() - timedelta(days=179), date.today(), auto_apply=True))
self.btn_365d.clicked.connect(lambda: self.set_range(date.today() - timedelta(days=364), date.today(), auto_apply=True))
self.btn_clear.clicked.connect(lambda: self.clear_range())
btn_row.addWidget(self.btn_today)
btn_row.addWidget(self.btn_30d)
btn_row.addWidget(self.btn_90d)
btn_row.addWidget(self.btn_180d)
btn_row.addWidget(self.btn_365d)
btn_row.addStretch(1)
btn_row.addWidget(self.btn_clear)
lay.addLayout(btn_row)
def _on_clicked(self, qdate: QDate):
d = qdate.toPython() # datetime.date
# 순서에 상관없이 2번 클릭 시 자동으로 기간 설정
if self._click_count == 0:
# 첫 번째 클릭: 임시로 첫 날짜 저장
self.start_date = d
self.end_date = None
self._click_count = 1
elif self._click_count == 1:
# 두 번째 클릭: 두 날짜를 비교하여 작은 날짜를 시작일, 큰 날짜를 종료일로 설정
if d < self.start_date:
# 두 번째 클릭이 더 이전 날짜인 경우
self.end_date = self.start_date
self.start_date = d
else:
# 두 번째 클릭이 더 이후 날짜인 경우
self.end_date = d
self._click_count = 0 # 리셋하여 다음 범위 선택 준비
# 범위가 완성되었으므로 자동 적용 시그널 발생
self._refresh_formats()
self._update_label()
self.range_changed.emit(self.start_date, self.end_date)
self.range_applied.emit(self.start_date, self.end_date)
return
self._refresh_formats()
self._update_label()
self.range_changed.emit(self.start_date, self.end_date)
def set_range(self, start: date, end: date, auto_apply: bool = False):
self.start_date = start
self.end_date = end if end >= start else start
self._click_count = 0 # 클릭 횟수 리셋
self.calendar.setSelectedDate(QDate(self.end_date.year, self.end_date.month, self.end_date.day))
self._refresh_formats()
self._update_label()
self.range_changed.emit(self.start_date, self.end_date)
if auto_apply:
self.range_applied.emit(self.start_date, self.end_date)
def clear_range(self):
self.start_date = None
self.end_date = None
self._click_count = 0 # 클릭 횟수 리셋
self._refresh_formats()
self._update_label()
self.range_changed.emit(None, None)
def _update_label(self):
if not self.start_date and not self.end_date:
self.label.setText("범위를 선택하세요: 날짜를 2번 클릭하세요")
return
if self.start_date and not self.end_date:
self.label.setText(f"첫 번째 날짜: {self.start_date:%Y-%m-%d} (두 번째 날짜를 클릭하세요)")
return
if self.start_date and self.end_date:
self.label.setText(f"선택: {self.start_date:%Y-%m-%d} ~ {self.end_date:%Y-%m-%d}")
def _refresh_formats(self):
# 전체 포맷 초기화(성능: 월 단위만 처리)
# 현재 표시 월 기준으로 1~31만 초기화/적용
shown_year = self.calendar.yearShown()
shown_month = self.calendar.monthShown()
# 일단 그 달의 모든 날짜 포맷 초기화
for day in range(1, 32):
qd = QDate(shown_year, shown_month, day)
if qd.isValid():
self.calendar.setDateTextFormat(qd, self._fmt_normal)
if not self.start_date:
return
# start만 있는 경우
if self.start_date and not self.end_date:
qd = QDate(self.start_date.year, self.start_date.month, self.start_date.day)
if qd.isValid() and qd.year() == shown_year and qd.month() == shown_month:
self.calendar.setDateTextFormat(qd, self._fmt_endpoints)
return
# start~end 범위 하이라이트(보이는 달 안에서만)
if self.start_date and self.end_date:
cur = self.start_date
while cur <= self.end_date:
if cur.year == shown_year and cur.month == shown_month:
qd = QDate(cur.year, cur.month, cur.day)
if qd.isValid():
self.calendar.setDateTextFormat(qd, self._fmt_selected)
cur += timedelta(days=1)
# endpoints 강조
s = QDate(self.start_date.year, self.start_date.month, self.start_date.day)
e = QDate(self.end_date.year, self.end_date.month, self.end_date.day)
if s.isValid() and s.year() == shown_year and s.month() == shown_month:
self.calendar.setDateTextFormat(s, self._fmt_endpoints)
if e.isValid() and e.year() == shown_year and e.month() == shown_month:
self.calendar.setDateTextFormat(e, self._fmt_endpoints)
def get_range(self) -> Tuple[Optional[date], Optional[date]]:
return self.start_date, self.end_date
def set_date_markers(self, markers: Dict[date, str]):
"""
날짜별 마커 설정
Args:
markers: {date: "marker"} 딕셔너리 (예: {date(2024, 1, 15): "DC", ...})
"""
self._date_markers = markers
self.calendar.set_date_markers(markers)
def set_date_marker(self, target_date: date, marker: str):
"""
특정 날짜에 마커 설정
Args:
target_date: 날짜
marker: 표시할 문자 (예: "DC", "AB", "AC")
"""
self._date_markers[target_date] = marker
self.calendar.set_date_marker(target_date, marker)
def clear_date_marker(self, target_date: date):
"""특정 날짜의 마커 제거"""
self._date_markers.pop(target_date, None)
self.calendar.clear_date_marker(target_date)
def clear_all_markers(self):
"""모든 마커 제거"""
self._date_markers.clear()
self.calendar.clear_all_markers()
# =========================================================
# 트리 필터 팝업 위젯: 검색 + 트리(체크) + 선택 즉시 반영
# =========================================================
class TreeFilterWidget(QWidget):
"""
트리(계통→장치→부품) + 체크 + 검색
- 체크 변화 시 선택이 즉시 반영되도록 시그널 발생
"""
selection_changed = Signal(list) # 선택된 path 리스트 (["계통/장치/부품", ...])
def __init__(self, title: str = "필터", parent: Optional[QWidget] = None):
super().__init__(parent)
self.setObjectName("TreeFilterWidget")
self._block_signals = False
lay = QVBoxLayout(self)
lay.setContentsMargins(8, 8, 8, 8)
lay.setSpacing(8)
head = QLabel(title)
head.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
lay.addWidget(head)
self.search = QLineEdit()
self.search.setPlaceholderText("검색… (계통/장치/부품)")
self.search.textChanged.connect(self._apply_filter)
lay.addWidget(self.search)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.itemChanged.connect(self._on_item_changed)
self.tree.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
lay.addWidget(self.tree, 1)
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
self.btn_expand = QPushButton("펼치기")
self.btn_collapse = QPushButton("접기")
self.btn_clear = QPushButton("해제")
self.btn_expand.clicked.connect(lambda: self.tree.expandAll())
self.btn_collapse.clicked.connect(lambda: self.tree.collapseAll())
self.btn_clear.clicked.connect(self.clear_checks)
btn_row.addWidget(self.btn_expand)
btn_row.addWidget(self.btn_collapse)
btn_row.addStretch(1)
btn_row.addWidget(self.btn_clear)
lay.addLayout(btn_row)
def set_tree_data(self, data: Dict[str, Any]):
"""
data 예시:
{
"추진": {
"인버터": ["게이트드라이버", "IGBT"],
"전동기": ["베어링", "코일"]
},
"제동": {...}
}
"""
self._block_signals = True
self.tree.clear()
for system, devices in data.items():
root = QTreeWidgetItem([system])
root.setFlags(root.flags() | Qt.ItemIsUserCheckable)
root.setCheckState(0, Qt.Unchecked)
for device, parts in devices.items():
dev_item = QTreeWidgetItem([device])
dev_item.setFlags(dev_item.flags() | Qt.ItemIsUserCheckable)
dev_item.setCheckState(0, Qt.Unchecked)
for part in parts:
part_item = QTreeWidgetItem([part])
part_item.setFlags(part_item.flags() | Qt.ItemIsUserCheckable)
part_item.setCheckState(0, Qt.Unchecked)
dev_item.addChild(part_item)
root.addChild(dev_item)
self.tree.addTopLevelItem(root)
self.tree.expandToDepth(1)
self._block_signals = False
self._emit_selection()
def clear_checks(self):
self._block_signals = True
root_count = self.tree.topLevelItemCount()
for i in range(root_count):
root = self.tree.topLevelItem(i)
self._set_check_recursive(root, Qt.Unchecked)
self._block_signals = False
self._emit_selection()
def _set_check_recursive(self, item: QTreeWidgetItem, state: Qt.CheckState):
item.setCheckState(0, state)
for j in range(item.childCount()):
self._set_check_recursive(item.child(j), state)
def _on_item_changed(self, item: QTreeWidgetItem, col: int):
if self._block_signals:
return
self._block_signals = True
state = item.checkState(0)
# 아래로 전파
for j in range(item.childCount()):
self._set_check_recursive(item.child(j), state)
# 위로 집계(부모 체크 상태 반영)
self._update_parent_states(item)
self._block_signals = False
self._emit_selection()
def _update_parent_states(self, item: QTreeWidgetItem):
parent = item.parent()
if not parent:
return
checked = 0
partial = 0
total = parent.childCount()
for i in range(total):
c = parent.child(i).checkState(0)
if c == Qt.Checked:
checked += 1
elif c == Qt.PartiallyChecked:
partial += 1
if checked == total:
parent.setCheckState(0, Qt.Checked)
elif checked == 0 and partial == 0:
parent.setCheckState(0, Qt.Unchecked)
else:
parent.setCheckState(0, Qt.PartiallyChecked)
self._update_parent_states(parent)
def _emit_selection(self):
paths: List[str] = []
root_count = self.tree.topLevelItemCount()
for i in range(root_count):
root = self.tree.topLevelItem(i)
self._collect_checked_leaf_paths(root, [], paths)
self.selection_changed.emit(paths)
def _collect_checked_leaf_paths(self, item: QTreeWidgetItem, prefix: List[str], out: List[str]):
name = item.text(0)
new_prefix = prefix + [name]
if item.childCount() == 0:
# leaf
if item.checkState(0) == Qt.Checked:
out.append("/".join(new_prefix))
return
for i in range(item.childCount()):
self._collect_checked_leaf_paths(item.child(i), new_prefix, out)
def _apply_filter(self, text: str):
text = (text or "").strip().lower()
def match_item(it: QTreeWidgetItem) -> bool:
if text == "":
return True
# 현재 item + 자식 중 하나라도 매칭되면 표시
if text in it.text(0).lower():
return True
for k in range(it.childCount()):
if match_item(it.child(k)):
return True
return False
root_count = self.tree.topLevelItemCount()
for i in range(root_count):
root = self.tree.topLevelItem(i)
self._set_visible_recursive(root, match_item(root), text)
def _set_visible_recursive(self, item: QTreeWidgetItem, visible: bool, text: str):
item.setHidden(not visible)
# 자식도 판정
for i in range(item.childCount()):
child = item.child(i)
child_visible = True
if text:
# 자식 단독 매칭이거나 자식의 자식 매칭
child_visible = (text in child.text(0).lower())
for k in range(child.childCount()):
if self._any_match(child.child(k), text):
child_visible = True
break
self._set_visible_recursive(child, child_visible, text)
def _any_match(self, item: QTreeWidgetItem, text: str) -> bool:
if text in item.text(0).lower():
return True
for i in range(item.childCount()):
if self._any_match(item.child(i), text):
return True
return False
# =========================================================
# HistoryDialog 본체
# =========================================================
class HistoryDialog(BaseDialog):
"""
기록보기 다이얼로그 (재설계)
- 좌측: 검색 카드 + 필터 카드들(날짜/팀/상태/장치/편성)
- 우측: 탭(지시/고장/작업/기타) + 테이블
"""
def __init__(self, parent=None, table_name: str = "", model_class: Type[SectionBase] = None):
super().__init__(parent, title="기록보기", width=1200, height=800)
self.table_name = table_name
self.model_class = model_class
self.crud = CRUDManager()
self.style_manager = StyleManager()
# 필터 상태
self.active_filters: Dict[str, Any] = {}
self.search_text: str = ""
# 필터 칩 저장(키->chip 위젯)
self.filter_chips: Dict[str, FilterChipButton] = {}
# 디바운스(트리 체크 변경시 _load_data 난사 방지)
self._reload_timer = QTimer(self)
self._reload_timer.setSingleShot(True)
self._reload_timer.timeout.connect(self._load_data)
self._setup_ui()
self._load_data()
# ----------------------------
# 키 이벤트 처리 (엔터키로 다이얼로그 닫히는 것 방지)
# ----------------------------
def keyPressEvent(self, event):
"""엔터키로 다이얼로그가 닫히지 않도록 처리"""
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
# 엔터키는 무시 (검색 입력창에서는 search_requested 시그널로 처리됨)
event.accept()
return
super().keyPressEvent(event)
# ----------------------------
# UI 구성
# ----------------------------
def _setup_ui(self):
root = QWidget()
root_lay = QHBoxLayout(root)
root_lay.setContentsMargins(12, 12, 12, 12)
root_lay.setSpacing(10)
self.splitter = QSplitter(Qt.Horizontal)
self.splitter.setChildrenCollapsible(False)
root_lay.addWidget(self.splitter, 1)
# 좌측(필터)
left = QWidget()
left_lay = QVBoxLayout(left)
left_lay.setContentsMargins(0, 0, 0, 0)
left_lay.setSpacing(10)
# 검색 카드(칩 내장)
self.search_card = SearchCard(self.style_manager, parent=left)
self.search_card.search_requested.connect(self._on_search_requested)
self.search_card.reset_requested.connect(self._reset_filters)
left_lay.addWidget(self.search_card)
# 필터 카드들 (각각 다른 배경색과 힌트를 가짐)
self.card_date = FilterCard("날짜", "📅", self.style_manager, filter_type="date", parent=left)
self.card_team = FilterCard("", "👥", self.style_manager, filter_type="team", parent=left)
self.card_status = FilterCard("상태", "", self.style_manager, filter_type="status", parent=left)
self.card_device = FilterCard("장치", "🔧", self.style_manager, filter_type="device", parent=left)
self.card_train = FilterCard("편성", "🚆", self.style_manager, filter_type="train", parent=left)
logger.debug(f"CardTitle minH={self.title_label.minimumHeight()}, font={self.title_label.font().pointSize()}pt")
logger.debug(f"CardTitle minH={self.card_date.title_label.minimumHeight()}, font={self.card_date.title_label.font().pointSize()}pt")
logger.debug(f"CardTitle minH={self.card_team.title_label.minimumHeight()}, font={self.card_team.title_label.font().pointSize()}pt")
logger.debug(f"CardTitle minH={self.card_status.title_label.minimumHeight()}, font={self.card_status.title_label.font().pointSize()}pt")
logger.debug(f"CardTitle minH={self.card_device.title_label.minimumHeight()}, font={self.card_device.title_label.font().pointSize()}pt")
logger.debug(f"CardTitle minH={self.card_train.title_label.minimumHeight()}, font={self.card_train.title_label.font().pointSize()}pt")
# 팝업 빌더 연결(QMenu 내부 위젯)
self.card_date.set_popup_builder(self._build_date_popup_menu)
self.card_team.set_popup_builder(self._build_team_popup_menu)
self.card_status.set_popup_builder(self._build_status_popup_menu)
self.card_device.set_popup_builder(self._build_device_popup_menu)
self.card_train.set_popup_builder(self._build_train_popup_menu)
left_lay.addWidget(self.card_date)
left_lay.addWidget(self.card_team)
left_lay.addWidget(self.card_status)
left_lay.addWidget(self.card_device)
left_lay.addWidget(self.card_train)
left_lay.addStretch(1)
# 우측(내용)
right = QWidget()
right_lay = QVBoxLayout(right)
right_lay.setContentsMargins(0, 0, 0, 0)
right_lay.setSpacing(10)
self.tab_widget = QTabWidget()
right_lay.addWidget(self.tab_widget, 1)
self._create_section_tabs()
self.splitter.addWidget(left)
self.splitter.addWidget(right)
self.splitter.setStretchFactor(0, 1)
self.splitter.setStretchFactor(1, 4)
self.splitter.setSizes([320, 880])
self.content_layout.addWidget(root)
# ----------------------------
# 탭/테이블
# ----------------------------
def _create_section_tabs(self):
sections = [
("instructions", "지시"),
("faults", "고장"),
("works", "작업"),
("miscs", "기타"),
]
for table_name, label in sections:
tab = self._create_section_tab(table_name)
self.tab_widget.addTab(tab, label)
def _create_section_tab(self, table_name: str) -> QWidget:
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
table = QTableWidget()
table.setSelectionBehavior(QAbstractItemView.SelectRows)
table.setAlternatingRowColors(True)
table.setShowGrid(False)
table.verticalHeader().setVisible(False)
table.horizontalHeader().setStretchLastSection(True)
table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)
layout.addWidget(table, 1)
widget.table = table
widget.table_name = table_name
return widget
# =========================================================
# 칩 관리 공통
# =========================================================
def _chip_theme_for_type(self, filter_type: str) -> ChipTheme:
# 색상은 filter_chip_button.py 내부의 _get_default_color()를 사용하므로,
# 여기서는 size/shape만 통일
return ChipTheme(height=28, radius=14, padding_x=10, font_px=12)
def _add_chip(self, owner: str, filter_key: str, text: str, filter_type: str):
"""
owner: 'search' | 'date' | 'team' | 'status' | 'device' | 'train'
"""
# 기존 칩 제거
if filter_key in self.filter_chips:
self._remove_chip(filter_key)
card = self._get_owner_card(owner)
if not card:
return
chip = FilterChipButton(
text=text,
filter_key=filter_key,
filter_type=filter_type,
theme=self._chip_theme_for_type(filter_type),
parent=card.chips_area
)
chip.removed.connect(self._on_chip_removed)
card.flow_layout.addWidget(chip)
self.filter_chips[filter_key] = chip
card.sync_empty_hint()
def _remove_chip(self, filter_key: str):
chip = self.filter_chips.pop(filter_key, None)
if not chip:
return
parent = chip.parentWidget()
chip.setParent(None)
chip.deleteLater()
# 카드 힌트 갱신
for c in [self.search_card, self.card_date, self.card_team, self.card_status, self.card_device, self.card_train]:
if hasattr(c, "sync_empty_hint"):
c.sync_empty_hint()
if parent:
parent.updateGeometry()
parent.repaint()
def _get_owner_card(self, owner: str):
if owner == "search":
return self.search_card
if owner == "date":
return self.card_date
if owner == "team":
return self.card_team
if owner == "status":
return self.card_status
if owner == "device":
return self.card_device
if owner == "train":
return self.card_train
return None
def _on_chip_removed(self, filter_key: str):
# 라우팅: filter_key 규칙에 따라 해당 필터 제거
if filter_key == "search":
self._remove_search_filter()
return
if filter_key == "date":
self._remove_date_filter()
return
if filter_key == "status":
self._remove_status_filter()
return
if filter_key.startswith("team:"):
team = filter_key.split("team:", 1)[1]
self._remove_team_filter(team)
return
if filter_key.startswith("device:"):
path = filter_key.split("device:", 1)[1]
self._remove_device_path(path)
return
if filter_key.startswith("train:"):
path = filter_key.split("train:", 1)[1]
self._remove_train_path(path)
return
# 알 수 없는 키면 그냥 칩 제거
self._remove_chip(filter_key)
self._request_reload()
# =========================================================
# 검색
# =========================================================
def _on_search_requested(self, text: str):
# 검색 실행(빈 문자열이면 검색 제거)
if not text:
self._remove_search_filter()
return
self.search_text = text
self._add_chip("search", "search", f"검색: {text}", "search")
self.search_card.sync_empty_hint()
self._request_reload()
def _remove_search_filter(self):
self.search_text = ""
self.search_card.clear_input()
self._remove_chip("search")
self._request_reload()
# =========================================================
# 날짜 필터: QMenu 내부 캘린더 범위 선택
# =========================================================
def _build_date_popup_menu(self, menu: QMenu):
# QWidgetAction으로 RangeCalendarWidget 삽입 (적용/닫기 버튼 제거)
container = QWidget()
lay = QVBoxLayout(container)
lay.setContentsMargins(6, 6, 6, 6)
lay.setSpacing(6)
container.setMinimumHeight(400)
container.setMinimumWidth(600)
cal = RangeCalendarWidget(container)
# 기존 값 반영
df = self.active_filters.get("date_from")
dt = self.active_filters.get("date_to")
if isinstance(df, date) and isinstance(dt, date):
cal.set_range(df, dt)
elif isinstance(df, date) and dt is None:
cal.set_range(df, df)
# 날짜별 마커 데이터 로드 및 설정
date_markers = self._load_date_markers()
if date_markers:
cal.set_date_markers(date_markers)
# 범위가 완성되면(2번 클릭 또는 빠른 기간 버튼 클릭) 자동으로 적용하고 메뉴 닫기
def on_range_applied(d_from, d_to):
if d_from and d_to:
self._add_date_filter(d_from, d_to)
elif d_from and not d_to:
self._add_date_filter(d_from, d_from)
else:
self._remove_date_filter()
menu.close()
cal.range_applied.connect(on_range_applied)
lay.addWidget(cal, 1)
act = QWidgetAction(menu)
act.setDefaultWidget(container)
menu.addAction(act)
def _add_date_filter(self, date_from: Optional[date], date_to: Optional[date]):
# 저장
self.active_filters["date_from"] = date_from
self.active_filters["date_to"] = date_to
if date_from and date_to:
text = f"{date_from:%Y-%m-%d} ~ {date_to:%Y-%m-%d}"
elif date_from:
text = f"{date_from:%Y-%m-%d}"
else:
self._remove_date_filter()
return
self._add_chip("date", "date", text, "date")
self.card_date.sync_empty_hint()
self._request_reload()
def _remove_date_filter(self):
self.active_filters.pop("date_from", None)
self.active_filters.pop("date_to", None)
self._remove_chip("date")
self._request_reload()
# =========================================================
# 팀 필터: 체크 토글 메뉴
# =========================================================
def _build_team_popup_menu(self, menu: QMenu):
current = self.active_filters.get("team", [])
if not isinstance(current, list):
current = [current] if current else []
for team in TEAMS:
act = QAction(team, menu)
act.setCheckable(True)
act.setChecked(team in current)
def toggle(_, t=team):
if "team" not in self.active_filters:
self.active_filters["team"] = []
if t in self.active_filters["team"]:
self._remove_team_filter(t)
else:
self._add_team_filter(t)
act.triggered.connect(toggle)
menu.addAction(act)
menu.addSeparator()
clear = QAction("팀 필터 전체 해제", menu)
clear.triggered.connect(self._clear_team_filters)
menu.addAction(clear)
def _add_team_filter(self, team: str):
if "team" not in self.active_filters:
self.active_filters["team"] = []
if team not in self.active_filters["team"]:
self.active_filters["team"].append(team)
self._add_chip("team", f"team:{team}", team, "team")
self.card_team.sync_empty_hint()
self._request_reload()
def _remove_team_filter(self, team: str):
if "team" in self.active_filters and isinstance(self.active_filters["team"], list):
if team in self.active_filters["team"]:
self.active_filters["team"].remove(team)
if not self.active_filters["team"]:
self.active_filters.pop("team", None)
self._remove_chip(f"team:{team}")
self._request_reload()
def _clear_team_filters(self):
teams = self.active_filters.get("team", [])
if isinstance(teams, list):
for t in list(teams):
self._remove_team_filter(t)
# =========================================================
# 상태 필터
# =========================================================
def _build_status_popup_menu(self, menu: QMenu):
a_completed = QAction("완료", menu)
a_pending = QAction("진행중", menu)
a_clear = QAction("해제", menu)
a_completed.triggered.connect(lambda: self._add_status_filter("completed"))
a_pending.triggered.connect(lambda: self._add_status_filter("pending"))
a_clear.triggered.connect(self._remove_status_filter)
menu.addAction(a_completed)
menu.addAction(a_pending)
menu.addSeparator()
menu.addAction(a_clear)
def _add_status_filter(self, status: str):
self.active_filters["status"] = status
text = "완료" if status == "completed" else "진행중"
self._add_chip("status", "status", text, "status")
self.card_status.sync_empty_hint()
self._request_reload()
def _remove_status_filter(self):
self.active_filters.pop("status", None)
self._remove_chip("status")
self._request_reload()
# =========================================================
# 장치/편성: QMenu 내부 트리 위젯(검색+체크, 선택 즉시 칩 반영)
# =========================================================
def _build_device_popup_menu(self, menu: QMenu):
widget = TreeFilterWidget("장치 필터")
widget.set_tree_data(self._load_device_tree_data())
# 기존 선택 반영(leaf 경로 기준)
selected_paths = self.active_filters.get("device_paths", [])
if isinstance(selected_paths, list) and selected_paths:
self._apply_tree_checked_paths(widget.tree, selected_paths)
widget.selection_changed.connect(lambda paths: self._on_device_paths_changed(paths))
self._wrap_widget_in_menu(menu, widget, width=360, height=420)
def _build_train_popup_menu(self, menu: QMenu):
widget = TreeFilterWidget("편성 필터")
widget.set_tree_data(self._load_train_tree_data())
selected_paths = self.active_filters.get("train_paths", [])
if isinstance(selected_paths, list) and selected_paths:
self._apply_tree_checked_paths(widget.tree, selected_paths)
widget.selection_changed.connect(lambda paths: self._on_train_paths_changed(paths))
self._wrap_widget_in_menu(menu, widget, width=360, height=420)
def _wrap_widget_in_menu(self, menu: QMenu, widget: QWidget, width: int = 360, height: int = 420):
container = QFrame()
lay = QVBoxLayout(container)
lay.setContentsMargins(6, 6, 6, 6)
lay.addWidget(widget, 1)
# 닫기 버튼만 제공(선택은 즉시 반영)
btn_row = QHBoxLayout()
btn_row.addStretch(1)
btn_close = QPushButton("닫기")
btn_close.clicked.connect(menu.close)
btn_row.addWidget(btn_close)
lay.addLayout(btn_row)
container.setMinimumSize(width, height)
act = QWidgetAction(menu)
act.setDefaultWidget(container)
menu.addAction(act)
def _apply_tree_checked_paths(self, tree: QTreeWidget, paths: List[str]):
# paths: ["계통/장치/부품", ...]
# 트리 생성 직후에 체크를 적용하는 용도(최소 구현)
def traverse(item: QTreeWidgetItem, prefix: List[str]):
name = item.text(0)
new_prefix = prefix + [name]
if item.childCount() == 0:
full = "/".join(new_prefix)
if full in paths:
item.setCheckState(0, Qt.Checked)
return
for i in range(item.childCount()):
traverse(item.child(i), new_prefix)
root_count = tree.topLevelItemCount()
for i in range(root_count):
traverse(tree.topLevelItem(i), [])
def _on_device_paths_changed(self, paths: List[str]):
# 선택 즉시 칩 반영(전체 동기화)
self.active_filters["device_paths"] = paths
# 기존 device 칩 모두 제거 후 재생성
for k in list(self.filter_chips.keys()):
if k.startswith("device:"):
self._remove_chip(k)
for p in paths:
# 칩 텍스트는 "마지막 노드"만 표시(부품명), 필요하면 p 전체를 쓰세요.
text = p.split("/")[-1]
self._add_chip("device", f"device:{p}", text, "device")
self.card_device.sync_empty_hint()
self._request_reload(debounce_ms=150)
def _on_train_paths_changed(self, paths: List[str]):
self.active_filters["train_paths"] = paths
for k in list(self.filter_chips.keys()):
if k.startswith("train:"):
self._remove_chip(k)
for p in paths:
text = p.split("/")[-1]
self._add_chip("train", f"train:{p}", text, "team") # 편성은 별도 타입이 없다면 team 색 계열 재사용
self.card_train.sync_empty_hint()
self._request_reload(debounce_ms=150)
def _remove_device_path(self, path: str):
paths = self.active_filters.get("device_paths", [])
if isinstance(paths, list) and path in paths:
paths.remove(path)
if not paths:
self.active_filters.pop("device_paths", None)
self._remove_chip(f"device:{path}")
self._request_reload()
def _remove_train_path(self, path: str):
paths = self.active_filters.get("train_paths", [])
if isinstance(paths, list) and path in paths:
paths.remove(path)
if not paths:
self.active_filters.pop("train_paths", None)
self._remove_chip(f"train:{path}")
self._request_reload()
# ----------------------------
# 날짜 마커 데이터 로드
# ----------------------------
def _load_date_markers(self) -> Dict[date, str]:
"""
수동으로 설정된 날짜별 마커 데이터 로드
주의: 근무조 마커는 paintCell에서 동적으로 계산되므로,
여기서는 수동으로 설정한 특별한 마커만 반환합니다.
Returns:
{date: "marker"} 딕셔너리 (수동 설정된 마커만)
"""
# 수동으로 설정된 마커가 있다면 여기서 로드
# 예: 특별한 날짜에만 다른 마커를 표시하고 싶을 때
markers: Dict[date, str] = {}
# TODO: 필요시 수동 마커를 DB나 설정에서 로드
# 예: markers = self.crud.get_custom_date_markers()
return markers
# ----------------------------
# 트리 데이터 로드(예시)
# ----------------------------
def _load_device_tree_data(self) -> Dict[str, Any]:
"""
TODO: 실제 DB/모델 기반으로 교체
예시 데이터: 계통 -> 장치 -> 부품
"""
return {
"추진": {
"인버터": ["게이트드라이버", "IGBT", "DC링크"],
"전동기": ["베어링", "코일", "엔코더"],
},
"제동": {
"제동제어": ["BCU", "센서", "릴레이"],
"공기계통": ["컴프레서", "레귤레이터", "밸브"],
},
"출입문": {
"도어구동": ["모터", "감속기", "벨트"],
"안전장치": ["장애물검지", "도어스위치", "인터록"],
}
}
def _load_train_tree_data(self) -> Dict[str, Any]:
"""
TODO: 실제 편성/차호/장치 구조로 교체
요구사항: 계통→장치→부품 트리 형태 유지
"""
return {
"41편성": {
"추진": ["인버터", "전동기", "센서"],
"출입문": ["모터", "감속기", "검지"],
},
"32편성": {
"추진": ["인버터", "전동기", "DC링크"],
"제동": ["BCU", "밸브", "센서"],
},
"전체": {
"공통": ["TCMS", "SIV", "배터리"],
}
}
# =========================================================
# 필터 초기화
# =========================================================
def _reset_filters(self):
# 칩 제거
for k in list(self.filter_chips.keys()):
self._remove_chip(k)
# 상태 초기화
self.active_filters = {}
self.search_text = ""
self.search_card.clear_input()
# empty hint 갱신
self.search_card.sync_empty_hint()
self.card_date.sync_empty_hint()
self.card_team.sync_empty_hint()
self.card_status.sync_empty_hint()
self.card_device.sync_empty_hint()
self.card_train.sync_empty_hint()
self._request_reload()
# =========================================================
# 데이터 로딩/필터 적용(기존 로직 최대 유지)
# =========================================================
def _request_reload(self, debounce_ms: int = 0):
if debounce_ms <= 0:
self._load_data()
return
self._reload_timer.stop()
self._reload_timer.start(debounce_ms)
def _load_data(self):
for i in range(self.tab_widget.count()):
tab = self.tab_widget.widget(i)
table_name = tab.table_name
from database.models import Instruction, Fault, Work, Misc
model_map = {
"instructions": Instruction,
"faults": Fault,
"works": Work,
"miscs": Misc,
}
model_class = model_map.get(table_name)
if not model_class:
continue
records = self._fetch_records(table_name, model_class)
self._update_table(tab.table, records, model_class)
def _fetch_records(self, table_name: str, model_class: Type[SectionBase]) -> List[BaseModel]:
if table_name == "instructions":
records = self.crud.get_all_instructions()
elif table_name == "faults":
records = self.crud.get_all_faults()
elif table_name == "works":
records = self.crud.get_all_works()
elif table_name == "miscs":
records = self.crud.get_all_miscs()
else:
records = []
if self.active_filters:
records = self._apply_filters(records)
if self.search_text:
records = self._apply_search(records, self.search_text)
return records
def _apply_filters(self, records: List[BaseModel]) -> List[BaseModel]:
filtered = records
# 날짜
date_from = self.active_filters.get("date_from")
date_to = self.active_filters.get("date_to")
if date_from or date_to:
filtered = [r for r in filtered if self._matches_date_filter(r, date_from, date_to)]
# 팀
if "team" in self.active_filters:
teams = self.active_filters["team"]
if isinstance(teams, list):
filtered = [r for r in filtered if hasattr(r, 'created_team') and r.created_team in teams]
else:
filtered = [r for r in filtered if hasattr(r, 'created_team') and r.created_team == teams]
# 상태
if "status" in self.active_filters:
status = self.active_filters["status"]
if status == "completed":
filtered = [r for r in filtered if hasattr(r, 'is_completed') and r.is_completed]
elif status == "pending":
filtered = [r for r in filtered if not (hasattr(r, 'is_completed') and r.is_completed)]
# 장치/편성: 현재는 record 구조가 불명확하므로 "후킹 포인트"만 제공
# 실제 필드(예: record.system, record.device, record.part, record.train_number 등)로 조건을 바꾸세요.
device_paths = self.active_filters.get("device_paths", [])
if isinstance(device_paths, list) and device_paths:
filtered = [r for r in filtered if self._matches_device_paths(r, device_paths)]
train_paths = self.active_filters.get("train_paths", [])
if isinstance(train_paths, list) and train_paths:
filtered = [r for r in filtered if self._matches_train_paths(r, train_paths)]
return filtered
def _matches_device_paths(self, record: BaseModel, device_paths: List[str]) -> bool:
"""
TODO: 프로젝트의 실제 레코드 필드에 맞게 구현
기본은 "텍스트 검색" 형태로 방어적으로 처리
"""
try:
# 예: system/device/part 조합이 record에 있다면 가장 이상적
# system = getattr(record, "system", "")
# device = getattr(record, "device", "")
# part = getattr(record, "part", "")
# path = f"{system}/{device}/{part}"
# return path in device_paths
# 임시: 레코드의 문자열 필드들에 device_paths leaf(부품명) 중 하나라도 포함되면 True
leafs = [p.split("/")[-1] for p in device_paths]
for attr_name in dir(record):
if attr_name.startswith("_"):
continue
v = getattr(record, attr_name, None)
if isinstance(v, str):
lv = v.lower()
for leaf in leafs:
if leaf.lower() in lv:
return True
except Exception:
pass
return True # 필드가 불명확한 상황에서는 너무 공격적으로 필터링하지 않음
def _matches_train_paths(self, record: BaseModel, train_paths: List[str]) -> bool:
"""
TODO: 실제 train_number 필드 등으로 정확 매칭 권장
"""
try:
# 임시: Fault 모델에는 train_number 등이 있을 수 있으니 그걸 먼저 확인
tn = getattr(record, "train_number", None)
if tn:
# train_paths에 "41편성/..." 같은 형태가 들어오므로 앞부분만 비교
for p in train_paths:
if p.startswith(str(tn)):
return True
# 방어적 텍스트 매칭
head = [p.split("/")[0] for p in train_paths]
for attr_name in dir(record):
if attr_name.startswith("_"):
continue
v = getattr(record, attr_name, None)
if isinstance(v, str):
lv = v.lower()
for h in head:
if h.lower() in lv:
return True
except Exception:
pass
return True
def _matches_date_filter(self, record: BaseModel, date_from: Optional[date], date_to: Optional[date]) -> bool:
if hasattr(record, 'created_date'):
record_date = record.created_date
if isinstance(record_date, str):
try:
record_date = datetime.fromisoformat(record_date).date()
except Exception:
return True
if date_from and record_date < date_from:
return False
if date_to and record_date > date_to:
return False
return True
def _apply_search(self, records: List[BaseModel], search_text: str) -> List[BaseModel]:
search_lower = search_text.lower()
results = []
for record in records:
for attr_name in dir(record):
if attr_name.startswith('_'):
continue
try:
value = getattr(record, attr_name)
if isinstance(value, str) and search_lower in value.lower():
results.append(record)
break
except Exception:
pass
return results
def _update_table(self, table: QTableWidget, records: List[BaseModel], model_class: Type[SectionBase]):
fields = self._get_model_fields(model_class)
table.setColumnCount(len(fields))
table.setHorizontalHeaderLabels([f["label"] for f in fields])
table.setRowCount(0)
for record in records:
row = table.rowCount()
table.insertRow(row)
for col, field in enumerate(fields):
value = getattr(record, field["name"], "")
display_value = self._format_value(field, value, record)
item = QTableWidgetItem(display_value)
item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
table.setItem(row, col, item)
def _get_model_fields(self, model_class: Type[SectionBase]) -> List[Dict[str, str]]:
fields = [
{"name": "created_date", "label": "생성일"},
{"name": "created_team", "label": "생성팀"},
]
if model_class.__name__ == "Instruction":
fields.extend([
{"name": "instructor", "label": "지시자"},
{"name": "instruction_content", "label": "지시내용"},
])
elif model_class.__name__ == "Fault":
fields.extend([
{"name": "train_number", "label": "편성"},
{"name": "fault_content", "label": "고장내용"},
])
elif model_class.__name__ == "Work":
fields.extend([
{"name": "work_entity", "label": "작업주체"},
{"name": "work_content", "label": "작업내용"},
])
elif model_class.__name__ == "Misc":
fields.extend([
{"name": "reporter", "label": "전달자"},
{"name": "report_content", "label": "전달내용"},
])
fields.extend([
{"name": "team_confirmations", "label": "확인팀"},
{"name": "is_completed", "label": "완료"},
])
return fields
def _format_value(self, field: Dict[str, str], value: Any, record: BaseModel) -> str:
if value is None:
return ""
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]
return ", ".join(confirmed_teams) if confirmed_teams else "-"
except Exception:
return "-"
if field["name"] == "is_completed":
return "완료" if value else "-"
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
return str(value)