first commit
@baf27ee7ab06e2a16c7c547272a7c7b48d4bdad2
+++ README.md
... | ... | @@ -0,0 +1,123 @@ |
| 1 | +# 급여명세서 분할기 수정 가이드 | |
| 2 | + | |
| 3 | +## 파일 구조 | |
| 4 | +``` | |
| 5 | +월급명세서_자동배포/ | |
| 6 | +├── 급여명세서분할기_v3_최종수정.exe # 실행 파일 | |
| 7 | +├── email_config.json # 이메일 설정 | |
| 8 | +├── employees.json # 직원 목록 | |
| 9 | +├── payslip_splitter_gui_v3.py # 메인 소스코드 | |
| 10 | +├── src/ # 핵심 모듈들 | |
| 11 | +│ ├── email_sender.py # 이메일 발송 로직 | |
| 12 | +│ ├── employee_manager.py # 직원 관리 | |
| 13 | +│ └── pdf_preview.py # PDF 처리 | |
| 14 | +├── samples/ # 샘플 PDF 파일들 | |
| 15 | +├── backup/ # 백업 파일들 | |
| 16 | +├── build.bat # 빌드 스크립트 | |
| 17 | +├── requirements.txt # Python 패키지 목록 | |
| 18 | +└── 수정가이드.md # 이 파일 | |
| 19 | +``` | |
| 20 | + | |
| 21 | +## ⚙️ 주요 설정 수정 방법 | |
| 22 | + | |
| 23 | +### 1. 이메일 제목/내용 수정 | |
| 24 | +**파일**: `email_config.json` | |
| 25 | +```json | |
| 26 | +{ | |
| 27 | + "email_template": { | |
| 28 | + "subject": "{month} 월급명세서 전달드립니다.", | |
| 29 | + "body": "여기에 이메일 내용 작성..." | |
| 30 | + } | |
| 31 | +} | |
| 32 | +``` | |
| 33 | +- `{month}`: 자동으로 다음달로 치환 (예: 8월 → 09월) | |
| 34 | +- 제목과 내용을 자유롭게 수정 가능 | |
| 35 | + | |
| 36 | +### 2. SMTP 서버 설정 | |
| 37 | +**파일**: `email_config.json` | |
| 38 | +```json | |
| 39 | +{ | |
| 40 | + "smtp_server": "smtp.worksmobile.com", | |
| 41 | + "smtp_port": 465, | |
| 42 | + "use_ssl": true, | |
| 43 | + "sender_email": "noreply@munjaon.co.kr", | |
| 44 | + "sender_name": "급여관리팀" | |
| 45 | +} | |
| 46 | +``` | |
| 47 | + | |
| 48 | +### 3. 직원 목록 초기 설정 | |
| 49 | +**파일**: `employees.json` | |
| 50 | +```json | |
| 51 | +[ | |
| 52 | + { | |
| 53 | + "name": "홍길동", | |
| 54 | + "email": "hong@company.com", | |
| 55 | + "birth_date": "19900115" | |
| 56 | + } | |
| 57 | +] | |
| 58 | +``` | |
| 59 | +- 새 직원 추가 또는 기존 직원 정보 수정 | |
| 60 | +- `birth_date`: PDF 비밀번호로 사용됨 (YYYYMMDD 형식) | |
| 61 | + | |
| 62 | +## ️ 고급 수정 (개발자용) | |
| 63 | + | |
| 64 | +### GUI 텍스트 수정 | |
| 65 | +**파일**: `payslip_splitter_gui_v3.py` | |
| 66 | +- 버튼 텍스트, 라벨, 메시지 등 UI 요소 수정 | |
| 67 | + | |
| 68 | +### 이메일 발송 로직 수정 | |
| 69 | +**파일**: `src/email_sender.py` | |
| 70 | +- 날짜 추출 로직 | |
| 71 | +- 첨부파일 처리 | |
| 72 | +- SMTP 연결 설정 | |
| 73 | + | |
| 74 | +### PDF 처리 로직 수정 | |
| 75 | +**파일**: `src/employee_manager.py`, `src/pdf_preview.py` | |
| 76 | +- PDF 분할 로직 | |
| 77 | +- 직원명 추출 패턴 | |
| 78 | +- 파일명 생성 규칙 | |
| 79 | + | |
| 80 | +## 개발 환경 설정 (소스코드 수정시) | |
| 81 | + | |
| 82 | +### Python 패키지 설치 | |
| 83 | +```bash | |
| 84 | +pip install -r requirements.txt | |
| 85 | +``` | |
| 86 | + | |
| 87 | +**requirements.txt에 포함된 패키지들:** | |
| 88 | +- `pymupdf` - PDF 처리 (PyMuPDF/fitz) | |
| 89 | +- `pytesseract` - OCR 텍스트 인식 | |
| 90 | +- `Pillow` - 이미지 처리 | |
| 91 | +- `pyinstaller` - exe 파일 생성 | |
| 92 | +- `tkinterdnd2` - GUI 드래그앤드롭 | |
| 93 | +- `pandas` - 데이터 처리 | |
| 94 | +- `openpyxl` - Excel 파일 처리 | |
| 95 | +- `pyperclip` - 클립보드 기능 | |
| 96 | + | |
| 97 | +**참고**: exe 파일만 사용한다면 이 패키지들 설치 불필요 | |
| 98 | + | |
| 99 | +## 빌드 방법 | |
| 100 | + | |
| 101 | +### 자동 빌드 (권장) | |
| 102 | +```batch | |
| 103 | +build.bat | |
| 104 | +``` | |
| 105 | + | |
| 106 | +### 수동 빌드 | |
| 107 | +```batch | |
| 108 | +pyinstaller --onefile --windowed --name "급여명세서분할기_v3_최종수정" --clean --exclude-module torch --exclude-module tensorflow --exclude-module sklearn --exclude-module scipy --exclude-module pandas payslip_splitter_gui_v3.py | |
| 109 | +``` | |
| 110 | + | |
| 111 | +## ⚠️ 주의사항 | |
| 112 | + | |
| 113 | +1. **이메일 설정**: `email_config.json` 수정 후 프로그램 재시작 필요 | |
| 114 | +2. **직원 목록**: GUI에서도 수정 가능하지만, 대량 추가시 JSON 파일 직접 수정 권장 | |
| 115 | +3. **백업**: 중요한 수정 전 `backup/` 폴더 활용 | |
| 116 | +4. **테스트**: 수정 후 반드시 테스트 발송으로 확인 | |
| 117 | + | |
| 118 | +## 문제 해결 | |
| 119 | + | |
| 120 | +- **PDF 첨부 안됨**: 파일 경로에 한글 있는지 확인 | |
| 121 | +- **이메일 발송 실패**: SMTP 설정 및 인증 정보 확인 | |
| 122 | +- **현재달 표시됨**: PDF 암호화로 인한 정상 동작 (다음달로 자동 변환됨) | |
| 123 | +- **발송 완료 상태 이상**: 프로그램 재시작 후 다시 시도(No newline at end of file) |
+++ __pycache__/email_sender.cpython-313.pyc
| Binary file is not shown |
+++ build.bat
... | ... | @@ -0,0 +1,66 @@ |
| 1 | +@echo off | |
| 2 | +chcp 65001 > nul | |
| 3 | +cls | |
| 4 | +echo ================================================ | |
| 5 | +echo 급여명세서 분할기 빌드 스크립트 | |
| 6 | +echo ================================================ | |
| 7 | +echo. | |
| 8 | + | |
| 9 | +echo [1/4] 이전 빌드 파일 정리 중... | |
| 10 | +if exist build rmdir /s /q build | |
| 11 | +if exist dist rmdir /s /q dist | |
| 12 | +if exist *.spec del /q *.spec | |
| 13 | +echo ✓ 정리 완료 | |
| 14 | + | |
| 15 | +echo. | |
| 16 | +echo [2/4] PyInstaller 실행 중... | |
| 17 | +echo 잠시만 기다려주세요... (약 1-2분 소요) | |
| 18 | +echo. | |
| 19 | + | |
| 20 | +pyinstaller --onefile --windowed --name "급여명세서분할기_v3_최종수정" --clean --exclude-module torch --exclude-module tensorflow --exclude-module sklearn --exclude-module scipy --exclude-module pandas payslip_splitter_gui_v3.py | |
| 21 | + | |
| 22 | +echo. | |
| 23 | +if exist "dist\급여명세서분할기_v3_최종수정.exe" ( | |
| 24 | + echo [3/4] exe 파일을 메인 폴더로 복사 중... | |
| 25 | + copy "dist\급여명세서분할기_v3_최종수정.exe" "급여명세서분할기_v3_최종수정.exe" > nul | |
| 26 | + echo ✓ 복사 완료 | |
| 27 | + | |
| 28 | + echo. | |
| 29 | + echo [4/4] 임시 파일 정리 중... | |
| 30 | + rmdir /s /q build > nul 2>&1 | |
| 31 | + rmdir /s /q dist > nul 2>&1 | |
| 32 | + del /q *.spec > nul 2>&1 | |
| 33 | + echo ✓ 정리 완료 | |
| 34 | + | |
| 35 | + echo. | |
| 36 | + echo ================================================ | |
| 37 | + echo 빌드 성공! | |
| 38 | + echo ================================================ | |
| 39 | + echo 생성된 파일: 급여명세서분할기_v3_최종수정.exe | |
| 40 | + echo. | |
| 41 | + | |
| 42 | + for %%A in (급여명세서분할기_v3_최종수정.exe) do ( | |
| 43 | + set size=%%~zA | |
| 44 | + ) | |
| 45 | + | |
| 46 | + if defined size ( | |
| 47 | + echo 파일 크기: %size% bytes | |
| 48 | + ) | |
| 49 | + | |
| 50 | + echo. | |
| 51 | + echo 이제 exe 파일을 다른 컴퓨터에서도 사용할 수 있습니다. | |
| 52 | + | |
| 53 | +) else ( | |
| 54 | + echo. | |
| 55 | + echo ================================================ | |
| 56 | + echo 빌드 실패! ❌ | |
| 57 | + echo ================================================ | |
| 58 | + echo PyInstaller 실행 중 오류가 발생했습니다. | |
| 59 | + echo 다음을 확인해주세요: | |
| 60 | + echo 1. Python이 설치되어 있는지 | |
| 61 | + echo 2. 필요한 패키지가 설치되어 있는지 (pip install -r requirements.txt) | |
| 62 | + echo 3. payslip_splitter_gui_v3.py 파일이 있는지 | |
| 63 | +) | |
| 64 | + | |
| 65 | +echo. | |
| 66 | +pause(No newline at end of file) |
+++ email_config.json
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +{ | |
| 2 | + "smtp_server": "smtp.worksmobile.com", | |
| 3 | + "smtp_port": 465, | |
| 4 | + "use_ssl": true, | |
| 5 | + "sender_email": "noreply@munjaon.co.kr", | |
| 6 | + "sender_name": "급여관리팀", | |
| 7 | + "email_template": { | |
| 8 | + "subject": "{month} 월급명세서 전달드립니다.", | |
| 9 | + "body": "안녕하세요.\n\n늘 맡은 자리에서 애써주심에 깊이 감사드립니다.\n이번 달 급여명세서를 PDF 파일로 첨부하여 보내드립니다.\n\n※ 첨부 파일의 비밀번호는 본인 생년월일 8자리입니다.\n 예) 1990년 1월 15일 → 19900115\n\n확인 중 궁금한 점이나 문의사항이 있으시면 언제든 편하게 연락 주시기 바랍니다.\n\n감사합니다.\n\n급여관리팀 드림" | |
| 10 | + } | |
| 11 | +}(No newline at end of file) |
+++ employees.json
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +{ | |
| 2 | + "김혜리": "khr2205@iten.co.kr", | |
| 3 | + "이준호": "tolag3@iten.co.kr", | |
| 4 | + "유인식": "smartyu@iten.co.kr", | |
| 5 | + "원영현": "dudgusw@iten.co.kr", | |
| 6 | + "유찬희": "ych@iten.co.kr", | |
| 7 | + "조현희": "hc3874@iten.co.kr", | |
| 8 | + "강영묵": "ymkang@iten.co.kr", | |
| 9 | + "조용준": "antelope@iten.co.kr", | |
| 10 | + "우영두": "rosehips@iten.co.kr", | |
| 11 | + "김상훈": "aricowiz@iten.co.kr", | |
| 12 | + "장영익": "yeongik@iten.co.kr", | |
| 13 | + "정다은": "jungde@iten.co.kr", | |
| 14 | + "이지우": "dlwldn1024@iten.co.kr", | |
| 15 | + "박진순": "jsp@iten.co.kr", | |
| 16 | + "정수빈": "dhgksk99@iten.co.kr", | |
| 17 | + "강민경": "kmk0522@iten.co.kr", | |
| 18 | + "이호영": "hylee@iten.co.kr" | |
| 19 | +}(No newline at end of file) |
+++ payslip_splitter_gui_v3.py
... | ... | @@ -0,0 +1,961 @@ |
| 1 | +import tkinter as tk | |
| 2 | +from tkinter import filedialog, messagebox, ttk, scrolledtext | |
| 3 | +import fitz # PyMuPDF | |
| 4 | +import re | |
| 5 | +import os | |
| 6 | +import threading | |
| 7 | +from tkinter import font | |
| 8 | +from tkinterdnd2 import DND_FILES, TkinterDnD | |
| 9 | +import json | |
| 10 | +from datetime import datetime | |
| 11 | + | |
| 12 | +# 사용자 정의 모듈 임포트 | |
| 13 | +from src.employee_manager import EmployeeManager | |
| 14 | +from src.email_sender import PayslipEmailSender, EmailConfig | |
| 15 | +from src.pdf_preview import PDFPreviewManager | |
| 16 | + | |
| 17 | +def extract_birth_date_numbers(text): | |
| 18 | + """ | |
| 19 | + 텍스트에서 '생년월일' 라벨 오른쪽에 있는 날짜를 찾아 숫자만 반환합니다. | |
| 20 | + """ | |
| 21 | + patterns = [ | |
| 22 | + r'생년월일\s*[::]?\s*(\d{4})[-.\/\s](\d{1,2})[-.\/\s](\d{1,2})', | |
| 23 | + r'생년월일\s+(\d{4})[-.\/](\d{1,2})[-.\/](\d{1,2})', | |
| 24 | + ] | |
| 25 | + | |
| 26 | + for pattern in patterns: | |
| 27 | + match = re.search(pattern, text) | |
| 28 | + if match: | |
| 29 | + year, month, day = match.groups() | |
| 30 | + month = month.zfill(2) | |
| 31 | + day = day.zfill(2) | |
| 32 | + return year + month + day | |
| 33 | + | |
| 34 | + return None | |
| 35 | + | |
| 36 | +class PayslipFileItem: | |
| 37 | + """개별 급여명세서 파일 아이템 UI 컴포넌트""" | |
| 38 | + | |
| 39 | + def __init__(self, parent_frame, employee_name, email, pdf_path, preview_manager, on_status_change=None): | |
| 40 | + self.parent_frame = parent_frame | |
| 41 | + self.employee_name = employee_name | |
| 42 | + self.email = email | |
| 43 | + self.pdf_path = pdf_path | |
| 44 | + self.preview_manager = preview_manager | |
| 45 | + self.on_status_change = on_status_change | |
| 46 | + | |
| 47 | + # PDF 정보 로드 | |
| 48 | + self.pdf_info = preview_manager.get_pdf_info(pdf_path) if pdf_path and os.path.exists(pdf_path) else {} | |
| 49 | + | |
| 50 | + self.create_ui() | |
| 51 | + | |
| 52 | + def create_ui(self): | |
| 53 | + # 메인 컨테이너 프레임 | |
| 54 | + self.main_frame = ttk.Frame(self.parent_frame, relief='ridge', padding=5) | |
| 55 | + self.main_frame.pack(fill='x', padx=5, pady=2) | |
| 56 | + | |
| 57 | + # 상단: 직원 정보 및 체크박스 | |
| 58 | + top_frame = ttk.Frame(self.main_frame) | |
| 59 | + top_frame.pack(fill='x') | |
| 60 | + | |
| 61 | + # 발송 여부 체크박스 | |
| 62 | + self.send_var = tk.BooleanVar(value=bool(self.email)) | |
| 63 | + self.send_check = ttk.Checkbutton(top_frame, variable=self.send_var, | |
| 64 | + command=self.on_send_status_change) | |
| 65 | + self.send_check.pack(side='left', padx=(0, 5)) | |
| 66 | + | |
| 67 | + # 직원 정보 | |
| 68 | + email_display = self.email if self.email else "❌ 매칭되지 않음" | |
| 69 | + info_color = 'black' if self.email else 'red' | |
| 70 | + | |
| 71 | + info_label = ttk.Label(top_frame, | |
| 72 | + text=f" {self.employee_name} → {email_display}", | |
| 73 | + font=('맑은 고딕', 9, 'bold')) | |
| 74 | + info_label.pack(side='left') | |
| 75 | + | |
| 76 | + # 중단: 파일 정보 및 버튼들 | |
| 77 | + middle_frame = ttk.Frame(self.main_frame) | |
| 78 | + middle_frame.pack(fill='x', pady=(5, 0)) | |
| 79 | + | |
| 80 | + # 파일 정보 | |
| 81 | + if self.pdf_path and os.path.exists(self.pdf_path): | |
| 82 | + file_size = self.preview_manager.format_file_size(self.pdf_info.get('file_size', 0)) | |
| 83 | + page_count = self.pdf_info.get('page_count', 0) | |
| 84 | + | |
| 85 | + file_info_text = f" {os.path.basename(self.pdf_path)} ({file_size})" | |
| 86 | + if self.pdf_info.get('is_encrypted'): | |
| 87 | + file_info_text += " 비밀번호 보호" | |
| 88 | + else: | |
| 89 | + file_info_text += f" ({page_count}페이지)" | |
| 90 | + else: | |
| 91 | + file_info_text = " 파일을 찾을 수 없습니다" | |
| 92 | + | |
| 93 | + file_label = ttk.Label(middle_frame, text=file_info_text) | |
| 94 | + file_label.pack(side='left') | |
| 95 | + | |
| 96 | + # 버튼들 | |
| 97 | + button_frame = ttk.Frame(middle_frame) | |
| 98 | + button_frame.pack(side='right') | |
| 99 | + | |
| 100 | + if self.pdf_path and os.path.exists(self.pdf_path): | |
| 101 | + ttk.Button(button_frame, text=" 열기", | |
| 102 | + command=self.open_file, width=8).pack(side='left', padx=2) | |
| 103 | + ttk.Button(button_frame, text=" 폴더", | |
| 104 | + command=self.open_folder, width=8).pack(side='left', padx=2) | |
| 105 | + | |
| 106 | + # 발송 완료 상태 표시용 | |
| 107 | + self.sent_status = False # 발송 완료 상태 | |
| 108 | + self.sent_label = None # 발송 완료 라벨 | |
| 109 | + | |
| 110 | + def mark_as_sent(self): | |
| 111 | + """발송 완료로 표시""" | |
| 112 | + self.sent_status = True | |
| 113 | + if not self.sent_label: | |
| 114 | + self.sent_label = ttk.Label(self.main_frame, text="✅ 발송 완료", | |
| 115 | + foreground='green', font=('맑은 고딕', 9, 'bold')) | |
| 116 | + self.sent_label.pack(side='right', padx=5) | |
| 117 | + | |
| 118 | + # 체크박스 비활성화 | |
| 119 | + self.send_check.config(state='disabled') | |
| 120 | + | |
| 121 | + def open_folder(self): | |
| 122 | + """파일이 있는 폴더 열기""" | |
| 123 | + if self.pdf_path and os.path.exists(self.pdf_path): | |
| 124 | + import subprocess | |
| 125 | + import platform | |
| 126 | + try: | |
| 127 | + folder_path = os.path.dirname(self.pdf_path) | |
| 128 | + if platform.system() == 'Windows': | |
| 129 | + subprocess.run(['explorer', '/select,', self.pdf_path]) | |
| 130 | + elif platform.system() == 'Darwin': # macOS | |
| 131 | + subprocess.run(['open', '-R', self.pdf_path]) | |
| 132 | + else: # Linux | |
| 133 | + subprocess.run(['xdg-open', folder_path]) | |
| 134 | + except Exception as e: | |
| 135 | + messagebox.showerror("오류", f"폴더를 열 수 없습니다:\n{str(e)}") | |
| 136 | + else: | |
| 137 | + messagebox.showwarning("파일 없음", "폴더를 열 파일이 없습니다.") | |
| 138 | + | |
| 139 | + def open_file(self): | |
| 140 | + """시스템 기본 프로그램으로 파일 열기""" | |
| 141 | + if self.pdf_path and os.path.exists(self.pdf_path): | |
| 142 | + # 비밀번호 보호된 파일이면 안내 메시지 표시 | |
| 143 | + if self.pdf_info.get('is_encrypted'): | |
| 144 | + response = messagebox.askyesno( | |
| 145 | + "비밀번호 보호된 파일", | |
| 146 | + f"{self.employee_name}님의 급여명세서는 비밀번호로 \n보호되어 있습니다.\n\n 비밀번호: 생년월일 8자리\n(예: 1990년 1월 15일 → 19900115)\n\n파일을 여시겠습니까?" | |
| 147 | + ) | |
| 148 | + if not response: | |
| 149 | + return | |
| 150 | + | |
| 151 | + success = self.preview_manager.open_file_with_system(self.pdf_path) | |
| 152 | + if not success: | |
| 153 | + messagebox.showerror("오류", "파일을 열 수 없습니다.") | |
| 154 | + else: | |
| 155 | + messagebox.showwarning("파일 없음", "열 파일이 없습니다.") | |
| 156 | + | |
| 157 | + def on_send_status_change(self): | |
| 158 | + """발송 상태 변경시 UI 업데이트""" | |
| 159 | + if self.on_status_change: | |
| 160 | + self.on_status_change() | |
| 161 | + | |
| 162 | + def get_send_data(self): | |
| 163 | + """이메일 발송 데이터 반환""" | |
| 164 | + # 발송 완료된 항목은 제외 | |
| 165 | + if self.sent_status: | |
| 166 | + print(f"[DEBUG] 발송 완료된 항목 제외: {self.employee_name}") | |
| 167 | + return None | |
| 168 | + | |
| 169 | + if self.send_var.get() and self.email and self.pdf_path: | |
| 170 | + if os.path.exists(self.pdf_path): | |
| 171 | + file_size = os.path.getsize(self.pdf_path) | |
| 172 | + print(f"[DEBUG] 발송 대상: {self.employee_name} -> {self.email}") | |
| 173 | + print(f"[DEBUG] PDF 파일: {self.pdf_path} ({file_size} bytes)") | |
| 174 | + return { | |
| 175 | + "name": self.employee_name, | |
| 176 | + "email": self.email, | |
| 177 | + "pdf_path": self.pdf_path | |
| 178 | + } | |
| 179 | + else: | |
| 180 | + print(f"[ERROR] PDF 파일 없음: {self.pdf_path}") | |
| 181 | + return None | |
| 182 | + | |
| 183 | +class EmployeeManagementTab: | |
| 184 | + """직원 관리 탭""" | |
| 185 | + | |
| 186 | + def __init__(self, parent_notebook, employee_manager): | |
| 187 | + self.employee_manager = employee_manager | |
| 188 | + self.selected_employee = None | |
| 189 | + | |
| 190 | + # 직원 관리 탭 생성 | |
| 191 | + self.frame = ttk.Frame(parent_notebook) | |
| 192 | + parent_notebook.add(self.frame, text="직원 관리") | |
| 193 | + | |
| 194 | + self.setup_ui() | |
| 195 | + self.refresh_employee_list() | |
| 196 | + | |
| 197 | + def setup_ui(self): | |
| 198 | + # 메인 컨테이너 | |
| 199 | + main_container = ttk.Frame(self.frame, padding=10) | |
| 200 | + main_container.pack(fill='both', expand=True) | |
| 201 | + | |
| 202 | + # 상단: 검색 영역 | |
| 203 | + search_frame = ttk.LabelFrame(main_container, text="직원 검색", padding=5) | |
| 204 | + search_frame.pack(fill='x', pady=(0, 10)) | |
| 205 | + | |
| 206 | + self.search_var = tk.StringVar() | |
| 207 | + ttk.Label(search_frame, text="검색:").pack(side='left') | |
| 208 | + search_entry = ttk.Entry(search_frame, textvariable=self.search_var, width=20) | |
| 209 | + search_entry.pack(side='left', padx=5) | |
| 210 | + search_entry.bind('<KeyRelease>', self.on_search) | |
| 211 | + | |
| 212 | + ttk.Button(search_frame, text="전체보기", | |
| 213 | + command=self.show_all).pack(side='left', padx=5) | |
| 214 | + | |
| 215 | + # 중앙: 좌우 분할 영역 | |
| 216 | + paned_window = ttk.PanedWindow(main_container, orient='horizontal') | |
| 217 | + paned_window.pack(fill='both', expand=True) | |
| 218 | + | |
| 219 | + # 왼쪽: 직원 목록 | |
| 220 | + left_frame = ttk.LabelFrame(paned_window, text="직원 목록", padding=5) | |
| 221 | + paned_window.add(left_frame, weight=2) | |
| 222 | + | |
| 223 | + # 직원 목록 (TreeView 사용) | |
| 224 | + columns = ('name', 'email') | |
| 225 | + self.tree = ttk.Treeview(left_frame, columns=columns, show='headings', height=15) | |
| 226 | + self.tree.heading('name', text='이름') | |
| 227 | + self.tree.heading('email', text='이메일') | |
| 228 | + self.tree.column('name', width=100) | |
| 229 | + self.tree.column('email', width=200) | |
| 230 | + | |
| 231 | + # 스크롤바 | |
| 232 | + scrollbar = ttk.Scrollbar(left_frame, orient='vertical', command=self.tree.yview) | |
| 233 | + self.tree.configure(yscrollcommand=scrollbar.set) | |
| 234 | + | |
| 235 | + self.tree.pack(side='left', fill='both', expand=True) | |
| 236 | + scrollbar.pack(side='right', fill='y') | |
| 237 | + | |
| 238 | + # 트리 선택 이벤트 | |
| 239 | + self.tree.bind('<<TreeviewSelect>>', self.on_tree_select) | |
| 240 | + | |
| 241 | + # 목록 하단 버튼들 | |
| 242 | + list_button_frame = ttk.Frame(left_frame) | |
| 243 | + list_button_frame.pack(fill='x', pady=5) | |
| 244 | + | |
| 245 | + ttk.Button(list_button_frame, text="선택삭제", | |
| 246 | + command=self.delete_selected).pack(side='left', padx=2) | |
| 247 | + | |
| 248 | + # 오른쪽: 편집 영역 | |
| 249 | + right_frame = ttk.LabelFrame(paned_window, text="직원 정보 편집", padding=5) | |
| 250 | + paned_window.add(right_frame, weight=1) | |
| 251 | + | |
| 252 | + # 편집 폼 | |
| 253 | + form_frame = ttk.Frame(right_frame) | |
| 254 | + form_frame.pack(fill='x', pady=5) | |
| 255 | + | |
| 256 | + ttk.Label(form_frame, text="이름:").grid(row=0, column=0, sticky='w', pady=2) | |
| 257 | + self.name_var = tk.StringVar() | |
| 258 | + self.name_entry = ttk.Entry(form_frame, textvariable=self.name_var, width=20) | |
| 259 | + self.name_entry.grid(row=0, column=1, padx=5, pady=2) | |
| 260 | + | |
| 261 | + ttk.Label(form_frame, text="이메일:").grid(row=1, column=0, sticky='w', pady=2) | |
| 262 | + self.email_var = tk.StringVar() | |
| 263 | + self.email_entry = ttk.Entry(form_frame, textvariable=self.email_var, width=30) | |
| 264 | + self.email_entry.grid(row=1, column=1, padx=5, pady=2) | |
| 265 | + | |
| 266 | + # 편집 버튼들 | |
| 267 | + edit_button_frame = ttk.Frame(right_frame) | |
| 268 | + edit_button_frame.pack(fill='x', pady=10) | |
| 269 | + | |
| 270 | + ttk.Button(edit_button_frame, text="새로 추가", | |
| 271 | + command=self.add_employee).pack(fill='x', pady=2) | |
| 272 | + ttk.Button(edit_button_frame, text="수정", | |
| 273 | + command=self.update_employee).pack(fill='x', pady=2) | |
| 274 | + ttk.Button(edit_button_frame, text="삭제", | |
| 275 | + command=self.delete_employee).pack(fill='x', pady=2) | |
| 276 | + | |
| 277 | + # 직원 관리 옵션 | |
| 278 | + option_frame = ttk.LabelFrame(right_frame, text="관리 옵션", padding=5) | |
| 279 | + option_frame.pack(fill='x', pady=10) | |
| 280 | + | |
| 281 | + ttk.Button(option_frame, text="전체 선택", | |
| 282 | + command=self.select_all_employees).pack(fill='x', pady=1) | |
| 283 | + ttk.Button(option_frame, text="선택 해제", | |
| 284 | + command=self.deselect_all_employees).pack(fill='x', pady=1) | |
| 285 | + | |
| 286 | + # 하단: 빠른 추가 | |
| 287 | + quick_frame = ttk.LabelFrame(main_container, text="빠른 추가", padding=5) | |
| 288 | + quick_frame.pack(fill='x', pady=10) | |
| 289 | + | |
| 290 | + quick_container = ttk.Frame(quick_frame) | |
| 291 | + quick_container.pack() | |
| 292 | + | |
| 293 | + ttk.Label(quick_container, text="이름:").pack(side='left') | |
| 294 | + self.quick_name_var = tk.StringVar() | |
| 295 | + quick_name_entry = ttk.Entry(quick_container, textvariable=self.quick_name_var, width=15) | |
| 296 | + quick_name_entry.pack(side='left', padx=5) | |
| 297 | + | |
| 298 | + ttk.Label(quick_container, text="이메일:").pack(side='left', padx=(10, 0)) | |
| 299 | + self.quick_email_var = tk.StringVar() | |
| 300 | + quick_email_entry = ttk.Entry(quick_container, textvariable=self.quick_email_var, width=25) | |
| 301 | + quick_email_entry.pack(side='left', padx=5) | |
| 302 | + | |
| 303 | + ttk.Button(quick_container, text="추가", | |
| 304 | + command=self.quick_add_employee).pack(side='left', padx=5) | |
| 305 | + | |
| 306 | + # 엔터키 바인딩 | |
| 307 | + quick_name_entry.bind('<Return>', lambda e: self.quick_add_employee()) | |
| 308 | + quick_email_entry.bind('<Return>', lambda e: self.quick_add_employee()) | |
| 309 | + | |
| 310 | + def refresh_employee_list(self, filter_text=""): | |
| 311 | + """직원 목록 새로고침""" | |
| 312 | + # 기존 항목 제거 | |
| 313 | + for item in self.tree.get_children(): | |
| 314 | + self.tree.delete(item) | |
| 315 | + | |
| 316 | + # 직원 목록 추가 | |
| 317 | + if filter_text: | |
| 318 | + employees = self.employee_manager.search_employees(filter_text) | |
| 319 | + else: | |
| 320 | + employees = self.employee_manager.get_all_employees() | |
| 321 | + | |
| 322 | + for name, email in sorted(employees.items()): | |
| 323 | + self.tree.insert('', 'end', values=(name, email)) | |
| 324 | + | |
| 325 | + def on_search(self, event=None): | |
| 326 | + """검색 필터링""" | |
| 327 | + self.refresh_employee_list(self.search_var.get()) | |
| 328 | + | |
| 329 | + def show_all(self): | |
| 330 | + """전체 목록 보기""" | |
| 331 | + self.search_var.set("") | |
| 332 | + self.refresh_employee_list() | |
| 333 | + | |
| 334 | + def on_tree_select(self, event): | |
| 335 | + """트리에서 항목 선택시""" | |
| 336 | + selection = self.tree.selection() | |
| 337 | + if selection: | |
| 338 | + item = self.tree.item(selection[0]) | |
| 339 | + values = item['values'] | |
| 340 | + self.name_var.set(values[0]) | |
| 341 | + self.email_var.set(values[1]) | |
| 342 | + self.selected_employee = values[0] | |
| 343 | + | |
| 344 | + def add_employee(self): | |
| 345 | + """직원 추가""" | |
| 346 | + name = self.name_var.get().strip() | |
| 347 | + email = self.email_var.get().strip() | |
| 348 | + | |
| 349 | + if not name or not email: | |
| 350 | + messagebox.showwarning("경고", "이름과 이메일을 모두 입력해주세요.") | |
| 351 | + return | |
| 352 | + | |
| 353 | + if self.employee_manager.add_employee(name, email): | |
| 354 | + messagebox.showinfo("성공", f"'{name}' 직원을 추가했습니다.") | |
| 355 | + self.refresh_employee_list() | |
| 356 | + self.clear_form() | |
| 357 | + else: | |
| 358 | + messagebox.showerror("오류", "이미 존재하는 직원입니다.") | |
| 359 | + | |
| 360 | + def update_employee(self): | |
| 361 | + """직원 정보 수정""" | |
| 362 | + if not self.selected_employee: | |
| 363 | + messagebox.showwarning("경고", "수정할 직원을 선택해주세요.") | |
| 364 | + return | |
| 365 | + | |
| 366 | + new_name = self.name_var.get().strip() | |
| 367 | + new_email = self.email_var.get().strip() | |
| 368 | + | |
| 369 | + if not new_name or not new_email: | |
| 370 | + messagebox.showwarning("경고", "이름과 이메일을 모두 입력해주세요.") | |
| 371 | + return | |
| 372 | + | |
| 373 | + if self.employee_manager.update_employee(self.selected_employee, new_name, new_email): | |
| 374 | + messagebox.showinfo("성공", f"'{new_name}' 직원 정보를 수정했습니다.") | |
| 375 | + self.refresh_employee_list() | |
| 376 | + self.selected_employee = new_name | |
| 377 | + else: | |
| 378 | + messagebox.showerror("오류", "직원 정보 수정에 실패했습니다.") | |
| 379 | + | |
| 380 | + def delete_employee(self): | |
| 381 | + """직원 삭제""" | |
| 382 | + if not self.selected_employee: | |
| 383 | + messagebox.showwarning("경고", "삭제할 직원을 선택해주세요.") | |
| 384 | + return | |
| 385 | + | |
| 386 | + result = messagebox.askyesno("확인", f"'{self.selected_employee}' 직원을 삭제하시겠습니까?") | |
| 387 | + if result: | |
| 388 | + if self.employee_manager.delete_employee(self.selected_employee): | |
| 389 | + messagebox.showinfo("성공", f"'{self.selected_employee}' 직원을 삭제했습니다.") | |
| 390 | + self.refresh_employee_list() | |
| 391 | + self.clear_form() | |
| 392 | + else: | |
| 393 | + messagebox.showerror("오류", "직원 삭제에 실패했습니다.") | |
| 394 | + | |
| 395 | + def delete_selected(self): | |
| 396 | + """선택된 직원들 삭제""" | |
| 397 | + selected_items = self.tree.selection() | |
| 398 | + if not selected_items: | |
| 399 | + messagebox.showwarning("경고", "삭제할 직원을 선택해주세요.") | |
| 400 | + return | |
| 401 | + | |
| 402 | + names = [self.tree.item(item)['values'][0] for item in selected_items] | |
| 403 | + | |
| 404 | + result = messagebox.askyesno("확인", f"{len(names)}명의 직원을 삭제하시겠습니까?") | |
| 405 | + if result: | |
| 406 | + success_count = 0 | |
| 407 | + for name in names: | |
| 408 | + if self.employee_manager.delete_employee(name): | |
| 409 | + success_count += 1 | |
| 410 | + | |
| 411 | + messagebox.showinfo("완료", f"{success_count}명의 직원을 삭제했습니다.") | |
| 412 | + self.refresh_employee_list() | |
| 413 | + self.clear_form() | |
| 414 | + | |
| 415 | + def quick_add_employee(self): | |
| 416 | + """빠른 추가""" | |
| 417 | + name = self.quick_name_var.get().strip() | |
| 418 | + email = self.quick_email_var.get().strip() | |
| 419 | + | |
| 420 | + if not name or not email: | |
| 421 | + messagebox.showwarning("경고", "이름과 이메일을 모두 입력해주세요.") | |
| 422 | + return | |
| 423 | + | |
| 424 | + if self.employee_manager.add_employee(name, email): | |
| 425 | + messagebox.showinfo("성공", f"'{name}' 직원을 추가했습니다.") | |
| 426 | + self.refresh_employee_list() | |
| 427 | + self.quick_name_var.set("") | |
| 428 | + self.quick_email_var.set("") | |
| 429 | + else: | |
| 430 | + messagebox.showerror("오류", "이미 존재하는 직원입니다.") | |
| 431 | + | |
| 432 | + def select_all_employees(self): | |
| 433 | + """모든 직원 선택""" | |
| 434 | + for item in self.employee_tree.get_children(): | |
| 435 | + self.employee_tree.set(item, 'selected', True) | |
| 436 | + self.employee_tree.selection_set(self.employee_tree.get_children()) | |
| 437 | + | |
| 438 | + def deselect_all_employees(self): | |
| 439 | + """모든 직원 선택 해제""" | |
| 440 | + for item in self.employee_tree.get_children(): | |
| 441 | + self.employee_tree.set(item, 'selected', False) | |
| 442 | + self.employee_tree.selection_set(()) | |
| 443 | + | |
| 444 | + def clear_form(self): | |
| 445 | + """폼 초기화""" | |
| 446 | + self.name_var.set("") | |
| 447 | + self.email_var.set("") | |
| 448 | + self.selected_employee = None | |
| 449 | + | |
| 450 | +class PayslipSplitterGUI: | |
| 451 | + def __init__(self, root): | |
| 452 | + self.root = root | |
| 453 | + self.root.title("급여명세서 분할기 v3 - 이메일 자동발송 (드래그앤드롭 지원)") | |
| 454 | + self.root.geometry("900x700") | |
| 455 | + | |
| 456 | + # 한글 폰트 설정 | |
| 457 | + try: | |
| 458 | + default_font = font.nametofont("TkDefaultFont") | |
| 459 | + default_font.configure(family="맑은 고딕", size=9) | |
| 460 | + self.root.option_add("*Font", default_font) | |
| 461 | + except: | |
| 462 | + pass | |
| 463 | + | |
| 464 | + # 관리자 초기화 | |
| 465 | + self.employee_manager = EmployeeManager() | |
| 466 | + self.email_sender = PayslipEmailSender() | |
| 467 | + self.preview_manager = PDFPreviewManager() | |
| 468 | + | |
| 469 | + # 분할된 파일 정보 | |
| 470 | + self.split_files = [] # [{"name": "이름", "pdf_path": "경로"}] | |
| 471 | + self.file_items = [] # PayslipFileItem 객체들 | |
| 472 | + | |
| 473 | + # 드래그앤드롭 설정 | |
| 474 | + self.root.drop_target_register(DND_FILES) | |
| 475 | + self.root.dnd_bind('<<Drop>>', self.on_drop) | |
| 476 | + | |
| 477 | + self.setup_ui() | |
| 478 | + | |
| 479 | + def setup_ui(self): | |
| 480 | + # 노트북 (탭) 컨테이너 | |
| 481 | + self.notebook = ttk.Notebook(self.root) | |
| 482 | + self.notebook.pack(fill='both', expand=True, padx=10, pady=10) | |
| 483 | + | |
| 484 | + # 탭 1: PDF 분할 | |
| 485 | + self.setup_split_tab() | |
| 486 | + | |
| 487 | + # 탭 2: 이메일 발송 | |
| 488 | + self.setup_email_tab() | |
| 489 | + | |
| 490 | + # 탭 3: 직원 관리 | |
| 491 | + self.employee_tab = EmployeeManagementTab(self.notebook, self.employee_manager) | |
| 492 | + | |
| 493 | + def setup_split_tab(self): | |
| 494 | + """PDF 분할 탭 설정""" | |
| 495 | + split_frame = ttk.Frame(self.notebook) | |
| 496 | + self.notebook.add(split_frame, text="PDF 분할") | |
| 497 | + | |
| 498 | + main_frame = ttk.Frame(split_frame, padding="10") | |
| 499 | + main_frame.pack(fill='both', expand=True) | |
| 500 | + | |
| 501 | + # 파일 선택 섹션 | |
| 502 | + file_frame = ttk.LabelFrame(main_frame, text="파일 선택", padding="5") | |
| 503 | + file_frame.pack(fill='x', pady=(0, 10)) | |
| 504 | + | |
| 505 | + self.file_path_var = tk.StringVar() | |
| 506 | + ttk.Label(file_frame, text="PDF 파일: (PDF 파일을 드래그해서 놓거나 찾아보기 버튼 사용)").pack(anchor='w') | |
| 507 | + | |
| 508 | + file_entry_frame = ttk.Frame(file_frame) | |
| 509 | + file_entry_frame.pack(fill='x', pady=5) | |
| 510 | + | |
| 511 | + self.file_entry = ttk.Entry(file_entry_frame, textvariable=self.file_path_var) | |
| 512 | + self.file_entry.pack(side='left', fill='x', expand=True, padx=(0, 5)) | |
| 513 | + self.file_entry.drop_target_register(DND_FILES) | |
| 514 | + self.file_entry.dnd_bind('<<Drop>>', self.on_drop) | |
| 515 | + | |
| 516 | + ttk.Button(file_entry_frame, text="찾아보기", command=self.browse_file).pack(side='right') | |
| 517 | + | |
| 518 | + # 옵션 섹션 | |
| 519 | + options_frame = ttk.LabelFrame(main_frame, text="옵션", padding="5") | |
| 520 | + options_frame.pack(fill='x', pady=(0, 10)) | |
| 521 | + | |
| 522 | + self.use_password_var = tk.BooleanVar(value=True) | |
| 523 | + ttk.Checkbutton(options_frame, text="생년월일을 비밀번호로 설정", | |
| 524 | + variable=self.use_password_var).pack(anchor='w', pady=2) | |
| 525 | + | |
| 526 | + self.overwrite_var = tk.BooleanVar(value=True) | |
| 527 | + ttk.Checkbutton(options_frame, text="기존 파일 덮어쓰기", | |
| 528 | + variable=self.overwrite_var).pack(anchor='w', pady=2) | |
| 529 | + | |
| 530 | + self.auto_email_var = tk.BooleanVar(value=False) | |
| 531 | + ttk.Checkbutton(options_frame, text="분할 완료 후 이메일 자동 발송", | |
| 532 | + variable=self.auto_email_var).pack(anchor='w', pady=2) | |
| 533 | + | |
| 534 | + # 처리 버튼 | |
| 535 | + button_frame = ttk.Frame(main_frame) | |
| 536 | + button_frame.pack(pady=(0, 10)) | |
| 537 | + | |
| 538 | + self.process_button = ttk.Button(button_frame, text="급여명세서 분할 시작", | |
| 539 | + command=self.start_processing) | |
| 540 | + self.process_button.pack() | |
| 541 | + | |
| 542 | + # 진행 상황 | |
| 543 | + progress_frame = ttk.LabelFrame(main_frame, text="진행 상황", padding="5") | |
| 544 | + progress_frame.pack(fill='x', pady=(0, 10)) | |
| 545 | + | |
| 546 | + self.progress_var = tk.StringVar(value="대기 중...") | |
| 547 | + ttk.Label(progress_frame, textvariable=self.progress_var).pack(anchor='w') | |
| 548 | + | |
| 549 | + self.progress_bar = ttk.Progressbar(progress_frame, mode='indeterminate') | |
| 550 | + self.progress_bar.pack(fill='x', pady=(5, 0)) | |
| 551 | + | |
| 552 | + # 결과 로그 | |
| 553 | + log_frame = ttk.LabelFrame(main_frame, text="처리 결과", padding="5") | |
| 554 | + log_frame.pack(fill='both', expand=True) | |
| 555 | + | |
| 556 | + self.log_text = scrolledtext.ScrolledText(log_frame, height=10, state='disabled') | |
| 557 | + self.log_text.pack(fill='both', expand=True) | |
| 558 | + | |
| 559 | + def setup_email_tab(self): | |
| 560 | + """이메일 발송 탭 설정""" | |
| 561 | + email_frame = ttk.Frame(self.notebook) | |
| 562 | + self.notebook.add(email_frame, text="이메일 발송") | |
| 563 | + | |
| 564 | + main_frame = ttk.Frame(email_frame, padding="10") | |
| 565 | + main_frame.pack(fill='both', expand=True) | |
| 566 | + | |
| 567 | + # 이메일 설정 섹션 | |
| 568 | + config_frame = ttk.LabelFrame(main_frame, text="이메일 설정", padding="5") | |
| 569 | + config_frame.pack(fill='x', pady=(0, 10)) | |
| 570 | + | |
| 571 | + config_grid = ttk.Frame(config_frame) | |
| 572 | + config_grid.pack(fill='x') | |
| 573 | + | |
| 574 | + # 기본 이메일 설정 (수정 불가) | |
| 575 | + ttk.Label(config_grid, text="사용자명:", font=('맑은 고딕', 9, 'bold')).grid(row=0, column=0, sticky='w', padx=(0, 5)) | |
| 576 | + self.email_user_var = tk.StringVar(value="noreply@munjaon.co.kr") | |
| 577 | + user_entry = ttk.Entry(config_grid, textvariable=self.email_user_var, width=30, state='readonly') | |
| 578 | + user_entry.grid(row=0, column=1, padx=5) | |
| 579 | + | |
| 580 | + ttk.Label(config_grid, text="비밀번호:", font=('맑은 고딕', 9, 'bold')).grid(row=1, column=0, sticky='w', padx=(0, 5), pady=2) | |
| 581 | + self.email_pass_var = tk.StringVar(value="iEWkihhyZipl") | |
| 582 | + pass_entry = ttk.Entry(config_grid, textvariable=self.email_pass_var, width=30, show="*", state='readonly') | |
| 583 | + pass_entry.grid(row=1, column=1, padx=5, pady=2) | |
| 584 | + | |
| 585 | + # 연결 테스트 버튼 | |
| 586 | + test_button = ttk.Button(config_grid, text=" 연결 테스트", command=self.test_email_connection) | |
| 587 | + test_button.grid(row=0, column=2, padx=10, rowspan=2) | |
| 588 | + | |
| 589 | + # 설정 안내 라벨 | |
| 590 | + info_label = ttk.Label(config_grid, text="ℹ️ 기본 이메일 설정이 적용되어 있습니다.", | |
| 591 | + font=('맑은 고딕', 8), foreground='gray') | |
| 592 | + info_label.grid(row=2, column=0, columnspan=3, pady=(5, 0)) | |
| 593 | + | |
| 594 | + # 파일 매칭 섹션 | |
| 595 | + self.files_frame = ttk.LabelFrame(main_frame, text="분할된 파일 및 발송 대상", padding="5") | |
| 596 | + self.files_frame.pack(fill='both', expand=True, pady=(0, 10)) | |
| 597 | + | |
| 598 | + # 스크롤 가능한 프레임 | |
| 599 | + self.files_canvas = tk.Canvas(self.files_frame, height=300) | |
| 600 | + files_scrollbar = ttk.Scrollbar(self.files_frame, orient="vertical", command=self.files_canvas.yview) | |
| 601 | + self.files_scrollable_frame = ttk.Frame(self.files_canvas) | |
| 602 | + | |
| 603 | + self.files_scrollable_frame.bind( | |
| 604 | + "<Configure>", | |
| 605 | + lambda e: self.files_canvas.configure(scrollregion=self.files_canvas.bbox("all")) | |
| 606 | + ) | |
| 607 | + | |
| 608 | + self.files_canvas.create_window((0, 0), window=self.files_scrollable_frame, anchor="nw") | |
| 609 | + self.files_canvas.configure(yscrollcommand=files_scrollbar.set) | |
| 610 | + | |
| 611 | + self.files_canvas.pack(side="left", fill="both", expand=True) | |
| 612 | + files_scrollbar.pack(side="right", fill="y") | |
| 613 | + | |
| 614 | + # 발송 버튼 및 상태 | |
| 615 | + send_frame = ttk.Frame(main_frame) | |
| 616 | + send_frame.pack(fill='x', pady=(0, 10)) | |
| 617 | + | |
| 618 | + left_buttons = ttk.Frame(send_frame) | |
| 619 | + left_buttons.pack(side='left') | |
| 620 | + | |
| 621 | + ttk.Button(left_buttons, text="전체 선택", command=self.select_all_files).pack(side='left', padx=(0, 5)) | |
| 622 | + ttk.Button(left_buttons, text="전체 해제", command=self.deselect_all_files).pack(side='left', padx=5) | |
| 623 | + ttk.Button(left_buttons, text="매칭 새로고침", command=self.refresh_file_matching).pack(side='left', padx=5) | |
| 624 | + | |
| 625 | + self.send_button = ttk.Button(send_frame, text="선택된 직원에게 이메일 발송", | |
| 626 | + command=self.start_email_sending) | |
| 627 | + self.send_button.pack(side='right') | |
| 628 | + | |
| 629 | + # 발송 상태 | |
| 630 | + self.email_status_var = tk.StringVar(value="이메일 발송 대기") | |
| 631 | + ttk.Label(main_frame, textvariable=self.email_status_var).pack() | |
| 632 | + | |
| 633 | + def on_drop(self, event): | |
| 634 | + """드래그앤드롭으로 파일을 받았을 때 처리""" | |
| 635 | + files = self.root.tk.splitlist(event.data) | |
| 636 | + if files: | |
| 637 | + file_path = files[0] | |
| 638 | + if file_path.lower().endswith('.pdf'): | |
| 639 | + self.file_path_var.set(file_path) | |
| 640 | + self.log_message(f"파일이 드롭되었습니다: {os.path.basename(file_path)}") | |
| 641 | + else: | |
| 642 | + messagebox.showwarning("경고", "PDF 파일만 지원됩니다.") | |
| 643 | + | |
| 644 | + def browse_file(self): | |
| 645 | + filename = filedialog.askopenfilename( | |
| 646 | + title="급여명세서 PDF 파일 선택", | |
| 647 | + filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")] | |
| 648 | + ) | |
| 649 | + if filename: | |
| 650 | + self.file_path_var.set(filename) | |
| 651 | + | |
| 652 | + def log_message(self, message): | |
| 653 | + """로그 텍스트에 메시지 추가""" | |
| 654 | + self.log_text.config(state='normal') | |
| 655 | + self.log_text.insert(tk.END, message + '\n') | |
| 656 | + self.log_text.config(state='disabled') | |
| 657 | + self.log_text.see(tk.END) | |
| 658 | + self.root.update() | |
| 659 | + | |
| 660 | + def clear_log(self): | |
| 661 | + """로그 텍스트 클리어""" | |
| 662 | + self.log_text.config(state='normal') | |
| 663 | + self.log_text.delete(1.0, tk.END) | |
| 664 | + self.log_text.config(state='disabled') | |
| 665 | + | |
| 666 | + def start_processing(self): | |
| 667 | + """처리 시작 (별도 스레드에서)""" | |
| 668 | + if not self.file_path_var.get(): | |
| 669 | + messagebox.showerror("오류", "PDF 파일을 선택해주세요.") | |
| 670 | + return | |
| 671 | + | |
| 672 | + if not os.path.exists(self.file_path_var.get()): | |
| 673 | + messagebox.showerror("오류", "선택한 파일이 존재하지 않습니다.") | |
| 674 | + return | |
| 675 | + | |
| 676 | + # UI 상태 변경 | |
| 677 | + self.process_button.config(state='disabled') | |
| 678 | + self.progress_bar.start() | |
| 679 | + self.progress_var.set("처리 중...") | |
| 680 | + self.clear_log() | |
| 681 | + | |
| 682 | + # 별도 스레드에서 처리 | |
| 683 | + thread = threading.Thread(target=self.process_pdf) | |
| 684 | + thread.daemon = True | |
| 685 | + thread.start() | |
| 686 | + | |
| 687 | + def process_pdf(self): | |
| 688 | + """PDF 처리 메인 로직""" | |
| 689 | + try: | |
| 690 | + input_pdf = self.file_path_var.get() | |
| 691 | + use_password = self.use_password_var.get() | |
| 692 | + overwrite = self.overwrite_var.get() | |
| 693 | + | |
| 694 | + self.log_message(f"파일 처리 시작: {os.path.basename(input_pdf)}") | |
| 695 | + self.log_message(f"비밀번호 설정: {'사용' if use_password else '사용 안 함'}") | |
| 696 | + self.log_message("=" * 50) | |
| 697 | + | |
| 698 | + base_name = os.path.splitext(os.path.basename(input_pdf))[0] | |
| 699 | + doc = fitz.open(input_pdf) | |
| 700 | + | |
| 701 | + if not doc.page_count: | |
| 702 | + self.log_message("오류: PDF에 페이지가 없습니다.") | |
| 703 | + return | |
| 704 | + | |
| 705 | + self.log_message(f"총 {doc.page_count}페이지 처리를 시작합니다...") | |
| 706 | + | |
| 707 | + success_count = 0 | |
| 708 | + fail_count = 0 | |
| 709 | + self.split_files = [] | |
| 710 | + | |
| 711 | + for i, page in enumerate(doc): | |
| 712 | + page_num = i + 1 | |
| 713 | + text = page.get_text() | |
| 714 | + | |
| 715 | + name_match = re.search(r"사원명:\s*(\S+)", text) | |
| 716 | + | |
| 717 | + if name_match: | |
| 718 | + name = name_match.group(1) | |
| 719 | + output_filename = f"{base_name}_{name}.pdf" | |
| 720 | + | |
| 721 | + if os.path.exists(output_filename) and not overwrite: | |
| 722 | + self.log_message(f"- {page_num}페이지: '{name}'님 파일이 이미 존재합니다. (건너뜀)") | |
| 723 | + continue | |
| 724 | + | |
| 725 | + new_doc = fitz.open() | |
| 726 | + new_doc.insert_pdf(doc, from_page=i, to_page=i) | |
| 727 | + | |
| 728 | + if use_password: | |
| 729 | + birth_date_password = extract_birth_date_numbers(text) | |
| 730 | + | |
| 731 | + if birth_date_password: | |
| 732 | + try: | |
| 733 | + new_doc.save(output_filename, | |
| 734 | + encryption=fitz.PDF_ENCRYPT_AES_256, | |
| 735 | + user_pw=birth_date_password, | |
| 736 | + owner_pw=birth_date_password) | |
| 737 | + self.log_message(f"- {page_num}페이지: '{name}'님 (비밀번호: {birth_date_password}) ✓") | |
| 738 | + success_count += 1 | |
| 739 | + except Exception as e: | |
| 740 | + new_doc.save(output_filename) | |
| 741 | + self.log_message(f"- {page_num}페이지: '{name}'님 (비밀번호 설정 실패, 일반 파일로 저장) ⚠️") | |
| 742 | + success_count += 1 | |
| 743 | + else: | |
| 744 | + new_doc.save(output_filename) | |
| 745 | + self.log_message(f"- {page_num}페이지: '{name}'님 (생년월일 없음, 일반 파일로 저장) ⚠️") | |
| 746 | + success_count += 1 | |
| 747 | + else: | |
| 748 | + new_doc.save(output_filename) | |
| 749 | + self.log_message(f"- {page_num}페이지: '{name}'님 (일반 파일로 저장) ✓") | |
| 750 | + success_count += 1 | |
| 751 | + | |
| 752 | + # 분할된 파일 정보 저장 | |
| 753 | + self.split_files.append({ | |
| 754 | + "name": name, | |
| 755 | + "pdf_path": output_filename | |
| 756 | + }) | |
| 757 | + | |
| 758 | + new_doc.close() | |
| 759 | + else: | |
| 760 | + self.log_message(f"- {page_num}페이지: 사원명을 찾지 못했습니다. ❌") | |
| 761 | + fail_count += 1 | |
| 762 | + | |
| 763 | + doc.close() | |
| 764 | + | |
| 765 | + self.log_message("=" * 50) | |
| 766 | + self.log_message(f"처리 완료! 성공: {success_count}개, 실패: {fail_count}개") | |
| 767 | + | |
| 768 | + if success_count > 0: | |
| 769 | + # 이메일 탭의 파일 목록 업데이트 | |
| 770 | + self.root.after(0, self.refresh_file_matching) | |
| 771 | + | |
| 772 | + # 자동 이메일 발송 확인 | |
| 773 | + if self.auto_email_var.get(): | |
| 774 | + self.root.after(0, self.auto_send_emails) | |
| 775 | + | |
| 776 | + messagebox.showinfo("완료", f"급여명세서 분할이 완료되었습니다.\n성공: {success_count}개, 실패: {fail_count}개") | |
| 777 | + else: | |
| 778 | + messagebox.showwarning("경고", "처리된 파일이 없습니다. PDF 형식을 확인해주세요.") | |
| 779 | + | |
| 780 | + except Exception as e: | |
| 781 | + error_msg = f"오류가 발생했습니다: {str(e)}" | |
| 782 | + self.log_message(error_msg) | |
| 783 | + messagebox.showerror("오류", error_msg) | |
| 784 | + | |
| 785 | + finally: | |
| 786 | + self.root.after(0, self.finish_processing) | |
| 787 | + | |
| 788 | + def finish_processing(self): | |
| 789 | + """처리 완료 후 UI 상태 복원""" | |
| 790 | + self.progress_bar.stop() | |
| 791 | + self.progress_var.set("완료") | |
| 792 | + self.process_button.config(state='normal') | |
| 793 | + | |
| 794 | + def refresh_file_matching(self): | |
| 795 | + """파일 매칭 새로고침""" | |
| 796 | + # 기존 아이템들 제거 | |
| 797 | + for item in self.file_items: | |
| 798 | + if hasattr(item, 'main_frame'): | |
| 799 | + item.main_frame.destroy() | |
| 800 | + self.file_items.clear() | |
| 801 | + | |
| 802 | + # 직원 목록 가져오기 | |
| 803 | + employees = self.employee_manager.get_all_employees() | |
| 804 | + | |
| 805 | + # 분할된 파일들에 대해 매칭 | |
| 806 | + for file_info in self.split_files: | |
| 807 | + name = file_info["name"] | |
| 808 | + pdf_path = file_info["pdf_path"] | |
| 809 | + email = employees.get(name, "") | |
| 810 | + | |
| 811 | + # 파일 아이템 생성 | |
| 812 | + file_item = PayslipFileItem( | |
| 813 | + self.files_scrollable_frame, | |
| 814 | + name, email, pdf_path, | |
| 815 | + self.preview_manager, | |
| 816 | + self.update_send_status | |
| 817 | + ) | |
| 818 | + self.file_items.append(file_item) | |
| 819 | + | |
| 820 | + self.update_send_status() | |
| 821 | + | |
| 822 | + def update_send_status(self): | |
| 823 | + """발송 상태 업데이트""" | |
| 824 | + total_count = len(self.file_items) | |
| 825 | + send_count = sum(1 for item in self.file_items if item.send_var.get() and item.email) | |
| 826 | + matched_count = sum(1 for item in self.file_items if item.email) | |
| 827 | + | |
| 828 | + status = f"전체: {total_count}명, 매칭됨: {matched_count}명, 발송대상: {send_count}명" | |
| 829 | + self.email_status_var.set(status) | |
| 830 | + | |
| 831 | + def select_all_files(self): | |
| 832 | + """전체 파일 선택""" | |
| 833 | + for item in self.file_items: | |
| 834 | + if item.email: # 이메일이 있는 경우만 | |
| 835 | + item.send_var.set(True) | |
| 836 | + item.on_send_status_change() | |
| 837 | + | |
| 838 | + def deselect_all_files(self): | |
| 839 | + """전체 파일 해제""" | |
| 840 | + for item in self.file_items: | |
| 841 | + item.send_var.set(False) | |
| 842 | + item.on_send_status_change() | |
| 843 | + | |
| 844 | + def test_email_connection(self): | |
| 845 | + """이메일 연결 테스트""" | |
| 846 | + username = self.email_user_var.get().strip() | |
| 847 | + password = self.email_pass_var.get().strip() | |
| 848 | + | |
| 849 | + # 연결 테스트 시작 알림 | |
| 850 | + self.email_status_var.set(" 이메일 서버 연결 테스트 중...") | |
| 851 | + self.root.update() | |
| 852 | + | |
| 853 | + self.email_sender.set_credentials(username, password) | |
| 854 | + success, message = self.email_sender.test_connection() | |
| 855 | + | |
| 856 | + if success: | |
| 857 | + self.email_status_var.set("✅ 연결 성공! 이메일 발송 준비 완료") | |
| 858 | + messagebox.showinfo(" 연결 성공", f"✅ SMTP 서버에 성공적으로 연결되었습니다!\n\n 이제 급여명세서를 이메일로 발송할 수 있습니다.\n\n설정: {username}") | |
| 859 | + else: | |
| 860 | + self.email_status_var.set("❌ 연결 실패 - 설정을 확인해주세요") | |
| 861 | + messagebox.showerror("⚠️ 연결 실패", f"❌ SMTP 서버 연결에 실패했습니다.\n\n 오류 내용:\n{message}\n\n 문제가 지속되면 IT팀에 문의해주세요.") | |
| 862 | + | |
| 863 | + def start_email_sending(self): | |
| 864 | + """이메일 발송 시작""" | |
| 865 | + # 발송할 데이터 수집 | |
| 866 | + send_data = [] | |
| 867 | + for item in self.file_items: | |
| 868 | + data = item.get_send_data() | |
| 869 | + if data: | |
| 870 | + send_data.append(data) | |
| 871 | + | |
| 872 | + print(f"[DEBUG] 총 {len(send_data)}개의 이메일 발송 대상") | |
| 873 | + | |
| 874 | + if not send_data: | |
| 875 | + messagebox.showwarning("경고", "발송할 파일이 없습니다.\n매칭된 직원을 선택해주세요.") | |
| 876 | + return | |
| 877 | + | |
| 878 | + # 인증 정보 확인 | |
| 879 | + username = self.email_user_var.get().strip() | |
| 880 | + password = self.email_pass_var.get().strip() | |
| 881 | + | |
| 882 | + if not username or not password: | |
| 883 | + messagebox.showwarning("경고", "이메일 사용자명과 비밀번호를 입력해주세요.") | |
| 884 | + return | |
| 885 | + | |
| 886 | + # 발송 확인 | |
| 887 | + result = messagebox.askyesno("확인", f"{len(send_data)}명에게 이메일을 발송하시겠습니까?") | |
| 888 | + if not result: | |
| 889 | + return | |
| 890 | + | |
| 891 | + # UI 비활성화 | |
| 892 | + self.send_button.config(state='disabled') | |
| 893 | + | |
| 894 | + # 별도 스레드에서 발송 | |
| 895 | + self.email_sender.set_credentials(username, password) | |
| 896 | + thread = threading.Thread(target=self.send_emails, args=(send_data,)) | |
| 897 | + thread.daemon = True | |
| 898 | + thread.start() | |
| 899 | + | |
| 900 | + def send_emails(self, send_data): | |
| 901 | + """이메일 발송 (별도 스레드)""" | |
| 902 | + def progress_callback(current, total, name): | |
| 903 | + self.root.after(0, lambda: self.email_status_var.set(f"발송 중... {current}/{total} ({name})")) | |
| 904 | + | |
| 905 | + try: | |
| 906 | + results = self.email_sender.send_bulk_emails(send_data, progress_callback) | |
| 907 | + | |
| 908 | + success_count = len(results["success"]) | |
| 909 | + failed_count = len(results["failed"]) | |
| 910 | + | |
| 911 | + # 발송 성공한 항목들의 UI 업데이트 | |
| 912 | + for success_item in results["success"]: | |
| 913 | + success_name = success_item.get("name") | |
| 914 | + # 해당 직원의 파일 아이템 찾기 | |
| 915 | + for file_item in self.file_items: | |
| 916 | + if file_item.employee_name == success_name: | |
| 917 | + self.root.after(0, lambda item=file_item: item.mark_as_sent()) | |
| 918 | + break | |
| 919 | + | |
| 920 | + # 결과 메시지 | |
| 921 | + message = f"이메일 발송 완료!\n\n성공: {success_count}명\n실패: {failed_count}명" | |
| 922 | + | |
| 923 | + if results["failed"]: | |
| 924 | + message += "\n\n실패 목록:" | |
| 925 | + for failed in results["failed"][:3]: # 최대 3개만 표시 | |
| 926 | + message += f"\n- {failed['name']}: {failed['error'][:50]}..." | |
| 927 | + | |
| 928 | + if len(results["failed"]) > 3: | |
| 929 | + message += f"\n... 및 {len(results['failed']) - 3}건 더" | |
| 930 | + | |
| 931 | + self.root.after(0, lambda: messagebox.showinfo("발송 완료", message)) | |
| 932 | + self.root.after(0, lambda: self.email_status_var.set(f"발송 완료: 성공 {success_count}명, 실패 {failed_count}명")) | |
| 933 | + | |
| 934 | + except Exception as e: | |
| 935 | + error_msg = f"이메일 발송 중 오류: {str(e)}" | |
| 936 | + self.root.after(0, lambda: messagebox.showerror("발송 오류", error_msg)) | |
| 937 | + self.root.after(0, lambda: self.email_status_var.set("발송 실패")) | |
| 938 | + | |
| 939 | + finally: | |
| 940 | + self.root.after(0, lambda: self.send_button.config(state='normal')) | |
| 941 | + | |
| 942 | + def auto_send_emails(self): | |
| 943 | + """자동 이메일 발송""" | |
| 944 | + # 인증 정보가 있는지 확인 | |
| 945 | + username = self.email_user_var.get().strip() | |
| 946 | + password = self.email_pass_var.get().strip() | |
| 947 | + | |
| 948 | + if not username or not password: | |
| 949 | + messagebox.showwarning("자동 발송 실패", "이메일 인증 정보가 설정되지 않았습니다.") | |
| 950 | + return | |
| 951 | + | |
| 952 | + # 이메일 탭으로 전환 | |
| 953 | + self.notebook.select(1) | |
| 954 | + | |
| 955 | + # 잠시 후 발송 시작 | |
| 956 | + self.root.after(1000, self.start_email_sending) | |
| 957 | + | |
| 958 | +if __name__ == "__main__": | |
| 959 | + root = TkinterDnD.Tk() | |
| 960 | + app = PayslipSplitterGUI(root) | |
| 961 | + root.mainloop()(No newline at end of file) |
+++ requirements.txt
... | ... | @@ -0,0 +1,8 @@ |
| 1 | +pymupdf | |
| 2 | +pytesseract | |
| 3 | +Pillow | |
| 4 | +pyinstaller | |
| 5 | +tkinterdnd2 | |
| 6 | +pandas | |
| 7 | +openpyxl | |
| 8 | +pyperclip(No newline at end of file) |
+++ src/__pycache__/email_sender.cpython-313.pyc
| Binary file is not shown |
+++ src/email_sender.py
... | ... | @@ -0,0 +1,354 @@ |
| 1 | +import smtplib | |
| 2 | +import os | |
| 3 | +from email.mime.multipart import MIMEMultipart | |
| 4 | +from email.mime.text import MIMEText | |
| 5 | +from email.mime.base import MIMEBase | |
| 6 | +from email import encoders | |
| 7 | +from email.utils import formataddr | |
| 8 | +from typing import Dict, List, Tuple | |
| 9 | +import json | |
| 10 | +import fitz # PyMuPDF | |
| 11 | +import re | |
| 12 | +import urllib.parse | |
| 13 | + | |
| 14 | +def extract_payslip_date_from_pdf(pdf_path: str) -> str: | |
| 15 | + """PDF에서 급여명세서 날짜 정보 추출하여 다음달 표시 (예: "08월" → "09월")""" | |
| 16 | + | |
| 17 | + # 기본값 설정: 현재 날짜 기준 다음달 | |
| 18 | + from datetime import datetime | |
| 19 | + current_month = datetime.now().month | |
| 20 | + if current_month == 12: | |
| 21 | + next_month = 1 | |
| 22 | + else: | |
| 23 | + next_month = current_month + 1 | |
| 24 | + default_month = f"{next_month:02d}월" | |
| 25 | + | |
| 26 | + try: | |
| 27 | + doc = fitz.open(pdf_path) | |
| 28 | + if doc.page_count == 0: | |
| 29 | + doc.close() | |
| 30 | + return default_month | |
| 31 | + | |
| 32 | + # 첫 페이지에서 텍스트 추출 | |
| 33 | + page = doc[0] | |
| 34 | + text = page.get_text() | |
| 35 | + doc.close() | |
| 36 | + | |
| 37 | + # "YYYY년MM월분 급여명세서" 패턴 찾기 | |
| 38 | + patterns = [ | |
| 39 | + r'(\d{4})년(\d{1,2})월분\s*급여명세서', | |
| 40 | + r'(\d{4})년(\d{1,2})월\s*급여명세서', | |
| 41 | + r'급여명세서.*?(\d{4})년(\d{1,2})월', | |
| 42 | + ] | |
| 43 | + | |
| 44 | + for pattern in patterns: | |
| 45 | + match = re.search(pattern, text) | |
| 46 | + if match: | |
| 47 | + year, month = match.groups() | |
| 48 | + year_int = int(year) | |
| 49 | + month_int = int(month) | |
| 50 | + | |
| 51 | + # 다음달 계산 (12월이면 1월로, 연도도 +1) | |
| 52 | + if month_int == 12: | |
| 53 | + next_month = 1 | |
| 54 | + next_year = year_int + 1 | |
| 55 | + else: | |
| 56 | + next_month = month_int + 1 | |
| 57 | + next_year = year_int | |
| 58 | + | |
| 59 | + month_str = f"{next_month:02d}월" | |
| 60 | + return month_str | |
| 61 | + | |
| 62 | + # PDF에서 텍스트 추출 실패시 현재 날짜 기준 다음달 반환 | |
| 63 | + return default_month | |
| 64 | + | |
| 65 | + except Exception as e: | |
| 66 | + print(f"날짜 추출 실패 ({pdf_path}): {e}") | |
| 67 | + # 오류 발생시에도 현재 날짜 기준 다음달 반환 | |
| 68 | + return default_month | |
| 69 | + | |
| 70 | +class EmailConfig: | |
| 71 | + """이메일 설정 관리 클래스""" | |
| 72 | + | |
| 73 | + def __init__(self, config_file="email_config.json"): | |
| 74 | + self.config_file = config_file | |
| 75 | + self.config = self.load_config() | |
| 76 | + | |
| 77 | + def load_config(self) -> Dict: | |
| 78 | + """설정 파일 로드""" | |
| 79 | + default_config = { | |
| 80 | + "smtp_server": "smtp.worksmobile.com", | |
| 81 | + "smtp_port": 465, | |
| 82 | + "use_ssl": True, | |
| 83 | + "sender_email": "noreply@munjaon.co.kr", | |
| 84 | + "sender_name": "급여관리팀", | |
| 85 | + "email_template": { | |
| 86 | + "subject": "{month} 월급명세서 전달드립니다.", | |
| 87 | + "body": """안녕하세요. | |
| 88 | + | |
| 89 | +늘 맡은 자리에서 애써주심에 깊이 감사드립니다. | |
| 90 | +다음 달 급여명세서를 PDF 파일로 첨부하여 보내드립니다. | |
| 91 | + | |
| 92 | +※ 첨부 파일의 비밀번호는 본인 생년월일 8자리입니다. | |
| 93 | + 예) 1990년 1월 15일 → 19900115 | |
| 94 | + | |
| 95 | +확인 중 궁금한 점이나 문의사항이 있으시면 언제든 편하게 연락 주시기 바랍니다. | |
| 96 | + | |
| 97 | +감사합니다. | |
| 98 | + | |
| 99 | +급여관리팀 드림""" | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + if os.path.exists(self.config_file): | |
| 104 | + try: | |
| 105 | + with open(self.config_file, 'r', encoding='utf-8') as f: | |
| 106 | + loaded_config = json.load(f) | |
| 107 | + # 기본 설정과 병합 | |
| 108 | + default_config.update(loaded_config) | |
| 109 | + except Exception as e: | |
| 110 | + print(f"설정 파일 로드 실패: {e}") | |
| 111 | + | |
| 112 | + self.save_config(default_config) | |
| 113 | + return default_config | |
| 114 | + | |
| 115 | + def save_config(self, config: Dict = None): | |
| 116 | + """설정 파일 저장""" | |
| 117 | + if config is None: | |
| 118 | + config = self.config | |
| 119 | + | |
| 120 | + try: | |
| 121 | + with open(self.config_file, 'w', encoding='utf-8') as f: | |
| 122 | + json.dump(config, f, ensure_ascii=False, indent=2) | |
| 123 | + except Exception as e: | |
| 124 | + print(f"설정 파일 저장 실패: {e}") | |
| 125 | + | |
| 126 | + def get_smtp_config(self) -> Dict: | |
| 127 | + """SMTP 설정 반환""" | |
| 128 | + return { | |
| 129 | + "server": self.config.get("smtp_server", "smtp.worksmobile.com"), | |
| 130 | + "port": self.config.get("smtp_port", 465), | |
| 131 | + "use_ssl": self.config.get("use_ssl", True) | |
| 132 | + } | |
| 133 | + | |
| 134 | + def get_sender_info(self) -> Dict: | |
| 135 | + """발송자 정보 반환""" | |
| 136 | + return { | |
| 137 | + "email": self.config.get("sender_email", "noreply@munjaon.co.kr"), | |
| 138 | + "name": self.config.get("sender_name", "급여관리팀") | |
| 139 | + } | |
| 140 | + | |
| 141 | + def get_email_template(self) -> Dict: | |
| 142 | + """이메일 템플릿 반환""" | |
| 143 | + return self.config.get("email_template", {}) | |
| 144 | + | |
| 145 | +class PayslipEmailSender: | |
| 146 | + """급여명세서 이메일 발송 클래스""" | |
| 147 | + | |
| 148 | + def __init__(self, username: str = None, password: str = None): | |
| 149 | + self.config = EmailConfig() | |
| 150 | + self.username = username | |
| 151 | + self.password = password | |
| 152 | + self.smtp_config = self.config.get_smtp_config() | |
| 153 | + self.sender_info = self.config.get_sender_info() | |
| 154 | + self.template = self.config.get_email_template() | |
| 155 | + | |
| 156 | + def set_credentials(self, username: str, password: str): | |
| 157 | + """인증 정보 설정""" | |
| 158 | + self.username = username | |
| 159 | + self.password = password | |
| 160 | + | |
| 161 | + def create_email_message(self, recipient_email: str, recipient_name: str, | |
| 162 | + pdf_path: str, custom_subject: str = None, | |
| 163 | + custom_body: str = None) -> MIMEMultipart: | |
| 164 | + """이메일 메시지 생성""" | |
| 165 | + msg = MIMEMultipart() | |
| 166 | + | |
| 167 | + # 발송자 정보 | |
| 168 | + sender_email = self.sender_info["email"] | |
| 169 | + sender_name = self.sender_info["name"] | |
| 170 | + msg['From'] = formataddr((sender_name, sender_email)) | |
| 171 | + msg['To'] = recipient_email | |
| 172 | + | |
| 173 | + # 제목 - PDF에서 날짜 추출하여 동적 생성 | |
| 174 | + if custom_subject: | |
| 175 | + subject = custom_subject | |
| 176 | + else: | |
| 177 | + month = extract_payslip_date_from_pdf(pdf_path) if pdf_path else "현재달" | |
| 178 | + subject_template = self.template.get("subject", "{month} 월급명세서 전달드립니다.") | |
| 179 | + subject = subject_template.format(month=month) | |
| 180 | + | |
| 181 | + msg['Subject'] = subject | |
| 182 | + | |
| 183 | + # 본문 | |
| 184 | + body_template = custom_body or self.template.get("body", "안녕하세요, {name}님.\n\n월급명세서를 첨부하여 전달드립니다.") | |
| 185 | + body = body_template.format(name=recipient_name) | |
| 186 | + | |
| 187 | + msg.attach(MIMEText(body, 'plain', 'utf-8')) | |
| 188 | + | |
| 189 | + # 첨부파일 | |
| 190 | + if pdf_path and os.path.exists(pdf_path): | |
| 191 | + try: | |
| 192 | + with open(pdf_path, "rb") as attachment: | |
| 193 | + # PDF 전용 MIME 타입 사용 | |
| 194 | + part = MIMEBase('application', 'pdf') | |
| 195 | + part.set_payload(attachment.read()) | |
| 196 | + | |
| 197 | + encoders.encode_base64(part) | |
| 198 | + | |
| 199 | + filename = os.path.basename(pdf_path) | |
| 200 | + # 한국어 파일명 인코딩 처리 | |
| 201 | + try: | |
| 202 | + filename_encoded = filename.encode('ascii') | |
| 203 | + filename_header = f'attachment; filename="{filename}"' | |
| 204 | + except UnicodeEncodeError: | |
| 205 | + # 한국어 파일명의 경우 UTF-8로 인코딩 | |
| 206 | + import urllib.parse | |
| 207 | + filename_encoded = urllib.parse.quote(filename) | |
| 208 | + filename_header = f"attachment; filename*=UTF-8''{filename_encoded}" | |
| 209 | + | |
| 210 | + part.add_header('Content-Disposition', filename_header) | |
| 211 | + part.add_header('Content-Transfer-Encoding', 'base64') | |
| 212 | + | |
| 213 | + msg.attach(part) | |
| 214 | + print(f"PDF 첨부 성공: {filename} ({os.path.getsize(pdf_path)} bytes)") # 디버그 로그 | |
| 215 | + except Exception as e: | |
| 216 | + print(f"PDF 첨부 실패 ({pdf_path}): {e}") # 오류 로그 | |
| 217 | + else: | |
| 218 | + print(f"PDF 파일 없음 또는 경로 문제: {pdf_path}") | |
| 219 | + | |
| 220 | + return msg | |
| 221 | + | |
| 222 | + def send_single_email(self, recipient_email: str, recipient_name: str, | |
| 223 | + pdf_path: str, custom_subject: str = None, | |
| 224 | + custom_body: str = None) -> Tuple[bool, str]: | |
| 225 | + """단일 이메일 발송""" | |
| 226 | + if not self.username or not self.password: | |
| 227 | + return False, "인증 정보가 설정되지 않았습니다." | |
| 228 | + | |
| 229 | + if not os.path.exists(pdf_path): | |
| 230 | + return False, f"첨부파일을 찾을 수 없습니다: {pdf_path}" | |
| 231 | + | |
| 232 | + try: | |
| 233 | + # 이메일 메시지 생성 | |
| 234 | + msg = self.create_email_message(recipient_email, recipient_name, | |
| 235 | + pdf_path, custom_subject, custom_body) | |
| 236 | + | |
| 237 | + # SMTP 서버 연결 및 발송 | |
| 238 | + if self.smtp_config["use_ssl"]: | |
| 239 | + server = smtplib.SMTP_SSL(self.smtp_config["server"], self.smtp_config["port"]) | |
| 240 | + else: | |
| 241 | + server = smtplib.SMTP(self.smtp_config["server"], self.smtp_config["port"]) | |
| 242 | + server.starttls() | |
| 243 | + | |
| 244 | + server.login(self.username, self.password) | |
| 245 | + | |
| 246 | + text = msg.as_string() | |
| 247 | + server.sendmail(self.sender_info["email"], recipient_email, text) | |
| 248 | + server.quit() | |
| 249 | + | |
| 250 | + return True, f"'{recipient_name}'님에게 이메일이 성공적으로 발송되었습니다." | |
| 251 | + | |
| 252 | + except Exception as e: | |
| 253 | + return False, f"이메일 발송 실패: {str(e)}" | |
| 254 | + | |
| 255 | + def send_bulk_emails(self, email_data: List[Dict], progress_callback=None) -> Dict: | |
| 256 | + """대량 이메일 발송 | |
| 257 | + | |
| 258 | + Args: | |
| 259 | + email_data: [{"name": "이름", "email": "이메일", "pdf_path": "파일경로"}] | |
| 260 | + progress_callback: 진행상황 콜백 함수 | |
| 261 | + | |
| 262 | + Returns: | |
| 263 | + {"success": [], "failed": []} | |
| 264 | + """ | |
| 265 | + if not self.username or not self.password: | |
| 266 | + return { | |
| 267 | + "success": [], | |
| 268 | + "failed": [{"name": "모든 직원", "error": "인증 정보가 설정되지 않았습니다."}] | |
| 269 | + } | |
| 270 | + | |
| 271 | + results = {"success": [], "failed": []} | |
| 272 | + total_count = len(email_data) | |
| 273 | + | |
| 274 | + try: | |
| 275 | + # SMTP 서버 연결 (연결 유지) | |
| 276 | + if self.smtp_config["use_ssl"]: | |
| 277 | + server = smtplib.SMTP_SSL(self.smtp_config["server"], self.smtp_config["port"]) | |
| 278 | + else: | |
| 279 | + server = smtplib.SMTP(self.smtp_config["server"], self.smtp_config["port"]) | |
| 280 | + server.starttls() | |
| 281 | + | |
| 282 | + server.login(self.username, self.password) | |
| 283 | + | |
| 284 | + for i, data in enumerate(email_data): | |
| 285 | + name = data.get("name", "") | |
| 286 | + email = data.get("email", "") | |
| 287 | + pdf_path = data.get("pdf_path", "") | |
| 288 | + | |
| 289 | + try: | |
| 290 | + if not os.path.exists(pdf_path): | |
| 291 | + results["failed"].append({ | |
| 292 | + "name": name, | |
| 293 | + "email": email, | |
| 294 | + "error": f"첨부파일을 찾을 수 없음: {pdf_path}" | |
| 295 | + }) | |
| 296 | + continue | |
| 297 | + | |
| 298 | + # 이메일 메시지 생성 | |
| 299 | + msg = self.create_email_message(email, name, pdf_path) | |
| 300 | + | |
| 301 | + # 발송 | |
| 302 | + text = msg.as_string() | |
| 303 | + server.sendmail(self.sender_info["email"], email, text) | |
| 304 | + | |
| 305 | + results["success"].append({ | |
| 306 | + "name": name, | |
| 307 | + "email": email, | |
| 308 | + "pdf_path": pdf_path | |
| 309 | + }) | |
| 310 | + | |
| 311 | + except Exception as e: | |
| 312 | + results["failed"].append({ | |
| 313 | + "name": name, | |
| 314 | + "email": email, | |
| 315 | + "error": str(e) | |
| 316 | + }) | |
| 317 | + | |
| 318 | + # 진행상황 업데이트 | |
| 319 | + if progress_callback: | |
| 320 | + progress_callback(i + 1, total_count, name) | |
| 321 | + | |
| 322 | + server.quit() | |
| 323 | + | |
| 324 | + except Exception as e: | |
| 325 | + # 서버 연결 실패 | |
| 326 | + for data in email_data: | |
| 327 | + if data.get("name") not in [item.get("name") for item in results["success"]]: | |
| 328 | + results["failed"].append({ | |
| 329 | + "name": data.get("name", ""), | |
| 330 | + "email": data.get("email", ""), | |
| 331 | + "error": f"SMTP 서버 연결 실패: {str(e)}" | |
| 332 | + }) | |
| 333 | + | |
| 334 | + return results | |
| 335 | + | |
| 336 | + def test_connection(self) -> Tuple[bool, str]: | |
| 337 | + """SMTP 서버 연결 테스트""" | |
| 338 | + if not self.username or not self.password: | |
| 339 | + return False, "인증 정보가 설정되지 않았습니다." | |
| 340 | + | |
| 341 | + try: | |
| 342 | + if self.smtp_config["use_ssl"]: | |
| 343 | + server = smtplib.SMTP_SSL(self.smtp_config["server"], self.smtp_config["port"]) | |
| 344 | + else: | |
| 345 | + server = smtplib.SMTP(self.smtp_config["server"], self.smtp_config["port"]) | |
| 346 | + server.starttls() | |
| 347 | + | |
| 348 | + server.login(self.username, self.password) | |
| 349 | + server.quit() | |
| 350 | + | |
| 351 | + return True, "SMTP 서버 연결 성공!" | |
| 352 | + | |
| 353 | + except Exception as e: | |
| 354 | + return False, f"SMTP 서버 연결 실패: {str(e)}"(No newline at end of file) |
+++ src/employee_manager.py
... | ... | @@ -0,0 +1,199 @@ |
| 1 | +import json | |
| 2 | +import os | |
| 3 | +import csv | |
| 4 | +from typing import Dict, List | |
| 5 | +from datetime import datetime | |
| 6 | + | |
| 7 | +class EmployeeManager: | |
| 8 | + """직원 정보 관리 클래스""" | |
| 9 | + | |
| 10 | + def __init__(self, data_file="employees.json"): | |
| 11 | + self.data_file = data_file | |
| 12 | + self.employees = self.load_employees() | |
| 13 | + | |
| 14 | + def load_employees(self) -> Dict[str, str]: | |
| 15 | + """직원 데이터 로드 (JSON 파일에서)""" | |
| 16 | + if os.path.exists(self.data_file): | |
| 17 | + try: | |
| 18 | + with open(self.data_file, 'r', encoding='utf-8') as f: | |
| 19 | + return json.load(f) | |
| 20 | + except Exception as e: | |
| 21 | + print(f"직원 데이터 로드 실패: {e}") | |
| 22 | + return {} | |
| 23 | + | |
| 24 | + # 기본 직원 데이터 | |
| 25 | + default_employees = { | |
| 26 | + "김혜리": "khr2205@iten.co.kr", | |
| 27 | + "이준호": "tolag3@iten.co.kr", | |
| 28 | + "이호영": "hylee@iten.co.kr", | |
| 29 | + "유인식": "smartyu@iten.co.kr", | |
| 30 | + "원영현": "dudgusw@iten.co.kr", | |
| 31 | + "유찬희": "ych@iten.co.kr", | |
| 32 | + "조현희": "hc3874@iten.co.kr", | |
| 33 | + "강영묵": "ymkang@iten.co.kr", | |
| 34 | + "조용준": "antelope@iten.co.kr", | |
| 35 | + "우영두": "rosehips@iten.co.kr", | |
| 36 | + "김상훈": "aricowiz@iten.co.kr", | |
| 37 | + "장영익": "yeongik@iten.co.kr", | |
| 38 | + "정다은": "jungde@iten.co.kr", | |
| 39 | + "이지우": "dlwldn1024@iten.co.kr", | |
| 40 | + "박진순": "jsp@iten.co.kr", | |
| 41 | + "정수빈": "dhgksk99@iten.co.kr", | |
| 42 | + "강민경": "kmk0522@iten.co.kr" | |
| 43 | + } | |
| 44 | + | |
| 45 | + # 기본 데이터 저장 | |
| 46 | + self.save_employees_data(default_employees) | |
| 47 | + return default_employees | |
| 48 | + | |
| 49 | + def save_employees(self): | |
| 50 | + """직원 데이터 저장""" | |
| 51 | + self.save_employees_data(self.employees) | |
| 52 | + | |
| 53 | + def save_employees_data(self, data: Dict[str, str]): | |
| 54 | + """직원 데이터를 파일에 저장""" | |
| 55 | + try: | |
| 56 | + with open(self.data_file, 'w', encoding='utf-8') as f: | |
| 57 | + json.dump(data, f, ensure_ascii=False, indent=2) | |
| 58 | + | |
| 59 | + # 백업 CSV 파일도 생성 | |
| 60 | + self.backup_to_csv(data) | |
| 61 | + except Exception as e: | |
| 62 | + print(f"직원 데이터 저장 실패: {e}") | |
| 63 | + | |
| 64 | + def backup_to_csv(self, data: Dict[str, str]): | |
| 65 | + """CSV 백업 파일 생성""" | |
| 66 | + try: | |
| 67 | + backup_file = "employees_backup.csv" | |
| 68 | + with open(backup_file, 'w', newline='', encoding='utf-8') as f: | |
| 69 | + writer = csv.writer(f) | |
| 70 | + writer.writerow(['이름', '이메일', '백업일시']) | |
| 71 | + current_time = datetime.now().strftime('%Y-%m-%d %H:%M') | |
| 72 | + for name, email in data.items(): | |
| 73 | + writer.writerow([name, email, current_time]) | |
| 74 | + except Exception as e: | |
| 75 | + print(f"CSV 백업 실패: {e}") | |
| 76 | + | |
| 77 | + def add_employee(self, name: str, email: str) -> bool: | |
| 78 | + """직원 추가""" | |
| 79 | + if name.strip() in self.employees: | |
| 80 | + return False # 이미 존재 | |
| 81 | + | |
| 82 | + self.employees[name.strip()] = email.strip() | |
| 83 | + self.save_employees() | |
| 84 | + return True | |
| 85 | + | |
| 86 | + def update_employee(self, old_name: str, new_name: str, email: str) -> bool: | |
| 87 | + """직원 정보 수정""" | |
| 88 | + if old_name not in self.employees: | |
| 89 | + return False | |
| 90 | + | |
| 91 | + old_name = old_name.strip() | |
| 92 | + new_name = new_name.strip() | |
| 93 | + email = email.strip() | |
| 94 | + | |
| 95 | + # 이름이 변경되는 경우 | |
| 96 | + if old_name != new_name: | |
| 97 | + if new_name in self.employees: | |
| 98 | + return False # 새 이름이 이미 존재 | |
| 99 | + del self.employees[old_name] | |
| 100 | + | |
| 101 | + self.employees[new_name] = email | |
| 102 | + self.save_employees() | |
| 103 | + return True | |
| 104 | + | |
| 105 | + def delete_employee(self, name: str) -> bool: | |
| 106 | + """직원 삭제""" | |
| 107 | + if name in self.employees: | |
| 108 | + del self.employees[name] | |
| 109 | + self.save_employees() | |
| 110 | + return True | |
| 111 | + return False | |
| 112 | + | |
| 113 | + def get_employee_email(self, name: str) -> str: | |
| 114 | + """직원 이메일 조회""" | |
| 115 | + return self.employees.get(name.strip(), "") | |
| 116 | + | |
| 117 | + def get_all_employees(self) -> Dict[str, str]: | |
| 118 | + """전체 직원 목록 반환""" | |
| 119 | + return self.employees.copy() | |
| 120 | + | |
| 121 | + def search_employees(self, query: str) -> Dict[str, str]: | |
| 122 | + """직원 검색""" | |
| 123 | + query = query.lower().strip() | |
| 124 | + if not query: | |
| 125 | + return self.get_all_employees() | |
| 126 | + | |
| 127 | + result = {} | |
| 128 | + for name, email in self.employees.items(): | |
| 129 | + if query in name.lower() or query in email.lower(): | |
| 130 | + result[name] = email | |
| 131 | + return result | |
| 132 | + | |
| 133 | + def import_from_csv(self, file_path: str) -> tuple: | |
| 134 | + """CSV 파일에서 직원 정보 가져오기""" | |
| 135 | + success_count = 0 | |
| 136 | + error_list = [] | |
| 137 | + | |
| 138 | + try: | |
| 139 | + encodings = ['utf-8', 'cp949', 'euc-kr'] | |
| 140 | + for encoding in encodings: | |
| 141 | + try: | |
| 142 | + with open(file_path, 'r', encoding=encoding) as f: | |
| 143 | + reader = csv.reader(f) | |
| 144 | + header = next(reader, None) # 헤더 스킵 | |
| 145 | + | |
| 146 | + for row_num, row in enumerate(reader, 2): | |
| 147 | + if len(row) >= 2: | |
| 148 | + name = row[0].strip() | |
| 149 | + email = row[1].strip() | |
| 150 | + | |
| 151 | + if name and email and '@' in email: | |
| 152 | + if self.add_employee_silent(name, email): | |
| 153 | + success_count += 1 | |
| 154 | + else: | |
| 155 | + error_list.append(f"행 {row_num}: '{name}' 이미 존재") | |
| 156 | + else: | |
| 157 | + error_list.append(f"행 {row_num}: 유효하지 않은 데이터") | |
| 158 | + break | |
| 159 | + except UnicodeDecodeError: | |
| 160 | + continue | |
| 161 | + | |
| 162 | + except Exception as e: | |
| 163 | + error_list.append(f"파일 읽기 오류: {str(e)}") | |
| 164 | + | |
| 165 | + if success_count > 0: | |
| 166 | + self.save_employees() | |
| 167 | + | |
| 168 | + return success_count, error_list | |
| 169 | + | |
| 170 | + def add_employee_silent(self, name: str, email: str) -> bool: | |
| 171 | + """직원 추가 (저장하지 않음, 배치 처리용)""" | |
| 172 | + if name.strip() in self.employees: | |
| 173 | + return False | |
| 174 | + | |
| 175 | + self.employees[name.strip()] = email.strip() | |
| 176 | + return True | |
| 177 | + | |
| 178 | + def export_to_csv(self, file_path: str) -> bool: | |
| 179 | + """CSV 파일로 내보내기""" | |
| 180 | + try: | |
| 181 | + with open(file_path, 'w', newline='', encoding='utf-8') as f: | |
| 182 | + writer = csv.writer(f) | |
| 183 | + writer.writerow(['이름', '이메일']) # 헤더 | |
| 184 | + | |
| 185 | + for name, email in sorted(self.employees.items()): | |
| 186 | + writer.writerow([name, email]) | |
| 187 | + return True | |
| 188 | + except Exception as e: | |
| 189 | + print(f"CSV 내보내기 실패: {e}") | |
| 190 | + return False | |
| 191 | + | |
| 192 | + def get_employee_count(self) -> int: | |
| 193 | + """직원 수 반환""" | |
| 194 | + return len(self.employees) | |
| 195 | + | |
| 196 | + def clear_all_employees(self): | |
| 197 | + """모든 직원 정보 삭제""" | |
| 198 | + self.employees.clear() | |
| 199 | + self.save_employees()(No newline at end of file) |
+++ src/pdf_preview.py
... | ... | @@ -0,0 +1,331 @@ |
| 1 | +import fitz # PyMuPDF | |
| 2 | +from PIL import Image, ImageTk | |
| 3 | +import io | |
| 4 | +import os | |
| 5 | +import tkinter as tk | |
| 6 | +from tkinter import ttk, messagebox | |
| 7 | +import platform | |
| 8 | +import subprocess | |
| 9 | + | |
| 10 | +class PDFPreviewManager: | |
| 11 | + """PDF 미리보기 관리 클래스""" | |
| 12 | + | |
| 13 | + def __init__(self): | |
| 14 | + self.preview_cache = {} # 미리보기 이미지 캐싱 | |
| 15 | + | |
| 16 | + def get_pdf_info(self, pdf_path: str) -> dict: | |
| 17 | + """PDF 기본 정보 추출""" | |
| 18 | + try: | |
| 19 | + if not pdf_path or not os.path.exists(pdf_path): | |
| 20 | + return { | |
| 21 | + 'error': 'PDF 파일을 찾을 수 없습니다.', | |
| 22 | + 'page_count': 0, | |
| 23 | + 'file_size': 0, | |
| 24 | + 'is_encrypted': False, | |
| 25 | + 'title': '', | |
| 26 | + 'author': '', | |
| 27 | + 'creation_date': '', | |
| 28 | + 'file_name': os.path.basename(pdf_path) if pdf_path else '', | |
| 29 | + 'file_path': pdf_path or '' | |
| 30 | + } | |
| 31 | + | |
| 32 | + doc = fitz.open(pdf_path) | |
| 33 | + metadata = doc.metadata or {} | |
| 34 | + info = { | |
| 35 | + 'page_count': doc.page_count, | |
| 36 | + 'file_size': os.path.getsize(pdf_path), | |
| 37 | + 'is_encrypted': doc.is_encrypted, | |
| 38 | + 'title': metadata.get('title', '') or '', | |
| 39 | + 'author': metadata.get('author', '') or '', | |
| 40 | + 'creation_date': metadata.get('creationDate', '') or '', | |
| 41 | + 'file_name': os.path.basename(pdf_path), | |
| 42 | + 'file_path': pdf_path | |
| 43 | + } | |
| 44 | + doc.close() | |
| 45 | + return info | |
| 46 | + except Exception as e: | |
| 47 | + return { | |
| 48 | + 'error': str(e), | |
| 49 | + 'page_count': 0, | |
| 50 | + 'file_size': 0, | |
| 51 | + 'is_encrypted': False, | |
| 52 | + 'title': '', | |
| 53 | + 'author': '', | |
| 54 | + 'creation_date': '', | |
| 55 | + 'file_name': os.path.basename(pdf_path) if pdf_path else '', | |
| 56 | + 'file_path': pdf_path or '' | |
| 57 | + } | |
| 58 | + | |
| 59 | + def create_preview_thumbnail(self, pdf_path: str, target_size=(250, 350)) -> Image.Image: | |
| 60 | + """PDF 첫 페이지 썸네일 생성""" | |
| 61 | + cache_key = f"{pdf_path}_{target_size}" | |
| 62 | + | |
| 63 | + # 캐시에 있으면 반환 | |
| 64 | + if cache_key in self.preview_cache: | |
| 65 | + return self.preview_cache[cache_key] | |
| 66 | + | |
| 67 | + try: | |
| 68 | + doc = fitz.open(pdf_path) | |
| 69 | + if doc.page_count == 0: | |
| 70 | + doc.close() | |
| 71 | + return None | |
| 72 | + | |
| 73 | + page = doc[0] # 첫 페이지 | |
| 74 | + | |
| 75 | + # 미리보기 크기에 맞게 해상도 계산 | |
| 76 | + zoom_x = target_size[0] / page.rect.width | |
| 77 | + zoom_y = target_size[1] / page.rect.height | |
| 78 | + zoom = min(zoom_x, zoom_y) * 1.5 # 약간 높은 해상도 | |
| 79 | + | |
| 80 | + mat = fitz.Matrix(zoom, zoom) | |
| 81 | + pix = page.get_pixmap(matrix=mat) | |
| 82 | + img_data = pix.tobytes("png") | |
| 83 | + | |
| 84 | + # PIL Image로 변환 | |
| 85 | + image = Image.open(io.BytesIO(img_data)) | |
| 86 | + image.thumbnail(target_size, Image.Resampling.LANCZOS) | |
| 87 | + | |
| 88 | + doc.close() | |
| 89 | + | |
| 90 | + # 캐시에 저장 | |
| 91 | + self.preview_cache[cache_key] = image | |
| 92 | + return image | |
| 93 | + | |
| 94 | + except Exception as e: | |
| 95 | + print(f"미리보기 생성 실패 ({pdf_path}): {e}") | |
| 96 | + return None | |
| 97 | + | |
| 98 | + def format_file_size(self, size_bytes: int) -> str: | |
| 99 | + """파일 크기를 읽기 쉬운 형태로 변환""" | |
| 100 | + if size_bytes < 1024: | |
| 101 | + return f"{size_bytes}B" | |
| 102 | + elif size_bytes < 1024**2: | |
| 103 | + return f"{size_bytes/1024:.1f}KB" | |
| 104 | + else: | |
| 105 | + return f"{size_bytes/(1024**2):.1f}MB" | |
| 106 | + | |
| 107 | + def clear_cache(self): | |
| 108 | + """캐시 클리어""" | |
| 109 | + self.preview_cache.clear() | |
| 110 | + | |
| 111 | + def open_file_with_system(self, pdf_path: str) -> bool: | |
| 112 | + """시스템 기본 프로그램으로 파일 열기""" | |
| 113 | + try: | |
| 114 | + if platform.system() == 'Windows': | |
| 115 | + os.startfile(pdf_path) | |
| 116 | + elif platform.system() == 'Darwin': # macOS | |
| 117 | + subprocess.run(['open', pdf_path]) | |
| 118 | + else: # Linux | |
| 119 | + subprocess.run(['xdg-open', pdf_path]) | |
| 120 | + return True | |
| 121 | + except Exception as e: | |
| 122 | + print(f"파일 열기 실패 ({pdf_path}): {e}") | |
| 123 | + return False | |
| 124 | + | |
| 125 | +class PDFPreviewDialog: | |
| 126 | + """PDF 미리보기 다이얼로그""" | |
| 127 | + | |
| 128 | + def __init__(self, parent, pdf_path: str, employee_name: str, preview_manager: PDFPreviewManager): | |
| 129 | + self.parent = parent | |
| 130 | + self.pdf_path = pdf_path | |
| 131 | + self.employee_name = employee_name | |
| 132 | + self.preview_manager = preview_manager | |
| 133 | + | |
| 134 | + # PDF 정보 가져오기 | |
| 135 | + self.pdf_info = preview_manager.get_pdf_info(pdf_path) | |
| 136 | + | |
| 137 | + # 다이얼로그 창 생성 | |
| 138 | + self.window = tk.Toplevel(parent) | |
| 139 | + self.window.title(f"미리보기 - {employee_name}") | |
| 140 | + self.window.geometry("450x650") | |
| 141 | + self.window.resizable(True, True) | |
| 142 | + | |
| 143 | + # 창을 모달로 설정 | |
| 144 | + self.window.transient(parent) | |
| 145 | + self.window.grab_set() | |
| 146 | + | |
| 147 | + # 창을 화면 중앙에 위치 | |
| 148 | + self.center_window() | |
| 149 | + | |
| 150 | + self.setup_ui() | |
| 151 | + | |
| 152 | + def center_window(self): | |
| 153 | + """창을 화면 중앙에 위치시키기""" | |
| 154 | + self.window.update_idletasks() | |
| 155 | + width = self.window.winfo_width() | |
| 156 | + height = self.window.winfo_height() | |
| 157 | + pos_x = (self.window.winfo_screenwidth() // 2) - (width // 2) | |
| 158 | + pos_y = (self.window.winfo_screenheight() // 2) - (height // 2) | |
| 159 | + self.window.geometry(f'{width}x{height}+{pos_x}+{pos_y}') | |
| 160 | + | |
| 161 | + def setup_ui(self): | |
| 162 | + """UI 구성""" | |
| 163 | + main_frame = ttk.Frame(self.window, padding=10) | |
| 164 | + main_frame.pack(fill='both', expand=True) | |
| 165 | + | |
| 166 | + # 상단: 파일 정보 | |
| 167 | + info_frame = ttk.LabelFrame(main_frame, text=" 파일 정보", padding=10) | |
| 168 | + info_frame.pack(fill='x', pady=(0, 10)) | |
| 169 | + | |
| 170 | + # 정보 표시 | |
| 171 | + info_grid = ttk.Frame(info_frame) | |
| 172 | + info_grid.pack(fill='x') | |
| 173 | + | |
| 174 | + # 파일명 | |
| 175 | + ttk.Label(info_grid, text="파일명:", font=('맑은 고딕', 9, 'bold')).grid( | |
| 176 | + row=0, column=0, sticky='w', padx=(0, 10) | |
| 177 | + ) | |
| 178 | + ttk.Label(info_grid, text=self.pdf_info.get('file_name', '알 수 없음')).grid( | |
| 179 | + row=0, column=1, sticky='w' | |
| 180 | + ) | |
| 181 | + | |
| 182 | + # 직원명 | |
| 183 | + ttk.Label(info_grid, text="직원명:", font=('맑은 고딕', 9, 'bold')).grid( | |
| 184 | + row=1, column=0, sticky='w', padx=(0, 10), pady=2 | |
| 185 | + ) | |
| 186 | + ttk.Label(info_grid, text=self.employee_name).grid( | |
| 187 | + row=1, column=1, sticky='w', pady=2 | |
| 188 | + ) | |
| 189 | + | |
| 190 | + # 페이지 수 | |
| 191 | + page_count = self.pdf_info.get('page_count', 0) | |
| 192 | + ttk.Label(info_grid, text="페이지 수:", font=('맑은 고딕', 9, 'bold')).grid( | |
| 193 | + row=2, column=0, sticky='w', padx=(0, 10), pady=2 | |
| 194 | + ) | |
| 195 | + ttk.Label(info_grid, text=f"{page_count}페이지").grid( | |
| 196 | + row=2, column=1, sticky='w', pady=2 | |
| 197 | + ) | |
| 198 | + | |
| 199 | + # 파일 크기 | |
| 200 | + file_size = self.preview_manager.format_file_size(self.pdf_info.get('file_size', 0)) | |
| 201 | + ttk.Label(info_grid, text="파일 크기:", font=('맑은 고딕', 9, 'bold')).grid( | |
| 202 | + row=3, column=0, sticky='w', padx=(0, 10), pady=2 | |
| 203 | + ) | |
| 204 | + ttk.Label(info_grid, text=file_size).grid( | |
| 205 | + row=3, column=1, sticky='w', pady=2 | |
| 206 | + ) | |
| 207 | + | |
| 208 | + # 암호화 상태 | |
| 209 | + if self.pdf_info.get('is_encrypted'): | |
| 210 | + ttk.Label(info_grid, text="보안:", font=('맑은 고딕', 9, 'bold')).grid( | |
| 211 | + row=4, column=0, sticky='w', padx=(0, 10), pady=2 | |
| 212 | + ) | |
| 213 | + security_label = ttk.Label(info_grid, text=" 비밀번호 보호됨", foreground='orange') | |
| 214 | + security_label.grid(row=4, column=1, sticky='w', pady=2) | |
| 215 | + | |
| 216 | + # 중앙: 미리보기 이미지 | |
| 217 | + preview_frame = ttk.LabelFrame(main_frame, text=" 첫 페이지 미리보기", padding=10) | |
| 218 | + preview_frame.pack(fill='both', expand=True, pady=(0, 10)) | |
| 219 | + | |
| 220 | + # 스크롤 가능한 캔버스 생성 | |
| 221 | + canvas_frame = ttk.Frame(preview_frame) | |
| 222 | + canvas_frame.pack(fill='both', expand=True) | |
| 223 | + | |
| 224 | + self.canvas = tk.Canvas(canvas_frame, bg='white', relief='sunken', bd=2) | |
| 225 | + scrollbar_v = ttk.Scrollbar(canvas_frame, orient='vertical', command=self.canvas.yview) | |
| 226 | + scrollbar_h = ttk.Scrollbar(canvas_frame, orient='horizontal', command=self.canvas.xview) | |
| 227 | + | |
| 228 | + self.canvas.configure(yscrollcommand=scrollbar_v.set, xscrollcommand=scrollbar_h.set) | |
| 229 | + | |
| 230 | + # 미리보기 이미지 로드 및 표시 | |
| 231 | + self.load_preview_image() | |
| 232 | + | |
| 233 | + # 그리드 배치 | |
| 234 | + self.canvas.grid(row=0, column=0, sticky='nsew') | |
| 235 | + scrollbar_v.grid(row=0, column=1, sticky='ns') | |
| 236 | + scrollbar_h.grid(row=1, column=0, sticky='ew') | |
| 237 | + | |
| 238 | + canvas_frame.grid_rowconfigure(0, weight=1) | |
| 239 | + canvas_frame.grid_columnconfigure(0, weight=1) | |
| 240 | + | |
| 241 | + # 하단: 버튼들 | |
| 242 | + button_frame = ttk.Frame(main_frame) | |
| 243 | + button_frame.pack(fill='x', pady=(10, 0)) | |
| 244 | + | |
| 245 | + # 버튼 배치 | |
| 246 | + ttk.Button(button_frame, text=" 시스템으로 열기", | |
| 247 | + command=self.open_with_system).pack(side='left', padx=(0, 5)) | |
| 248 | + ttk.Button(button_frame, text=" 폴더 열기", | |
| 249 | + command=self.open_folder).pack(side='left', padx=5) | |
| 250 | + ttk.Button(button_frame, text=" 새로고침", | |
| 251 | + command=self.refresh_preview).pack(side='left', padx=5) | |
| 252 | + | |
| 253 | + # 오른쪽에 닫기 버튼 | |
| 254 | + ttk.Button(button_frame, text="닫기", | |
| 255 | + command=self.close_dialog).pack(side='right') | |
| 256 | + | |
| 257 | + def load_preview_image(self): | |
| 258 | + """미리보기 이미지 로드""" | |
| 259 | + try: | |
| 260 | + if 'error' in self.pdf_info: | |
| 261 | + # 오류 메시지 표시 | |
| 262 | + self.canvas.create_text(200, 100, | |
| 263 | + text=f"미리보기를 생성할 수 없습니다.\n{self.pdf_info['error']}", | |
| 264 | + fill='red', font=('맑은 고딕', 12), anchor='center') | |
| 265 | + return | |
| 266 | + | |
| 267 | + # 미리보기 이미지 생성 | |
| 268 | + thumbnail = self.preview_manager.create_preview_thumbnail(self.pdf_path, (350, 500)) | |
| 269 | + | |
| 270 | + if thumbnail: | |
| 271 | + # PIL Image를 PhotoImage로 변환 | |
| 272 | + self.photo = ImageTk.PhotoImage(thumbnail) | |
| 273 | + | |
| 274 | + # 캔버스에 이미지 표시 | |
| 275 | + self.canvas.delete("all") # 기존 내용 제거 | |
| 276 | + self.canvas.create_image(thumbnail.width//2, thumbnail.height//2, | |
| 277 | + image=self.photo, anchor='center') | |
| 278 | + | |
| 279 | + # 스크롤 영역 설정 | |
| 280 | + self.canvas.configure(scrollregion=self.canvas.bbox("all")) | |
| 281 | + | |
| 282 | + else: | |
| 283 | + # 미리보기 실패 메시지 | |
| 284 | + self.canvas.create_text(200, 100, | |
| 285 | + text="미리보기를 생성할 수 없습니다.\nPDF 파일이 손상되었거나\n지원하지 않는 형식일 수 있습니다.", | |
| 286 | + fill='red', font=('맑은 고딕', 12), anchor='center') | |
| 287 | + | |
| 288 | + except Exception as e: | |
| 289 | + # 예외 발생시 오류 메시지 표시 | |
| 290 | + self.canvas.create_text(200, 100, | |
| 291 | + text=f"미리보기 로드 실패:\n{str(e)}", | |
| 292 | + fill='red', font=('맑은 고딕', 12), anchor='center') | |
| 293 | + | |
| 294 | + def open_with_system(self): | |
| 295 | + """시스템 기본 프로그램으로 열기""" | |
| 296 | + success = self.preview_manager.open_file_with_system(self.pdf_path) | |
| 297 | + if not success: | |
| 298 | + messagebox.showerror("오류", "파일을 열 수 없습니다.\n기본 PDF 뷰어가 설치되어 있는지 확인해주세요.") | |
| 299 | + | |
| 300 | + def open_folder(self): | |
| 301 | + """파일이 있는 폴더 열기""" | |
| 302 | + try: | |
| 303 | + folder_path = os.path.dirname(self.pdf_path) | |
| 304 | + if platform.system() == 'Windows': | |
| 305 | + subprocess.run(['explorer', '/select,', self.pdf_path]) | |
| 306 | + elif platform.system() == 'Darwin': # macOS | |
| 307 | + subprocess.run(['open', '-R', self.pdf_path]) | |
| 308 | + else: # Linux | |
| 309 | + subprocess.run(['xdg-open', folder_path]) | |
| 310 | + except Exception as e: | |
| 311 | + messagebox.showerror("오류", f"폴더를 열 수 없습니다:\n{str(e)}") | |
| 312 | + | |
| 313 | + def refresh_preview(self): | |
| 314 | + """미리보기 새로고침""" | |
| 315 | + # 캐시에서 제거 | |
| 316 | + cache_key = f"{self.pdf_path}_(350, 500)" | |
| 317 | + if cache_key in self.preview_manager.preview_cache: | |
| 318 | + del self.preview_manager.preview_cache[cache_key] | |
| 319 | + | |
| 320 | + # PDF 정보 다시 로드 | |
| 321 | + self.pdf_info = self.preview_manager.get_pdf_info(self.pdf_path) | |
| 322 | + | |
| 323 | + # 미리보기 이미지 다시 로드 | |
| 324 | + self.load_preview_image() | |
| 325 | + | |
| 326 | + messagebox.showinfo("새로고침", "미리보기가 새로고침되었습니다.") | |
| 327 | + | |
| 328 | + def close_dialog(self): | |
| 329 | + """다이얼로그 닫기""" | |
| 330 | + self.window.grab_release() # 모달 해제 | |
| 331 | + self.window.destroy()(No newline at end of file) |
+++ test/employees.json
... | ... | @@ -0,0 +1,19 @@ |
| 1 | +{ | |
| 2 | + "김혜리": "khr2205@iten.co.kr", | |
| 3 | + "이준호": "tolag3@iten.co.kr", | |
| 4 | + "유인식": "smartyu@iten.co.kr", | |
| 5 | + "원영현": "dudgusw@iten.co.kr", | |
| 6 | + "유찬희": "ych@iten.co.kr", | |
| 7 | + "조현희": "hc3874@iten.co.kr", | |
| 8 | + "강영묵": "ymkang@iten.co.kr", | |
| 9 | + "조용준": "antelope@iten.co.kr", | |
| 10 | + "우영두": "rosehips@iten.co.kr", | |
| 11 | + "김상훈": "aricowiz@iten.co.kr", | |
| 12 | + "장영익": "yeongik@iten.co.kr", | |
| 13 | + "정다은": "jungde@iten.co.kr", | |
| 14 | + "이지우": "dlwldn1024@iten.co.kr", | |
| 15 | + "박진순": "jsp@iten.co.kr", | |
| 16 | + "정수빈": "dhgksk99@iten.co.kr", | |
| 17 | + "강민경": "kmk0522@iten.co.kr", | |
| 18 | + "이호영": "hylee@iten.co.kr" | |
| 19 | +}(No newline at end of file) |
+++ test/employees_backup.csv
... | ... | @@ -0,0 +1,18 @@ |
| 1 | +이름,이메일,백업일시 | |
| 2 | +김혜리,khr2205@iten.co.kr,2025-08-28 16:41 | |
| 3 | +이준호,tolag3@iten.co.kr,2025-08-28 16:41 | |
| 4 | +유인식,smartyu@iten.co.kr,2025-08-28 16:41 | |
| 5 | +원영현,dudgusw@iten.co.kr,2025-08-28 16:41 | |
| 6 | +유찬희,ych@iten.co.kr,2025-08-28 16:41 | |
| 7 | +조현희,hc3874@iten.co.kr,2025-08-28 16:41 | |
| 8 | +강영묵,ymkang@iten.co.kr,2025-08-28 16:41 | |
| 9 | +조용준,antelope@iten.co.kr,2025-08-28 16:41 | |
| 10 | +우영두,rosehips@iten.co.kr,2025-08-28 16:41 | |
| 11 | +김상훈,aricowiz@iten.co.kr,2025-08-28 16:41 | |
| 12 | +장영익,yeongik@iten.co.kr,2025-08-28 16:41 | |
| 13 | +정다은,jungde@iten.co.kr,2025-08-28 16:41 | |
| 14 | +이지우,dlwldn1024@iten.co.kr,2025-08-28 16:41 | |
| 15 | +박진순,jsp@iten.co.kr,2025-08-28 16:41 | |
| 16 | +정수빈,dhgksk99@iten.co.kr,2025-08-28 16:41 | |
| 17 | +강민경,kmk0522@iten.co.kr,2025-08-28 16:41 | |
| 18 | +이호영,hylee@iten.co.kr,2025-08-28 16:41 |
+++ test/급여명세서.pdf
| Binary file is not shown |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?