handOver2/ui/dialogs/attachment_dialog.py

1383 lines
49 KiB
Python

# -*- 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'<a href="#" style="color: #60a5fa; text-decoration: none;">{filename}</a>')
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'<a href="#" style="color: #3b82f6; text-decoration: none;">{self.attachment.title}</a>')
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