diff --git a/package-lock.json b/package-lock.json index 7d3dd3c..bc3952a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.87.4", "@tanstack/react-table": "^8.21.3", - "axios": "^1.11.0", + "axios": "^1.12.1", "dayjs": "^1.11.18", "react": "^18.3.1", "react-big-calendar": "^1.19.4", @@ -2448,9 +2448,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.1.tgz", + "integrity": "sha512-Kn4kbSXpkFHCGE6rBFNwIv0GQs4AvDT80jlveJDKFxjbTYMUeB4QtsdPCv6H8Cm19Je7IU6VFtRl2zWZI0rudQ==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -4960,20 +4960,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8fd8bd9..4c8bdc6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@tailwindcss/vite": "^4.1.12", "@tanstack/react-query": "^5.87.4", "@tanstack/react-table": "^8.21.3", - "axios": "^1.11.0", + "axios": "^1.12.1", "dayjs": "^1.11.18", "react": "^18.3.1", "react-big-calendar": "^1.19.4", diff --git a/src/api/meetingAPI.js b/src/api/meetingAPI.js index 5d943c3..ef0bd94 100644 --- a/src/api/meetingAPI.js +++ b/src/api/meetingAPI.js @@ -1,7 +1,7 @@ import api from './client'; // 회의록 생성 -export const createMeeting = async (projectId, meetingData) => { +export const createMeeting = async (projectId, meetingData, audioFile = null) => { try { // FormData 생성 - meetingData를 JSON 문자열로 전송 const formData = new FormData(); @@ -14,6 +14,11 @@ export const createMeeting = async (projectId, meetingData) => { participantIds: meetingData.participantIds })); + // 오디오 파일이 있으면 추가 + if (audioFile) { + formData.append('audio', audioFile, 'recording.wav'); + } + const response = await api.post(`/meeting/${projectId}`, formData, { headers: { 'Content-Type': 'multipart/form-data', @@ -54,7 +59,7 @@ export const getMeetingDetail = async (projectId, meetingId) => { }; // 회의록 수정 -export const updateMeeting = async (projectId, meetingId, meetingData) => { +export const updateMeeting = async (projectId, meetingId, meetingData, audioFile = null) => { try { // FormData 생성 - meetingData를 JSON 문자열로 전송 const formData = new FormData(); @@ -67,6 +72,11 @@ export const updateMeeting = async (projectId, meetingId, meetingData) => { participantIds: meetingData.participantIds })); + // 오디오 파일이 있으면 추가 + if (audioFile) { + formData.append('audio', audioFile, 'recording.wav'); + } + const response = await api.put(`/meeting/${projectId}/${meetingId}`, formData, { headers: { 'Content-Type': 'multipart/form-data', @@ -89,3 +99,109 @@ export const deleteMeeting = async (projectId, meetingId) => { throw error; } }; + +// 파일 다운로드 +export const downloadMeetingReport = async (projectId, meetingId) => { + try { + const response = await api.get(`/meeting/${projectId}/${meetingId}/report/download`, { + responseType: 'blob', + }); + + // Content-Disposition 헤더에서 파일명 추출 + const contentDisposition = response.headers['content-disposition']; + let fileName = 'meeting_report.docx'; // 기본 파일명 + + if (contentDisposition) { + console.log('Content-Disposition:', contentDisposition); // 디버깅용 + + // 다양한 Content-Disposition 형식 지원 + let fileNameMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/); + + if (fileNameMatch && fileNameMatch[1]) { + // RFC 5987 형식 (filename*=UTF-8''encoded-filename) + try { + fileName = decodeURIComponent(fileNameMatch[1]); + } catch { + // e 변수 제거 + console.warn('UTF-8 디코딩 실패, 원본 사용:', fileNameMatch[1]); + fileName = fileNameMatch[1]; + } + } else { + // 일반 형식 (filename="filename") + fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/); + if (fileNameMatch && fileNameMatch[1]) { + fileName = fileNameMatch[1]; + } + } + } + + console.log('추출된 파일명:', fileName); // 디버깅용 + + // Blob 객체 생성 + const blob = new Blob([response.data], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }); + + // 파일 다운로드 실행 + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + // 정리 + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + return { success: true, fileName }; + } catch (error) { + console.error('회의록 다운로드 실패:', error); + throw error; + } +}; + + +// 이메일 전송 +export const sendMeetingEmail = async (projectId, meetingId, emailData, attachments = []) => { + try { + const formData = new FormData(); + + // emailData를 JSON 문자열로 변환하여 추가 + formData.append('emailData', JSON.stringify({ + to: emailData.to, + subject: emailData.subject, + content: emailData.content + })); + + // 첨부파일 추가 - index 매개변수 제거 + if (attachments && attachments.length > 0) { + attachments.forEach((file) => { + formData.append('attachments', file); + }); + } + + const response = await api.post(`/meeting/${projectId}/${meetingId}/email`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data; + } catch (error) { + console.error('이메일 전송 실패:', error); + throw error; + } +}; + + +// 회의록 작성 프로젝트 참여 인원 호출 +export const getProjectParticipants = async (projectId) => { + try { + const response = await api.get(`/meeting/${projectId}/participant`); + return response.data; + } catch (error) { + console.error('프로젝트 팀 인원 조회 실패:', error); + throw error; + } +}; \ No newline at end of file diff --git a/src/components/common/AISummaryPanel.jsx b/src/components/common/AISummaryPanel.jsx index 813e5b0..511c8d7 100644 --- a/src/components/common/AISummaryPanel.jsx +++ b/src/components/common/AISummaryPanel.jsx @@ -5,22 +5,24 @@ function AISummaryPanel({ title = "AI 정리", className = "bg-gray-100 rounded-lg p-6 border border-gray-200", }) { - const defaultSummary = `임시 예시 텍스트: - -📝 주요 안건 -• 고객 마케팅을 위한 앱 개선 방안 논의 -• 신규 기능 개발 일정 조율 -• 팀 간 협업 프로세스 개선 - -✅ 결정 사항 -• UI/UX 개선안 3월 말까지 완료 -• 백엔드 API 연동 4월 초 시작 -• 주간 스탠드업 미팅 화요일 오전 10시로 변경 + // summary가 없거나 빈 객체인 경우 기본값 설정 + if (!summary || typeof summary !== 'object') { + return ( +
+
+
+

{title}

+
+
+ AI 요약 정보가 없습니다. +
+
+
+ ); + } -📋 액션 아이템 -• 박서호: 프론트엔드 컴포넌트 설계 (3/25까지) -• 이지민: 디자인 시스템 업데이트 (3/28까지) -• 최우식: 요구사항 문서 작성 (3/30까지)`; + // 서버에서 받은 데이터 구조에 맞게 처리 + const { mainTopics = [], decisions = [], priorities = [], recommends = [] } = summary; return (
@@ -29,14 +31,88 @@ function AISummaryPanel({

{title}

-
-
- {summary || defaultSummary} -
+
+ {/* 주요 안건 */} + {mainTopics && mainTopics.length > 0 && ( +
+

+ 📝 주요 안건 +

+
    + {mainTopics.map((topic, index) => ( +
  • + + {topic} +
  • + ))} +
+
+ )} + + {/* 결정 사항 */} + {decisions && decisions.length > 0 && ( +
+

+ ✅ 결정 사항 +

+
    + {decisions.map((decision, index) => ( +
  • + + {decision} +
  • + ))} +
+
+ )} + + {/* 액션 아이템 */} + {priorities && priorities.length > 0 && ( +
+

+ 📋 우선 사항 +

+
    + {priorities.map((priority, index) => ( +
  • + + {priority} +
  • + ))} +
+
+ )} + + {/* 추천 아이템 */} + {recommends && recommends.length > 0 && ( +
+

+ 👍 추천 업무 +

+
    + {recommends.map((recommand, index) => ( +
  • + + {recommand} +
  • + ))} +
+
+ )} + + {/* 모든 데이터가 없는 경우 */} + {(!mainTopics || mainTopics.length === 0) && + (!decisions || decisions.length === 0) && + (!priorities || priorities.length === 0) && + (!recommends || recommends.length === 0) && ( +
+ AI 요약 정보가 없습니다. +
+ )}
); } -export default AISummaryPanel; +export default AISummaryPanel; \ No newline at end of file diff --git a/src/components/meeting/EmailSendModal.jsx b/src/components/meeting/EmailSendModal.jsx new file mode 100644 index 0000000..6acf163 --- /dev/null +++ b/src/components/meeting/EmailSendModal.jsx @@ -0,0 +1,235 @@ +// src/components/meeting/EmailSendModal.jsx (새 파일 생성) +import { useState } from 'react'; +import { Close, AttachFile, Send } from '@mui/icons-material'; +import { sendMeetingEmail } from '../../api/meetingAPI'; +import { useToast } from '../../hooks/useToast'; + +function EmailSendModal({ isOpen, onClose, projectId, meetingId, meetingTitle }) { + const { showSuccess, showError } = useToast(); + + const [formData, setFormData] = useState({ + to: '', + subject: `회의록: ${meetingTitle || '제목 없음'}`, + content: '' + }); + const [attachments, setAttachments] = useState([]); + const [isSending, setIsSending] = useState(false); + + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleFileChange = (e) => { + const files = Array.from(e.target.files); + setAttachments(prev => [...prev, ...files]); + }; + + const removeAttachment = (index) => { + setAttachments(prev => prev.filter((_, i) => i !== index)); + }; + + // src/components/meeting/EmailSendModal.jsx의 handleSubmit 함수 수정 +const handleSubmit = async (e) => { + e.preventDefault(); + + if (!formData.to.trim()) { + showError('수신자 이메일 주소를 입력해주세요.'); + return; + } + + if (!formData.subject.trim()) { + showError('이메일 제목을 입력해주세요.'); + return; + } + + if (!formData.content.trim()) { + showError('이메일 내용을 입력해주세요.'); + return; + } + + // 이메일 주소 유효성 검사 개선 + const toEmails = formData.to.split(',').map(email => email.trim()).filter(email => email); + + // 각 이메일 주소 유효성 검사 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const invalidEmails = toEmails.filter(email => !emailRegex.test(email)); + + if (invalidEmails.length > 0) { + showError(`유효하지 않은 이메일 주소가 있습니다: ${invalidEmails.join(', ')}`); + return; + } + + setIsSending(true); + try { + await sendMeetingEmail(projectId, meetingId, { + to: toEmails, + subject: formData.subject, + content: formData.content + }, attachments); + + showSuccess('이메일이 성공적으로 전송되었습니다.'); + onClose(); + + // 폼 초기화 + setFormData({ + to: '', + subject: `회의록: ${meetingTitle || '제목 없음'}`, + content: '' + }); + setAttachments([]); + } catch (error) { + console.error('이메일 전송 실패:', error); + if (error.response?.status === 400) { + showError('잘못된 요청입니다. 입력 정보를 확인해주세요.'); + } else if (error.response?.status === 500) { + showError('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + } else { + showError('이메일 전송 중 오류가 발생했습니다.'); + } + } finally { + setIsSending(false); + } +}; + + if (!isOpen) return null; + + return ( +
+
+
+

이메일 전송

+ +
+ +
+ {/* 수신자 이메일 */} +
+ + +

+ 여러 이메일 주소는 쉼표(,)로 구분해주세요. +

+
+ + {/* 이메일 제목 */} +
+ + +
+ + {/* 이메일 내용 */} +
+ +