# -*- coding: utf-8 -*- """ 첨부파일 관리 다이얼로그 모듈 사진, PDF, HWP 등의 첨부파일을 업로드하고 관리하는 다이얼로그입니다. """ from typing import List from pathlib import Path from datetime import date from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QLabel, QScrollArea, QGridLayout, QFrame, QPushButton, QLineEdit, QFileDialog, QMenu, QDateEdit, QApplication, QInputDialog, QMessageBox ) from PySide6.QtCore import Qt, Signal, QDate from PySide6.QtGui import QPixmap, QDragEnterEvent, QDropEvent, QKeyEvent from ui.base.base_dialog import BaseDialog from services.storage_service import ( get_storage_service, Attachment, SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_REPORT_EXTENSIONS, REPORTS_DIR ) from core.logger import get_logger logger = get_logger(__name__) class DropZone(QFrame): """드래그 앤 드롭 영역""" files_dropped = Signal(list) # 파일 경로 리스트 def __init__(self, accept_types: str = "all", parent=None): """ Args: accept_types: "all", "image", "report" """ super().__init__(parent) self.accept_types = accept_types self.setAcceptDrops(True) self.setMinimumHeight(100) self.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken) self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) layout.setAlignment(Qt.AlignCenter) icon_label = QLabel("📁") icon_label.setStyleSheet("font-size: 32px;") icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) if self.accept_types == "image": text = "이미지 파일을 여기에 드롭하세요\n(jpg, png, gif, bmp, webp)\n\n💡 Ctrl+V: 클립보드에서 붙여넣기" elif self.accept_types == "report": text = "보고서 파일을 여기에 드롭하세요\n(pdf, hwp, doc, docx)" else: text = "파일을 여기에 드롭하세요" text_label = QLabel(text) text_label.setAlignment(Qt.AlignCenter) text_label.setStyleSheet("color: #64748b;") layout.addWidget(text_label) # 기본 스타일 self._set_normal_style() def _set_normal_style(self): self.setStyleSheet(""" DropZone { background-color: #1e293b; border: 2px dashed #475569; border-radius: 8px; } """) def _set_hover_style(self): self.setStyleSheet(""" DropZone { background-color: #334155; border: 2px dashed #3b82f6; border-radius: 8px; } """) def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): # 파일 타입 검증 urls = event.mimeData().urls() valid = False for url in urls: if url.isLocalFile(): ext = Path(url.toLocalFile()).suffix.lower() if self.accept_types == "image": valid = ext in SUPPORTED_IMAGE_EXTENSIONS elif self.accept_types == "report": valid = ext in SUPPORTED_REPORT_EXTENSIONS else: valid = ext in SUPPORTED_IMAGE_EXTENSIONS | SUPPORTED_REPORT_EXTENSIONS if valid: break if valid: event.acceptProposedAction() self._set_hover_style() else: event.ignore() def dragLeaveEvent(self, event): self._set_normal_style() def dropEvent(self, event: QDropEvent): self._set_normal_style() files = [] for url in event.mimeData().urls(): if url.isLocalFile(): file_path = url.toLocalFile() ext = Path(file_path).suffix.lower() valid = False if self.accept_types == "image": valid = ext in SUPPORTED_IMAGE_EXTENSIONS elif self.accept_types == "report": valid = ext in SUPPORTED_REPORT_EXTENSIONS else: valid = ext in SUPPORTED_IMAGE_EXTENSIONS | SUPPORTED_REPORT_EXTENSIONS if valid: files.append(file_path) if files: self.files_dropped.emit(files) event.acceptProposedAction() class ImageThumbnail(QFrame): """이미지 썸네일 위젯""" clicked = Signal(object) # Attachment delete_requested = Signal(object) # Attachment open_folder_requested = Signal(object) # Attachment rename_requested = Signal(object) # Attachment - 이름 변경 요청 def __init__(self, attachment: Attachment, parent=None): super().__init__(parent) self.attachment = attachment self.setFixedSize(120, 140) self.setCursor(Qt.PointingHandCursor) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(4, 4, 4, 4) layout.setSpacing(4) # 썸네일 이미지 self.image_label = QLabel() self.image_label.setFixedSize(110, 100) self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setStyleSheet(""" QLabel { background-color: #0f172a; border: 1px solid #334155; border-radius: 4px; } """) # 썸네일 로드 thumb_path = self.attachment.get_thumbnail_path() if thumb_path and thumb_path.exists(): pixmap = QPixmap(str(thumb_path)) else: full_path = self.attachment.get_full_path() if full_path.exists(): pixmap = QPixmap(str(full_path)) else: pixmap = QPixmap() if not pixmap.isNull(): pixmap = pixmap.scaled( 100, 90, Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.image_label.setPixmap(pixmap) layout.addWidget(self.image_label) # 제목 title = self.attachment.title if len(title) > 12: title = title[:10] + "..." title_label = QLabel(title) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("font-size: 10px; color: #94a3b8;") title_label.setToolTip(self.attachment.title) layout.addWidget(title_label) # 프레임 스타일 self.setStyleSheet(""" ImageThumbnail { background-color: #1e293b; border: 1px solid #334155; border-radius: 6px; } ImageThumbnail:hover { border-color: #3b82f6; } """) def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.clicked.emit(self.attachment) super().mousePressEvent(event) def _show_context_menu(self, pos): menu = QMenu(self) open_action = menu.addAction("📷 열기") open_action.triggered.connect(lambda: self.clicked.emit(self.attachment)) menu.addSeparator() rename_action = menu.addAction("✏️ 파일 이름 변경") rename_action.triggered.connect(lambda: self.rename_requested.emit(self.attachment)) folder_action = menu.addAction("📂 폴더 위치 열기") folder_action.triggered.connect(lambda: self.open_folder_requested.emit(self.attachment)) menu.addSeparator() delete_action = menu.addAction("🗑 삭제") delete_action.triggered.connect(lambda: self.delete_requested.emit(self.attachment)) menu.exec(self.mapToGlobal(pos)) class TemplateFileItem(QFrame): """템플릿 파일 항목 위젯 (보고서 작성 탭용)""" clicked = Signal(str) # 파일 경로 - 클릭하면 보고서 작성 시작 file_opened = Signal(str) # 파일 경로 - 파일명 클릭 시 파일 열기 rename_requested = Signal(str) # 파일 경로 - 이름 변경 요청 def __init__(self, file_path: Path, parent=None): super().__init__(parent) self.file_path = file_path self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self._setup_ui() def _setup_ui(self): layout = QHBoxLayout(self) layout.setContentsMargins(12, 10, 12, 10) # 아이콘 ext = self.file_path.suffix.lower() icon = "📝" if ext in {'.hwp', '.hwpx'} else "📄" icon_label = QLabel(icon) icon_label.setStyleSheet("font-size: 20px;") layout.addWidget(icon_label) # 파일명 (클릭 가능한 링크 스타일) filename = self.file_path.name self.title_label = QLabel(f'{filename}') self.title_label.setTextFormat(Qt.RichText) self.title_label.setCursor(Qt.PointingHandCursor) self.title_label.setToolTip(f"클릭하여 열기: {self.file_path}") self.title_label.linkActivated.connect(lambda: self._open_file()) layout.addWidget(self.title_label, 1) # 수정일 mtime = self.file_path.stat().st_mtime from datetime import datetime mtime_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d") mtime_label = QLabel(mtime_str) mtime_label.setStyleSheet("color: #64748b; font-size: 11px;") layout.addWidget(mtime_label) # 이 템플릿으로 작성 버튼 use_btn = QPushButton("✏️ 이 템플릿 사용") use_btn.setCursor(Qt.PointingHandCursor) use_btn.clicked.connect(lambda: self.clicked.emit(str(self.file_path))) use_btn.setStyleSheet(""" QPushButton { background-color: #22c55e; color: white; border: none; border-radius: 6px; padding: 8px 14px; font-size: 11px; font-weight: bold; } QPushButton:hover { background-color: #16a34a; } """) layout.addWidget(use_btn) def _open_file(self): """파일 열기""" import os if self.file_path.exists(): os.startfile(str(self.file_path)) self.file_opened.emit(str(self.file_path)) def _open_folder(self): """파일 위치 열기 (탐색기에서 파일 선택)""" import subprocess if self.file_path.exists(): subprocess.run(['explorer', '/select,', str(self.file_path)], check=False) def _show_context_menu(self, pos): """컨텍스트 메뉴 표시""" menu = QMenu(self) # 파일 열기 open_action = menu.addAction("📄 파일 열기") open_action.triggered.connect(self._open_file) # 파일 위치 열기 folder_action = menu.addAction("📂 파일 위치 열기") folder_action.triggered.connect(self._open_folder) menu.addSeparator() # 파일 이름 변경 rename_action = menu.addAction("✏️ 파일 이름 변경") rename_action.triggered.connect(lambda: self.rename_requested.emit(str(self.file_path))) menu.addSeparator() # 이 템플릿으로 작성 use_action = menu.addAction("📝 이 템플릿으로 새 보고서 작성") use_action.triggered.connect(lambda: self.clicked.emit(str(self.file_path))) menu.exec(self.mapToGlobal(pos)) # 프레임 스타일 self.setStyleSheet(""" TemplateFileItem { background-color: #1e293b; border: 1px solid #334155; border-radius: 8px; } TemplateFileItem:hover { border-color: #3b82f6; background-color: #334155; } """) class ReportItem(QFrame): """보고서 항목 위젯""" clicked = Signal(object) # Attachment delete_requested = Signal(object) # Attachment open_folder_requested = Signal(object) # Attachment rename_requested = Signal(object) # Attachment - 이름 변경 요청 def __init__(self, attachment: Attachment, parent=None): super().__init__(parent) self.attachment = attachment self.setCursor(Qt.PointingHandCursor) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self._setup_ui() def _setup_ui(self): layout = QHBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) # 아이콘 icon = "📄" if self.attachment.file_type == 'pdf' else "📝" icon_label = QLabel(icon) icon_label.setStyleSheet("font-size: 20px;") layout.addWidget(icon_label) # 제목 (링크 스타일) title_label = QLabel(f'{self.attachment.title}') title_label.setTextFormat(Qt.RichText) title_label.setCursor(Qt.PointingHandCursor) layout.addWidget(title_label, 1) # 파일 크기 size_kb = self.attachment.file_size / 1024 if size_kb > 1024: size_str = f"{size_kb / 1024:.1f} MB" else: size_str = f"{size_kb:.1f} KB" size_label = QLabel(size_str) size_label.setStyleSheet("color: #64748b; font-size: 11px;") layout.addWidget(size_label) # 프레임 스타일 self.setStyleSheet(""" ReportItem { background-color: #1e293b; border: 1px solid #334155; border-radius: 6px; } ReportItem:hover { border-color: #3b82f6; background-color: #334155; } """) def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.clicked.emit(self.attachment) super().mousePressEvent(event) def _show_context_menu(self, pos): menu = QMenu(self) open_action = menu.addAction("📄 열기") open_action.triggered.connect(lambda: self.clicked.emit(self.attachment)) menu.addSeparator() rename_action = menu.addAction("✏️ 파일 이름 변경") rename_action.triggered.connect(lambda: self.rename_requested.emit(self.attachment)) folder_action = menu.addAction("📂 폴더 위치 열기") folder_action.triggered.connect(lambda: self.open_folder_requested.emit(self.attachment)) menu.addSeparator() delete_action = menu.addAction("🗑 삭제") delete_action.triggered.connect(lambda: self.delete_requested.emit(self.attachment)) menu.exec(self.mapToGlobal(pos)) class AttachmentDialog(BaseDialog): """첨부파일 관리 다이얼로그""" def __init__( self, parent=None, record_type: str = "faults", record_id: int = 0, device_category: str = "", record_info: dict = None # 레코드 정보 (파일명 생성용) ): super().__init__( parent, title="관련자료", width=900, height=600, resizable=True ) self.record_type = record_type self.record_id = record_id self.device_category = device_category self.record_info = record_info or {} # 레코드 정보 저장 self.storage = get_storage_service() self._setup_attachment_ui() self._set_default_titles() # 기본 제목 설정 self._load_attachments() # 닫기 버튼만 self.add_button("닫기", self.close) def _generate_default_folder_title(self) -> str: """기본 폴더 제목 생성 (레코드 정보 기반)""" parts = [] # 편성번호 train_number = self.record_info.get("train_number", "") if train_number: parts.append(f"{train_number}편성") # 호차 car_number = self.record_info.get("car_number", "") if car_number: parts.append(f"{car_number}호차") # 장치분류 device_category = self.record_info.get("device_category", "") if device_category: parts.append(device_category) # 고장내용 요약 fault_content = self.record_info.get("fault_content", "") if fault_content: summary = self._summarize_fault_content(fault_content) if summary: parts.append(summary) return " ".join(parts) if parts else "" def _summarize_fault_content(self, content: str) -> str: """ 고장내용 요약 (NLP 도입 전 임시 메서드) 추후 NLP(자연어처리)를 도입하여 고장내용을 분석하고 핵심 키워드를 추출하는 방식으로 개선 예정 """ # TODO: NLP 도입 시 이 메서드를 개선 if not content: return "" # 현재는 단순히 앞부분만 사용 max_len = 20 if len(content) <= max_len: return content return content[:max_len] + "..." def _set_default_titles(self): """기본 제목 설정""" # 레코드 정보가 있으면 기본 폴더명 설정 default_title = self._generate_default_folder_title() if default_title: self.image_title_input.setText(default_title) self.report_title_input.setText(default_title) # 보고서 제목도 동일하게 # 발생일자가 있으면 날짜도 설정 occurrence_date = self.record_info.get("occurrence_date") if occurrence_date: if isinstance(occurrence_date, date): self.image_date_edit.setDate( QDate(occurrence_date.year, occurrence_date.month, occurrence_date.day) ) self.report_date_edit.setDate( QDate(occurrence_date.year, occurrence_date.month, occurrence_date.day) ) def _setup_attachment_ui(self): """첨부파일 UI 설정""" # 탭 위젯 self.tabs = QTabWidget() self.tabs.setStyleSheet(""" QTabWidget::pane { border: 1px solid #334155; border-radius: 8px; background-color: #1e293b; } QTabBar::tab { background-color: #1e293b; color: #94a3b8; padding: 10px 20px; border-top-left-radius: 6px; border-top-right-radius: 6px; } QTabBar::tab:selected { background-color: #334155; color: #f8fafc; } """) # 사진 탭 self.image_tab = self._create_image_tab() self.tabs.addTab(self.image_tab, "📷 사진") # 보고서 탭 self.report_tab = self._create_report_tab() self.tabs.addTab(self.report_tab, "📄 보고서") # 보고서 작성 탭 self.report_writer_tab = self._create_report_writer_tab() self.tabs.addTab(self.report_writer_tab, "✏️ 보고서 작성") self.content_layout.addWidget(self.tabs) def _create_image_tab(self) -> QWidget: """사진 탭 생성""" tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) # 상단 헤더 (폴더 열기 버튼) header_widget = QWidget() header_layout = QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(8) header_label = QLabel("📷 사진 첨부") header_label.setStyleSheet("font-weight: bold; color: #f8fafc; font-size: 14px;") header_layout.addWidget(header_label) open_image_folder_btn = QPushButton("📁 사진 폴더 열기") open_image_folder_btn.setCursor(Qt.PointingHandCursor) open_image_folder_btn.clicked.connect(self._open_images_folder) open_image_folder_btn.setStyleSheet(""" QPushButton { background-color: #475569; color: white; border: none; border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: bold; } QPushButton:hover { background-color: #64748b; } """) header_layout.addWidget(open_image_folder_btn) header_layout.addStretch(1) layout.addWidget(header_widget) # 업로드 영역 upload_widget = QWidget() upload_layout = QHBoxLayout(upload_widget) upload_layout.setContentsMargins(0, 0, 0, 0) # 날짜 선택 self.image_date_edit = QDateEdit() self.image_date_edit.setDate(QDate.currentDate()) self.image_date_edit.setCalendarPopup(True) self.image_date_edit.setDisplayFormat("yyyy-MM-dd") self.image_date_edit.setFixedWidth(140) self.image_date_edit.setStyleSheet(""" QDateEdit { background-color: #0f172a; color: #f8fafc; border: 1px solid #334155; border-radius: 6px; padding: 8px 8px; } QDateEdit:focus { border-color: #3b82f6; } QDateEdit::drop-down { border: none; width: 20px; } """) upload_layout.addWidget(self.image_date_edit) # 제목 입력 self.image_title_input = QLineEdit() self.image_title_input.setPlaceholderText("폴더명 (예: 7편성 3호차 2위 출입문)") self.image_title_input.setStyleSheet(""" QLineEdit { background-color: #0f172a; color: #f8fafc; border: 1px solid #334155; border-radius: 6px; padding: 8px 12px; } QLineEdit:focus { border-color: #3b82f6; } """) upload_layout.addWidget(self.image_title_input, 1) # 파일 선택 버튼 select_btn = QPushButton("파일 선택") select_btn.setCursor(Qt.PointingHandCursor) select_btn.clicked.connect(self._select_images) select_btn.setStyleSheet(""" QPushButton { background-color: #3b82f6; color: white; border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; } QPushButton:hover { background-color: #2563eb; } """) upload_layout.addWidget(select_btn) layout.addWidget(upload_widget) # 드롭 영역 self.image_drop_zone = DropZone(accept_types="image") self.image_drop_zone.files_dropped.connect(self._on_images_dropped) layout.addWidget(self.image_drop_zone) # 이미지 목록 (스크롤 가능) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet("QScrollArea { border: none; }") self.image_grid_widget = QWidget() self.image_grid_layout = QGridLayout(self.image_grid_widget) self.image_grid_layout.setSpacing(10) self.image_grid_layout.setAlignment(Qt.AlignLeft | Qt.AlignTop) scroll.setWidget(self.image_grid_widget) layout.addWidget(scroll, 1) return tab def _create_report_tab(self) -> QWidget: """보고서 탭 생성 (PDF/HWP 통합)""" tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) # 상단 헤더 (폴더 열기 버튼) header_widget = QWidget() header_layout = QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(8) header_label = QLabel("📄 보고서 첨부") header_label.setStyleSheet("font-weight: bold; color: #f8fafc; font-size: 14px;") header_layout.addWidget(header_label) open_report_folder_btn = QPushButton("📁 보고서 폴더 열기") open_report_folder_btn.setCursor(Qt.PointingHandCursor) open_report_folder_btn.clicked.connect(self._open_attached_reports_folder) open_report_folder_btn.setStyleSheet(""" QPushButton { background-color: #475569; color: white; border: none; border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: bold; } QPushButton:hover { background-color: #64748b; } """) header_layout.addWidget(open_report_folder_btn) header_layout.addStretch(1) layout.addWidget(header_widget) # 업로드 영역 (사진 탭과 동일한 형태) upload_widget = QWidget() upload_layout = QHBoxLayout(upload_widget) upload_layout.setContentsMargins(0, 0, 0, 0) # 날짜 선택 self.report_date_edit = QDateEdit() self.report_date_edit.setDate(QDate.currentDate()) self.report_date_edit.setCalendarPopup(True) self.report_date_edit.setDisplayFormat("yyyy-MM-dd") self.report_date_edit.setFixedWidth(140) self.report_date_edit.setStyleSheet(""" QDateEdit { background-color: #0f172a; color: #f8fafc; border: 1px solid #334155; border-radius: 6px; padding: 8px 8px; } QDateEdit:focus { border-color: #3b82f6; } QDateEdit::drop-down { border: none; width: 20px; } """) upload_layout.addWidget(self.report_date_edit) # 제목 입력 self.report_title_input = QLineEdit() self.report_title_input.setPlaceholderText("보고서 제목 (예: 7편성 3호차 2위 출입문 닫힘 고장 동향보고)") self.report_title_input.setStyleSheet(""" QLineEdit { background-color: #0f172a; color: #f8fafc; border: 1px solid #334155; border-radius: 6px; padding: 8px 12px; } QLineEdit:focus { border-color: #3b82f6; } """) upload_layout.addWidget(self.report_title_input, 1) # 파일 선택 버튼 select_btn = QPushButton("파일 선택") select_btn.setCursor(Qt.PointingHandCursor) select_btn.clicked.connect(self._select_reports) select_btn.setStyleSheet(""" QPushButton { background-color: #3b82f6; color: white; border: none; border-radius: 6px; padding: 8px 16px; font-weight: bold; } QPushButton:hover { background-color: #2563eb; } """) upload_layout.addWidget(select_btn) layout.addWidget(upload_widget) # 드롭 영역 self.report_drop_zone = DropZone(accept_types="report") self.report_drop_zone.files_dropped.connect(self._on_reports_dropped) layout.addWidget(self.report_drop_zone) # 보고서 목록 (스크롤 가능) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet("QScrollArea { border: none; }") self.report_list_widget = QWidget() self.report_list_layout = QVBoxLayout(self.report_list_widget) self.report_list_layout.setSpacing(8) self.report_list_layout.setAlignment(Qt.AlignTop) scroll.setWidget(self.report_list_widget) layout.addWidget(scroll, 1) return tab def _create_report_writer_tab(self) -> QWidget: """보고서 작성 탭 생성 - 템플릿 목록만 표시하고 클릭 시 별도 다이얼로그 열기""" tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) # 상단 안내 영역 header_widget = QWidget() header_layout = QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) header_layout.setSpacing(10) # 왼쪽: 카테고리 정보 및 안내 left_info = QWidget() left_layout = QVBoxLayout(left_info) left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(4) category_name = self._get_device_category_name() category_label = QLabel(f"📂 {category_name} 폴더의 기존 보고서") category_label.setStyleSheet("font-weight: bold; color: #f8fafc; font-size: 14px;") left_layout.addWidget(category_label) info_label = QLabel("아래 목록에서 참고할 보고서를 선택하면 보고서 작성 화면이 열립니다.") info_label.setStyleSheet("color: #94a3b8; font-size: 12px;") left_layout.addWidget(info_label) header_layout.addWidget(left_info, 1) # 오른쪽: 보고서 폴더 열기 버튼 open_folder_btn = QPushButton("📁 보고서 폴더 열기") open_folder_btn.setCursor(Qt.PointingHandCursor) open_folder_btn.clicked.connect(self._open_reports_folder) open_folder_btn.setStyleSheet(""" QPushButton { background-color: #475569; color: white; border: none; border-radius: 6px; padding: 8px 14px; font-size: 11px; } QPushButton:hover { background-color: #64748b; } """) header_layout.addWidget(open_folder_btn) layout.addWidget(header_widget) # 템플릿 목록 스크롤 scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setStyleSheet(""" QScrollArea { border: 1px solid #334155; border-radius: 8px; background-color: #0f172a; } """) self.template_list_widget = QWidget() self.template_list_widget.setStyleSheet("background-color: #0f172a;") self.template_list_layout = QVBoxLayout(self.template_list_widget) self.template_list_layout.setContentsMargins(8, 8, 8, 8) self.template_list_layout.setSpacing(6) self.template_list_layout.setAlignment(Qt.AlignTop) scroll.setWidget(self.template_list_widget) layout.addWidget(scroll, 1) # 템플릿 목록 로드 self._load_template_list() return tab def _get_device_category_name(self) -> str: """장치분류명 반환""" category = self.device_category or "17.기타" # "01.출입문" -> "출입문" if "." in category: return category.split(".", 1)[1] return category def _get_device_category_folder(self) -> Path: """장치분류 폴더 경로 반환""" category = self.storage._match_category(self.device_category) return REPORTS_DIR / category def _load_template_list(self): """템플릿(기존 보고서) 목록 로드""" layout = self.template_list_layout # 기존 위젯 제거 while layout.count(): item = layout.takeAt(0) if item.widget(): item.widget().deleteLater() # 장치분류 폴더에서 HWP 파일 검색 category_folder = self._get_device_category_folder() if not category_folder.exists(): no_files_label = QLabel("📁 보고서 폴더가 없습니다") no_files_label.setStyleSheet("color: #64748b; padding: 40px; font-size: 13px;") no_files_label.setAlignment(Qt.AlignCenter) layout.addWidget(no_files_label) return # HWP, HWPX 파일 검색 (하위 폴더 포함) hwp_files = [] for ext in ['*.hwp', '*.hwpx', '*.HWP', '*.HWPX']: hwp_files.extend(category_folder.rglob(ext)) # 수정일 기준 정렬 (최신순) hwp_files.sort(key=lambda x: x.stat().st_mtime, reverse=True) if not hwp_files: no_files_label = QLabel("📝 HWP 보고서가 없습니다\n\n이 폴더에 참고할 보고서 파일을 추가하세요.") no_files_label.setStyleSheet("color: #64748b; padding: 40px; font-size: 13px;") no_files_label.setAlignment(Qt.AlignCenter) layout.addWidget(no_files_label) return for file_path in hwp_files: item = TemplateFileItem(file_path) # 클릭 시 보고서 작성 다이얼로그 열기 item.clicked.connect(self._on_open_report_writer) item.rename_requested.connect(self._on_rename_template_file) layout.addWidget(item) def _open_reports_folder(self): """보고서 작성 탭 - 템플릿 보고서 폴더 열기 (storage/보고서)""" import subprocess if REPORTS_DIR.exists(): subprocess.run(['explorer', str(REPORTS_DIR)], check=False) else: # 폴더가 없으면 생성 후 열기 REPORTS_DIR.mkdir(parents=True, exist_ok=True) subprocess.run(['explorer', str(REPORTS_DIR)], check=False) def _open_images_folder(self): """사진 탭 - 첨부된 사진 폴더 열기""" import subprocess from services.storage_service import STORAGE_DIR # 현재 레코드의 사진 저장 폴더 images_folder = STORAGE_DIR / self.record_type / str(self.record_id) / "images" if images_folder.exists(): subprocess.run(['explorer', str(images_folder)], check=False) else: # 폴더가 없으면 상위 레코드 폴더 열기 record_folder = STORAGE_DIR / self.record_type / str(self.record_id) if record_folder.exists(): subprocess.run(['explorer', str(record_folder)], check=False) else: # 레코드 폴더도 없으면 storage 폴더 열기 subprocess.run(['explorer', str(STORAGE_DIR)], check=False) def _open_attached_reports_folder(self): """보고서 탭 - 첨부된 보고서 폴더 열기""" import subprocess from services.storage_service import STORAGE_DIR # 현재 레코드의 보고서 저장 폴더 reports_folder = STORAGE_DIR / self.record_type / str(self.record_id) / "reports" if reports_folder.exists(): subprocess.run(['explorer', str(reports_folder)], check=False) else: # 폴더가 없으면 상위 레코드 폴더 열기 record_folder = STORAGE_DIR / self.record_type / str(self.record_id) if record_folder.exists(): subprocess.run(['explorer', str(record_folder)], check=False) else: # 레코드 폴더도 없으면 storage 폴더 열기 subprocess.run(['explorer', str(STORAGE_DIR)], check=False) def _on_open_report_writer(self, template_path: str): """보고서 작성 시작 - 원본/새파일을 좌우로 배치하여 한글 실행""" from ui.dialogs.report_writer_dialog import start_report_writing # 보고서 작성 시작 (원본: 왼쪽, 새파일: 오른쪽) self._current_report_writer = start_report_writing( template_path=template_path, device_category=self.device_category, record_info=self.record_info ) # 다이얼로그 닫기 self.close() def _on_report_saved(self, file_path: str): """보고서 저장 완료 시 호출""" logger.info(f"보고서 저장됨: {file_path}") # 템플릿 목록 새로고침 self._load_template_list() def _load_attachments(self): """첨부파일 로드""" # 이미지 로드 self._load_images() # 보고서 로드 self._load_reports() def _load_images(self): """이미지 목록 로드""" # 기존 위젯 제거 while self.image_grid_layout.count(): item = self.image_grid_layout.takeAt(0) if item.widget(): item.widget().deleteLater() images = self.storage.get_attachments_by_type( self.record_type, self.record_id, "image" ) for i, attachment in enumerate(images): row, col = divmod(i, 5) # 5열 thumb = ImageThumbnail(attachment) thumb.clicked.connect(self._on_image_clicked) thumb.delete_requested.connect(self._on_delete_attachment) thumb.open_folder_requested.connect(self._on_open_folder) thumb.rename_requested.connect(self._on_rename_attachment) self.image_grid_layout.addWidget(thumb, row, col) def _load_reports(self): """보고서 목록 로드 (PDF + HWP 통합)""" layout = self.report_list_layout # 기존 위젯 제거 while layout.count(): item = layout.takeAt(0) if item.widget(): item.widget().deleteLater() # PDF와 HWP 모두 로드 all_reports = [] for report_type in ["pdf", "hwp", "doc"]: reports = self.storage.get_attachments_by_type( self.record_type, self.record_id, report_type ) all_reports.extend(reports) # 날짜순 정렬 (최신순) all_reports.sort(key=lambda x: x.created_at, reverse=True) for attachment in all_reports: item = ReportItem(attachment) item.clicked.connect(self._on_report_clicked) item.delete_requested.connect(self._on_delete_attachment) item.open_folder_requested.connect(self._on_open_folder) item.rename_requested.connect(self._on_rename_attachment) layout.addWidget(item) def _select_images(self): """이미지 파일 선택""" files, _ = QFileDialog.getOpenFileNames( self, "이미지 선택", "", "이미지 파일 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)" ) if files: self._upload_images(files) def _select_reports(self): """보고서 파일 선택""" files, _ = QFileDialog.getOpenFileNames( self, "보고서 선택", "", "보고서 파일 (*.pdf *.hwp *.hwpx *.doc *.docx)" ) if files: self._upload_reports(files) def _on_images_dropped(self, files: List[str]): """이미지 드롭""" self._upload_images(files) def _on_reports_dropped(self, files: List[str]): """보고서 드롭""" self._upload_reports(files) def _upload_images(self, files: List[str]): """이미지 업로드""" folder_title = self.image_title_input.text().strip() if not folder_title: folder_title = "사진" # QDate -> date 변환 qdate = self.image_date_edit.date() folder_date = date(qdate.year(), qdate.month(), qdate.day()) self.storage.save_images( file_paths=files, record_type=self.record_type, record_id=self.record_id, folder_title=folder_title, folder_date=folder_date ) self.image_title_input.clear() self._load_images() def _upload_reports(self, files: List[str]): """보고서 업로드""" title = self.report_title_input.text().strip() qdate = self.report_date_edit.date() # QDate -> date 변환 report_date = date(qdate.year(), qdate.month(), qdate.day()) # 장치분류는 레코드 정보에서 가져옴 category = self.device_category or "17.기타" for file_path in files: file_title = title or Path(file_path).stem self.storage.save_report( file_path=file_path, record_type=self.record_type, record_id=self.record_id, device_category=category, report_title=file_title, report_date=report_date ) self.report_title_input.clear() self._load_reports() def _on_image_clicked(self, attachment: Attachment): """이미지 클릭 - 뷰어 열기""" from ui.dialogs.image_viewer_dialog import ImageViewerDialog # 현재 이미지 목록 images = self.storage.get_attachments_by_type( self.record_type, self.record_id, "image" ) # 현재 이미지 인덱스 찾기 current_index = 0 for i, img in enumerate(images): if img.id == attachment.id: current_index = i break dialog = ImageViewerDialog( self, images, current_index, record_info=self.record_info ) dialog.exec() # 저장 후 목록 새로고침 self._load_images() def _on_report_clicked(self, attachment: Attachment): """보고서 클릭 - 파일 열기""" self.storage.open_file(attachment) def _on_delete_attachment(self, attachment: Attachment): """첨부파일 삭제""" result = self.storage.delete_attachment( self.record_type, self.record_id, attachment.id ) if result: self._load_attachments() def _on_open_folder(self, attachment: Attachment): """폴더 위치 열기""" import subprocess full_path = attachment.get_full_path() folder_path = full_path.parent if folder_path.exists(): # Windows에서 탐색기로 폴더 열기 (파일 선택 상태로) subprocess.run(['explorer', '/select,', str(full_path)], check=False) def _on_rename_attachment(self, attachment: Attachment): """첨부파일 이름 변경 (사진, 보고서)""" full_path = attachment.get_full_path() if not full_path.exists(): logger.warning(f"파일이 존재하지 않습니다: {full_path}") return old_name = full_path.stem # 확장자 제외 파일명 ext = full_path.suffix # 확장자 # 새 이름 입력받기 new_name, ok = QInputDialog.getText( self, "파일 이름 변경", f"새 파일 이름을 입력하세요:\n(확장자 {ext}는 자동으로 추가됩니다)", text=old_name ) if not ok or not new_name.strip(): return new_name = new_name.strip() # 새 경로 생성 new_path = full_path.parent / f"{new_name}{ext}" if new_path.exists(): QMessageBox.warning(self, "오류", f"'{new_name}{ext}' 파일이 이미 존재합니다.") return try: # 파일 이름 변경 full_path.rename(new_path) # 썸네일도 있으면 이름 변경 thumb_path = attachment.get_thumbnail_path() if thumb_path and thumb_path.exists(): new_thumb_name = f"{new_name}_thumb{thumb_path.suffix}" new_thumb_path = thumb_path.parent / new_thumb_name thumb_path.rename(new_thumb_path) # attachment 객체 업데이트 및 DB 저장 attachment.filename = f"{new_name}{ext}" attachment.title = new_name get_storage_service().update_attachment(attachment) logger.info(f"파일 이름 변경: {old_name}{ext} -> {new_name}{ext}") # 목록 새로고침 self._load_images() self._load_reports() except Exception as e: logger.error(f"파일 이름 변경 실패: {e}") QMessageBox.warning(self, "오류", f"파일 이름 변경에 실패했습니다:\n{e}") def _on_rename_template_file(self, file_path: str): """템플릿 파일 이름 변경 (보고서 작성 탭)""" path = Path(file_path) if not path.exists(): logger.warning(f"파일이 존재하지 않습니다: {path}") return old_name = path.stem # 확장자 제외 파일명 ext = path.suffix # 확장자 # 새 이름 입력받기 new_name, ok = QInputDialog.getText( self, "파일 이름 변경", f"새 파일 이름을 입력하세요:\n(확장자 {ext}는 자동으로 추가됩니다)", text=old_name ) if not ok or not new_name.strip(): return new_name = new_name.strip() # 새 경로 생성 new_path = path.parent / f"{new_name}{ext}" if new_path.exists(): QMessageBox.warning(self, "오류", f"'{new_name}{ext}' 파일이 이미 존재합니다.") return try: # 파일 이름 변경 path.rename(new_path) logger.info(f"템플릿 파일 이름 변경: {old_name}{ext} -> {new_name}{ext}") # 목록 새로고침 self._load_template_list() except Exception as e: logger.error(f"파일 이름 변경 실패: {e}") QMessageBox.warning(self, "오류", f"파일 이름 변경에 실패했습니다:\n{e}") def keyPressEvent(self, event: QKeyEvent): """키 이벤트 - Ctrl+V로 클립보드 이미지 붙여넣기""" if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_V: # 사진 탭이 활성화되어 있을 때만 if self.tabs.currentWidget() == self.image_tab: if self._paste_image_from_clipboard(): event.accept() return super().keyPressEvent(event) def _paste_image_from_clipboard(self) -> bool: """클립보드에서 이미지 붙여넣기 Returns: 성공 여부 """ import tempfile from datetime import datetime clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() # 이미지가 있는지 확인 if mime_data.hasImage(): image = clipboard.image() if image.isNull(): return False # 임시 파일로 저장 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") temp_dir = Path(tempfile.gettempdir()) temp_file = temp_dir / f"clipboard_{timestamp}.png" # QImage를 파일로 저장 if image.save(str(temp_file), "PNG"): # 업로드 self._upload_images([str(temp_file)]) # 임시 파일 삭제 시도 (실패해도 무시) try: temp_file.unlink() except Exception: pass logger.info("클립보드에서 이미지 붙여넣기 완료") return True # 파일 URL이 있는 경우 (파일 복사 후 붙여넣기) elif mime_data.hasUrls(): files = [] for url in mime_data.urls(): if url.isLocalFile(): file_path = url.toLocalFile() ext = Path(file_path).suffix.lower() if ext in SUPPORTED_IMAGE_EXTENSIONS: files.append(file_path) if files: self._upload_images(files) logger.info("클립보드에서 %d개 이미지 파일 붙여넣기 완료", len(files)) return True return False