1777 lines
69 KiB
Python
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)
|