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("", 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("", 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("", 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("", 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("", self._on_click) widget.bind("", lambda e: self.configure(cursor="hand2")) widget.bind("", 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))