VOC_Monitor/view.py

503 lines
21 KiB
Python

import customtkinter as ctk
from tkinter import ttk
import pystray
from PIL import Image, ImageDraw
import sys
import os
# winotify 사용 시도
try:
from winotify import Notification, audio
USE_WINOTIFY = True
except ImportError:
from plyer import notification
USE_WINOTIFY = False
class SettingsView(ctk.CTk):
def __init__(self, controller):
super().__init__()
self.controller = controller
self.title("VOC 모니터링 설정")
self.geometry("500x500")
self.tabview = ctk.CTkTabview(self)
self.tabview.pack(padx=20, pady=20, fill="both", expand=True)
self.tab_login = self.tabview.add("로그인")
self.tab_crawl = self.tabview.add("크롤링 설정")
self.tab_theme = self.tabview.add("테마")
self._init_login_tab()
self._init_crawl_tab()
self._init_theme_tab()
def _init_login_tab(self):
ctk.CTkLabel(self.tab_login, text="사번 (ID)").pack(pady=5)
self.entry_id = ctk.CTkEntry(self.tab_login)
self.entry_id.pack(pady=5)
self.entry_id.insert(0, self.controller.settings['login']['id'])
ctk.CTkLabel(self.tab_login, text="비밀번호").pack(pady=5)
self.entry_pw = ctk.CTkEntry(self.tab_login, show="*")
self.entry_pw.pack(pady=5)
self.entry_pw.insert(0, self.controller.settings['login']['pw'])
def _init_crawl_tab(self):
ctk.CTkLabel(self.tab_crawl, text="수집 주기 (분)").pack(pady=5)
self.combo_interval = ctk.CTkComboBox(self.tab_crawl, values=["3", "5", "10", "15", "30"])
self.combo_interval.pack(pady=5)
self.combo_interval.set(str(self.controller.settings['crawling']['interval_minutes']))
ctk.CTkLabel(self.tab_crawl, text="알림 키워드 (콤마로 구분)").pack(pady=5)
self.entry_kw = ctk.CTkEntry(self.tab_crawl, width=300)
self.entry_kw.pack(pady=5)
self.entry_kw.insert(0, ",".join(self.controller.settings['crawling']['keywords']))
ctk.CTkLabel(self.tab_crawl, text="관심 부서 (콤마로 구분)").pack(pady=5)
self.entry_dept = ctk.CTkEntry(self.tab_crawl, width=300)
self.entry_dept.pack(pady=5)
self.entry_dept.insert(0, ",".join(self.controller.settings['crawling']['target_depts']))
ctk.CTkButton(self.tab_crawl, text="설정 저장", command=self.save_all).pack(pady=20)
def _init_theme_tab(self):
ctk.CTkLabel(self.tab_theme, text="UI 테마 모드").pack(pady=10)
self.combo_theme = ctk.CTkComboBox(self.tab_theme, values=["System", "Dark", "Light"])
self.combo_theme.pack(pady=5)
current_theme = self.controller.settings.get('theme', 'System')
self.combo_theme.set(current_theme)
ctk.CTkButton(self.tab_theme, text="테마 적용 및 저장", command=self.save_all).pack(pady=20)
def save_all(self):
self.controller.settings['login']['id'] = self.entry_id.get()
self.controller.settings['login']['pw'] = self.entry_pw.get()
self.controller.settings['crawling']['interval_minutes'] = int(self.combo_interval.get())
self.controller.settings['crawling']['keywords'] = [k.strip() for k in self.entry_kw.get().split(',') if k.strip()]
self.controller.settings['crawling']['target_depts'] = [k.strip() for k in self.entry_dept.get().split(',') if k.strip()]
# 테마 저장 및 적용
selected_theme = self.combo_theme.get()
self.controller.settings['theme'] = selected_theme
ctk.set_appearance_mode(selected_theme)
self.controller.save_settings()
self.controller.update_config_runtime()
self.destroy()
class PreviewWindow(ctk.CTkToplevel):
def __init__(self, controller, data):
super().__init__()
self.controller = controller
self.data = data
self.title("인쇄 미리보기")
self.geometry("600x800")
# 툴바
toolbar = ctk.CTkFrame(self)
toolbar.pack(fill="x", padx=10, pady=10)
ctk.CTkButton(toolbar, text="🖨 인쇄하기", command=self._print).pack(side="left", padx=5)
ctk.CTkButton(toolbar, text="💾 PDF 저장", command=self._save_pdf).pack(side="left", padx=5)
ctk.CTkButton(toolbar, text="닫기", command=self.destroy, fg_color="gray").pack(side="right", padx=5)
# A4 용지 느낌의 프레임 (흰색 고정)
paper_frame = ctk.CTkFrame(self, fg_color="white")
paper_frame.pack(fill="both", expand=True, padx=20, pady=20)
# 내용 표시 (텍스트 박스, 검은 글씨)
self.text = ctk.CTkTextbox(paper_frame, text_color="black", fg_color="white", font=("Batang", 12))
self.text.pack(fill="both", expand=True, padx=30, pady=30)
self._render_content()
self.text.configure(state="disabled")
def _render_content(self):
# 문서 양식 흉내
d = self.data
txt = f"""
[ VOC 상세 보고서 ]
■ 제 목 : {d['title']}
■ 부 서 : {d.get('department','-')}
■ 작성자 : {d.get('writer','-')}
■ 일 자 : {d.get('date','-')}
■ 상 태 : {d.get('status','-')}
=============================================================
[ 질의 내용 ]
{d.get('content','')}
=============================================================
[ 답변 내용 ]
{d.get('answer','')}
"""
self.text.insert("1.0", txt)
def _print(self):
self.controller.request_print_voc(self.data)
def _save_pdf(self):
self.controller.request_create_pdf(self.data)
class VOCDetailView(ctk.CTkToplevel):
"""VOC 상세 정보를 보여주는 별도의 창"""
def __init__(self, controller, data):
super().__init__()
self.controller = controller
self.data = data
self.title(f"VOC 상세 정보 - {data['title']}")
self.geometry("900x900")
self.attributes('-topmost', True)
# ESC 키 바인딩
self.bind("<Escape>", lambda e: self.destroy())
# 전체 컨테이너
self.main_container = ctk.CTkFrame(self)
self.main_container.pack(fill="both", expand=True, padx=20, pady=20)
# 1. 메인 정보 그룹 (상단)
self._create_info_section(self.main_container, data)
# 구분선
ctk.CTkFrame(self.main_container, height=2, fg_color="gray50").pack(fill="x", pady=10)
# 2. 상세 정보 그룹 (중앙)
self.content_frame = ctk.CTkFrame(self.main_container, fg_color="transparent")
self.content_frame.pack(fill="both", expand=True)
# 2-1. 제목
ctk.CTkLabel(self.content_frame, text=data['title'], font=("Malgun Gothic", 18, "bold"), anchor="w").pack(fill="x", pady=(0, 10))
# 2-2. 처리부서 / 역명
sub_info_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
sub_info_frame.pack(fill="x", pady=(0, 10))
ctk.CTkLabel(sub_info_frame, text=f"처리부서: {data.get('department', '-')}", font=("Malgun Gothic", 14), anchor="w").pack(side="left", padx=(0, 20))
ctk.CTkLabel(sub_info_frame, text=f"역명: {data.get('station', '-')}", font=("Malgun Gothic", 14), anchor="w").pack(side="left")
# 2-3. 질의내용 (가장 큰 영역 차지)
ctk.CTkLabel(self.content_frame, text="<질의 내용>", font=("Malgun Gothic", 12, "bold"), anchor="w").pack(fill="x")
self.q_text = ctk.CTkTextbox(self.content_frame, font=("Malgun Gothic", 14), height=300)
self.q_text.pack(fill="both", expand=True, pady=(5, 10))
self.q_text.insert("1.0", str(data.get('content', '')))
self.q_text.configure(state="disabled")
# 2-4. 고객 첨부파일
file_frame = ctk.CTkFrame(self.content_frame)
file_frame.pack(fill="x", pady=(0, 10))
ctk.CTkLabel(file_frame, text="첨부파일:", width=80, anchor="w").pack(side="left", padx=10)
# NoneType 처리 강화
att_val = data.get('attachment')
att_text = str(att_val).strip() if att_val else ""
if not att_text: att_text = "없음"
# text_color 오류 수정: "text_color"라는 색상 이름은 없음.
# 파일이 있으면 blue, 없으면 기본값(None 혹은 시스템 테마 따름).
text_col = "blue" if att_text != "없음" else ("gray90", "gray10") # (light, dark) 테마 대응
self.lbl_att = ctk.CTkLabel(file_frame, text=att_text, anchor="w", text_color=text_col)
self.lbl_att.pack(side="left", fill="x", expand=True, padx=5)
# TODO: 실제 파일 다운로드 로직 연결 필요 (현재는 표시만)
if att_text != "없음":
self.lbl_att.bind("<Button-1>", lambda e: self._open_file(att_text))
self.lbl_att.configure(cursor="hand2")
# 2-5. 답변내용 (질의내용보다 작게)
ctk.CTkLabel(self.content_frame, text="<답변 내용>", font=("Malgun Gothic", 12, "bold"), anchor="w").pack(fill="x")
self.a_text = ctk.CTkTextbox(self.content_frame, font=("Malgun Gothic", 14), height=150)
self.a_text.pack(fill="x", pady=(5, 10))
self.a_text.insert("1.0", str(data.get('answer', '')))
self.a_text.configure(state="disabled")
# 3. 하단 버튼 그룹
btn_frame = ctk.CTkFrame(self.main_container, fg_color="transparent")
btn_frame.pack(fill="x", pady=10)
# [수정] 컨트롤러에게 위임 -> 미리보기 창 열기
ctk.CTkButton(btn_frame, text="미리보기/인쇄", width=120,
command=self._open_preview).pack(side="left", padx=5)
ctk.CTkButton(btn_frame, text="보고서생성(Hwp)", width=120,
command=lambda: self.controller.request_create_report(self.data)).pack(side="left", padx=5)
ctk.CTkButton(btn_frame, text="닫기 (ESC)", width=100, command=self.destroy, fg_color="gray").pack(side="right")
def _open_preview(self):
PreviewWindow(self.controller, self.data)
def _create_info_section(self, parent, data):
info_frame = ctk.CTkFrame(parent)
info_frame.pack(fill="x")
# Grid 설정
info_frame.columnconfigure((0,1,2,3,4), weight=1)
# 1행: 번호, 접수채널, 등록일자, VOC유형+요약? -> VOC유형까지
items_row1 = [
("접수번호", data['id']),
("접수채널", data.get('channel', '-')),
("등록일자", data.get('date', '-')),
("VOC유형", data.get('voc_type', '-'))
]
for idx, (label, val) in enumerate(items_row1):
self._add_info_item(info_frame, idx, 0, label, val)
# 2행: 응답구분, 작성자, 처리부서, 상태, 공개여부
is_pub = "공개" if data.get('is_public') else "비공개"
items_row2 = [
("응답구분", data.get('response_type', '-')),
("작성자", data['writer']),
("처리부서", data['department']),
("상태", data['status']),
("공개여부", is_pub)
]
for idx, (label, val) in enumerate(items_row2):
self._add_info_item(info_frame, idx, 1, label, val)
def _add_info_item(self, parent, col, row, label, value):
f = ctk.CTkFrame(parent, fg_color="transparent")
f.grid(row=row, column=col, padx=5, pady=5, sticky="ew")
ctk.CTkLabel(f, text=label, font=("Malgun Gothic", 11), text_color="gray70").pack(anchor="w")
ctk.CTkLabel(f, text=str(value), font=("Malgun Gothic", 12, "bold")).pack(anchor="w")
def _open_file(self, filename):
"""파일 열기 시도 (경로는 추후 구현 필요, 현재는 알림만)"""
# 실제 로컬 경로가 있다면 os.startfile(path) 등을 사용
print(f"파일 열기 클릭: {filename}")
# 임시 알림
try:
from tkinter import messagebox
messagebox.showinfo("알림", f"파일 다운로드 기능은 구현 중입니다.\n파일명: {filename}")
except: pass
class VOCListView(ctk.CTkToplevel):
def __init__(self, controller, data_rows):
super().__init__()
self.controller = controller
self.title("VOC 수집 내역")
self.geometry("1000x600")
self.all_data = data_rows # 필터링용 원본 데이터
# --- 상단: 필터 및 검색 레이아웃 ---
self.filter_frame = ctk.CTkFrame(self)
self.filter_frame.pack(fill="x", padx=20, pady=10)
ctk.CTkLabel(self.filter_frame, text="검색어 필터:").pack(side="left", padx=10)
self.search_entry = ctk.CTkEntry(self.filter_frame, width=300, placeholder_text="제목, 내용, 부서 검색...")
self.search_entry.pack(side="left", padx=10)
self.search_entry.bind("<KeyRelease>", lambda e: self.apply_filter())
self.btn_refresh = ctk.CTkButton(self.filter_frame, text="새로고침", width=100, command=self.refresh_and_update)
self.btn_refresh.pack(side="right", padx=10)
# --- 메인: 트리뷰 목록 ---
self.list_frame = ctk.CTkFrame(self)
self.list_frame.pack(fill="both", expand=True, padx=20, pady=10)
# 스크롤바 추가
self.tree_scroll = ttk.Scrollbar(self.list_frame)
self.tree_scroll.pack(side="right", fill="y")
# 컬럼 정의: 번호, 제목, 처리부서, 상태, 접수채널, 고객명, 공개여부
columns = ("번호", "제목", "처리부서", "상태", "접수채널", "고객명", "공개여부")
self.tree = ttk.Treeview(
self.list_frame,
columns=columns,
show='headings',
yscrollcommand=self.tree_scroll.set,
style="Custom.Treeview"
)
self.tree_scroll.config(command=self.tree.yview)
# 헤더 설정 및 정렬 연결
for col in columns:
self.tree.heading(col, text=col, command=lambda c=col: self.sort_column(c, False))
self.tree.column(col, anchor="center")
# 컬럼 너비 설정
self.tree.column("제목", width=500, anchor="w")
self.tree.column("번호", width=100)
self.tree.column("공개여부", width=80)
self.tree.column("접수채널", width=100)
self.tree.column("고객명", width=100)
self.tree.pack(fill="both", expand=True)
self.tree.bind("<Double-1>", self._on_double_click)
# 데이터 초기 삽입
self.update_data(data_rows)
def refresh_and_update(self):
# controller의 run_cycle을 스레드로 실행하여 UI 멈춤 방지
import threading
t = threading.Thread(target=self.controller.run_cycle, daemon=True)
t.start()
def update_data(self, rows):
"""데이터 갱신 및 내림차순 정렬 적용"""
self.all_data = rows
self.apply_filter()
def apply_filter(self):
"""현재 검색어에 따라 필터링하여 목록 표시"""
query = self.search_entry.get().lower()
for i in self.tree.get_children():
self.tree.delete(i)
filtered = [
r for r in self.all_data
if query in r['title'].lower() or query in r['department'].lower() or query in r['writer'].lower()
]
# 정렬 (기본: 번호 내림차순)
# 단, 정렬 기준 컬럼이 변경되었을 수 있으므로 이 부분은
# 처음 로딩 시나 명시적 정렬 시에만 적용되도록 함이 좋으나,
# 유저가 "번호 기준 내림차순 정렬"을 명시했으므로 기본값으로 둔다.
# 단, 사용자가 컬럼 클릭으로 정렬을 바꿨다면 그 상태를 유지해야 하는데 복잡해지므로
# 여기서는 매 필터링마다 최신순(번호 역순)으로 보여준다.
try:
filtered.sort(key=lambda x: int(x['id']), reverse=True)
except:
filtered.sort(key=lambda x: str(x['id']), reverse=True)
for row in filtered:
is_pub = "공개" if row.get('is_public') else "비공개"
self.tree.insert("", "end", values=(
row['id'],
row['title'],
row['department'],
row['status'],
row.get('channel', '-'),
row.get('writer', '-'),
is_pub
))
def sort_column(self, col, reverse):
"""컬럼 헤더 클릭 시 데이터 정렬"""
l = [(self.tree.set(k, col), k) for k in self.tree.get_children('')]
# 숫자 정렬 시도 (번호 등)
try:
l.sort(key=lambda t: int(t[0]), reverse=reverse)
except ValueError:
l.sort(reverse=reverse)
for index, (val, k) in enumerate(l):
self.tree.move(k, '', index)
self.tree.heading(col, command=lambda: self.sort_column(col, not reverse))
def _on_double_click(self, event):
selected = self.tree.selection()
if not selected: return
# 첫 번째 컬럼(번호)이 ID임
voc_id = self.tree.item(selected[0])['values'][0]
self.controller.request_detail_popup(voc_id)
class NotificationView(ctk.CTkToplevel):
"""우측 하단에 나타나는 커스텀 알림 창"""
def __init__(self, controller, title, msg, voc_id=None):
super().__init__()
self.controller = controller
self.voc_id = voc_id
# 1. 창 설정 (타이틀바 없음, 최상위)
self.overrideredirect(True)
self.attributes('-topmost', True)
self.title("VOC 알림")
# 2. 디자인 및 레이아웃
self.configure(fg_color="#333333") # 다크 모드 배경
# 메인 컨테이너
container = ctk.CTkFrame(self, fg_color="transparent")
container.pack(fill="both", expand=True, padx=15, pady=15)
# 제목
ctk.CTkLabel(
container,
text=title,
font=("Roboto", 14, "bold"),
text_color="#3B8ED0" # 포인트 컬러
).pack(anchor="w", pady=(0, 5))
# 내용
ctk.CTkLabel(
container,
text=msg,
wraplength=280,
justify="left",
font=("Roboto", 12)
).pack(anchor="w")
# 클릭 이벤트 바인딩 (전체 영역)
for widget in [self, container] + container.winfo_children():
widget.bind("<Button-1>", self._on_click)
widget.bind("<Enter>", lambda e: self.configure(cursor="hand2"))
widget.bind("<Leave>", lambda e: self.configure(cursor=""))
# 3. 위치 지정 (우측 하단)
self.update_idletasks()
w, h = 320, 100 # 기본 크기
screen_w = self.winfo_screenwidth()
screen_h = self.winfo_screenheight()
# 작업 표시줄 높이 대략 고려 (40~50px)
x = screen_w - w - 20
y = screen_h - h - 60
self.geometry(f"{w}x{h}+{x}+{y}")
# 4. 자동 종료 예약
self.after(5000, self.destroy)
def _on_click(self, event):
"""알림 클릭 시 상세 정보 열기"""
if self.voc_id:
self.controller.request_detail_popup(self.voc_id)
self.destroy()
class SystemTray:
def __init__(self, controller):
self.controller = controller
self.icon = None
def create_modern_icon(self):
w, h = 64, 64
image = Image.new('RGBA', (w, h), (0, 0, 0, 0))
dc = ImageDraw.Draw(image)
dc.ellipse((4, 4, 60, 60), fill=(58, 123, 213))
dc.line((20, 32, 30, 42), fill="white", width=4)
dc.line((30, 42, 44, 22), fill="white", width=4)
return image
def run(self, on_check, on_list, on_settings, on_quit):
menu = (
pystray.MenuItem('VOC 모니터링 열기', lambda *args: on_list()), # 메인 기능
pystray.Menu.SEPARATOR,
pystray.MenuItem('지금 확인', lambda *args: on_check()),
pystray.MenuItem('설정', lambda *args: on_settings()),
pystray.MenuItem('종료', lambda *args: on_quit()),
)
self.icon = pystray.Icon("VOC_Monitor", self.create_modern_icon(), "VOC 알리미", menu)
self.icon.run()
def stop(self):
if self.icon:
self.icon.stop()
def show_notification(self, title, msg, voc_id=None):
"""커스텀 알림 창 표시 (메인 스레드에서 실행)"""
# controller.root.after를 사용하여 메인 스레드에서 UI 생성 보장
self.controller.root.after(0, lambda: NotificationView(self.controller, title, msg, voc_id))