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