From 09cb3cf6e0c42b284b26c1cf22fccdeb53cb3797 Mon Sep 17 00:00:00 2001
From: JIWON <82360230+qowl880@users.noreply.github.com>
Date: Mon, 15 Sep 2025 03:13:48 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat=20:=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20AP?=
=?UTF-8?q?I=20=EC=97=B0=EA=B2=B0=20(#15)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com>
---
package-lock.json | 22 +-
package.json | 2 +-
src/api/meetingAPI.js | 120 ++++++++++-
src/components/common/AISummaryPanel.jsx | 116 +++++++++--
src/components/meeting/EmailSendModal.jsx | 235 ++++++++++++++++++++++
src/components/meeting/Meeting.jsx | 27 ++-
src/components/meeting/MeetingCreate.jsx | 144 +++++++++----
src/components/meeting/MeetingDetail.jsx | 77 ++++++-
src/hooks/useMeetingQueries.js | 10 +-
src/pages/ProjectDetail.jsx | 7 +-
src/store/meetingStore.js | 75 +++----
11 files changed, 695 insertions(+), 140 deletions(-)
create mode 100644 src/components/meeting/EmailSendModal.jsx
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 (
+
+ );
+}
+
+export default EmailSendModal;
\ No newline at end of file
diff --git a/src/components/meeting/Meeting.jsx b/src/components/meeting/Meeting.jsx
index 6d6f99d..b31d4aa 100644
--- a/src/components/meeting/Meeting.jsx
+++ b/src/components/meeting/Meeting.jsx
@@ -8,10 +8,13 @@ import { LoadingSpinner, ErrorFallback } from "../common/loading/LoadingComponen
import useMeetingStore from "../../store/meetingStore";
import { useToast } from "../../hooks/useToast";
-function Meeting() {
+function Meeting({ projectId }) {
const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState("all");
+ console.log('Meeting에서 받은 projectId:', projectId);
+ console.log('projectId 타입:', typeof projectId);
+
// Zustand store 사용
const {
meetings,
@@ -35,8 +38,10 @@ function Meeting() {
// 컴포넌트 마운트 시 회의록 목록 조회
useEffect(() => {
- fetchMeetings(1); // 프로젝트 ID 1로 고정
- }, [fetchMeetings]);
+ if (projectId) {
+ fetchMeetings(projectId, 1, 10);
+ }
+ }, [projectId, fetchMeetings]);
// 필터링된 회의 목록
const filteredMeetings = meetings.filter((meeting) => {
@@ -55,7 +60,7 @@ function Meeting() {
// 삭제 확인 함수
const handleDeleteConfirm = async (meeting) => {
try {
- await deleteMeeting(meeting.id);
+ await deleteMeeting(meeting.id, projectId);
showSuccess("회의록이 성공적으로 삭제되었습니다.");
} catch (error) {
console.error('삭제 실패:', error);
@@ -67,7 +72,7 @@ function Meeting() {
const handleMeetingAction = (action, meeting) => {
switch (action) {
case "view":
- selectMeeting(meeting);
+ selectMeeting(meeting, projectId);
break;
case "edit":
showEdit(meeting);
@@ -80,7 +85,7 @@ function Meeting() {
console.log('handleSaveMeeting 호출됨, API 응답:', apiResponse);
// 회의록 생성 후 목록 새로고침
- await refreshAfterCreate(1);
+ await refreshAfterCreate(projectId);
} catch (error) {
console.error('handleSaveMeeting 에러:', error);
@@ -93,7 +98,7 @@ function Meeting() {
console.log('handleUpdateMeeting 호출됨, API 응답:', apiResponse);
// 회의록 수정 후 목록 새로고침
- await fetchMeetings(1, pagination.page, pagination.size);
+ await fetchMeetings(projectId, pagination.page, pagination.size);
showList();
} catch (error) {
@@ -117,6 +122,7 @@ function Meeting() {
) : currentView === 'edit' ? (
) : currentView === 'detail' ? (
fetchMeetings(1)}
+ onRetry={() => fetchMeetings(projectId)}
/>
) : (
<>
@@ -195,7 +202,7 @@ function Meeting() {
{!loading && pagination.totalPages > 1 && (
changePage(pagination.page - 1)}
+ onClick={() => changePage(pagination.page - 1,projectId)}
disabled={!pagination.hasPrevious}
className="px-3 py-1 rounded border disabled:opacity-50"
>
@@ -205,7 +212,7 @@ function Meeting() {
{pagination.page} / {pagination.totalPages}
changePage(pagination.page + 1)}
+ onClick={() => changePage(pagination.page + 1, projectId)}
disabled={!pagination.hasNext}
className="px-3 py-1 rounded border disabled:opacity-50"
>
diff --git a/src/components/meeting/MeetingCreate.jsx b/src/components/meeting/MeetingCreate.jsx
index 458e901..9cda746 100644
--- a/src/components/meeting/MeetingCreate.jsx
+++ b/src/components/meeting/MeetingCreate.jsx
@@ -1,12 +1,13 @@
-import { useState, useRef } from "react";
+import { useState, useRef, useEffect } from "react";
import { ArrowBack, Mic, Stop, PlayArrow, Pause } from "@mui/icons-material";
import { employeesData } from "../../data/employees";
import Dropdown from '../common/Dropdown';
import { LoadingSpinner } from '../common/loading/LoadingComponents';
import { useCreateMeeting, useUpdateMeeting } from '../../hooks/useMeetingQueries';
import { useToast } from '../../hooks/useToast';
+import { getProjectParticipants } from '../../api/meetingAPI';
-function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
+function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false, projectId }) {
const [meetingData, setMeetingData] = useState({
title: meeting?.title || "",
place: meeting?.location || meeting?.place || "",
@@ -16,12 +17,19 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
progressDate: meeting?.progressDate || new Date().toISOString().slice(0, -1) + '+09:00',
});
+ console.log('MeetingCreate에서 받은 projectId:', projectId);
+ console.log('projectId 타입:', typeof projectId);
+
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [showParticipantDropdown, setShowParticipantDropdown] = useState(false);
+ // 팀 인원 데이터 상태 추가
+ const [teamMembers, setTeamMembers] = useState([]);
+ const [isLoadingMembers, setIsLoadingMembers] = useState(false);
+
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const intervalRef = useRef(null);
@@ -44,6 +52,38 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
{ value: 'RETROSPECTIVE', label: 'Sprint Retrospective' }
];
+ // 팀 인원 조회 함수 추가
+ const fetchTeamMembers = async () => {
+ setIsLoadingMembers(true);
+ try {
+ const response = await getProjectParticipants(projectId);
+
+ if (response.statusCode === 200) {
+ // 서버 응답 데이터를 컴포넌트에서 사용할 수 있는 형태로 변환
+ const members = response.data.map(member => ({
+ id: member.memberId || member.id,
+ name: member.memberName || member.name,
+ email: member.email,
+ department: member.department,
+ position: member.position
+ }));
+ setTeamMembers(members);
+ }
+ } catch (error) {
+ console.error('팀 인원 조회 실패:', error);
+ showError('팀 인원 정보를 불러오는데 실패했습니다.');
+ // 실패 시 기본 데이터 사용
+ setTeamMembers(employeesData);
+ } finally {
+ setIsLoadingMembers(false);
+ }
+ };
+
+ // 컴포넌트 마운트 시 팀 인원 조회
+ useEffect(() => {
+ fetchTeamMembers();
+ }, []);
+
// 입력값 변경 핸들러
const handleInputChange = (field, value) => {
setMeetingData((prev) => ({
@@ -52,13 +92,13 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
}));
};
- // 참석자 선택/해제 - ID 기반으로 수정
- const toggleParticipant = (employee) => {
+ // 참석자 선택/해제 - 팀 인원 데이터 사용
+ const toggleParticipant = (member) => {
setMeetingData((prev) => ({
...prev,
- participants: prev.participants.includes(employee.id)
- ? prev.participants.filter((id) => id !== employee.id)
- : [...prev.participants, employee.id],
+ participants: prev.participants.includes(member.id)
+ ? prev.participants.filter((id) => id !== member.id)
+ : [...prev.participants, member.id],
}));
};
@@ -152,27 +192,29 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
participantIds: meetingData.participants
};
- const projectId = 1; // 현재는 1번 프로젝트만 있다고 가정
-
try {
let result;
if (isEditing && meeting?.id) {
- // 수정 모드
+ // 수정 모드 - 오디오 파일 전송
result = await updateMutation.mutateAsync({
projectId,
meetingId: meeting.id,
- meetingData: requestData
+ meetingData: requestData,
+ audioFile: audioBlob // 오디오 파일 추가
});
showSuccess("회의록이 성공적으로 수정되었습니다.");
} else {
- // 생성 모드
+ // 생성 모드 - 오디오 파일 전송
result = await createMutation.mutateAsync({
projectId,
- meetingData: requestData
+ meetingData: requestData,
+ audioFile: audioBlob // 오디오 파일 추가
});
showSuccess("회의록이 성공적으로 저장되었습니다.");
}
+ console.log('API 응답:', result); // 응답 확인용 로그
+ console.log('전송된 오디오 파일:', audioBlob); // 오디오 파일 확인용 로그
console.log('API 응답:', result); // 응답 확인용 로그
// 성공 시 콜백 호출
@@ -309,48 +351,68 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
{showParticipantDropdown && (
- {employeesData.map((employee) => (
-
toggleParticipant(employee)}
- >
-
{}}
- className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
- />
-
-
- {employee.name}
-
-
- {employee.department} · {employee.position}
-
-
+ {isLoadingMembers ? (
+
+
- ))}
+ ) : (
+
+ {teamMembers.map((member) => (
+
toggleParticipant(member)}
+ className={`w-full px-4 py-2 text-left hover:bg-gray-50 flex items-center justify-between ${
+ meetingData.participants.includes(member.id)
+ ? 'bg-blue-50 text-blue-700'
+ : 'text-gray-700'
+ }`}
+ >
+
+ {member.name}
+ {member.department && (
+ {member.department}
+ )}
+ {member.position && (
+ {member.position}
+ )}
+
+ {meetingData.participants.includes(member.id) && (
+
+
+
+ )}
+
+ ))}
+
+ )}
)}
- {/* 선택된 참석자 표시 - 이름으로 표시하되 ID 기반으로 관리 */}
+ {/* 선택된 참석자 표시 - 팀 인원 데이터 사용 */}
{meetingData.participants.length > 0 && (
{meetingData.participants.map((participantId) => {
- const participant = employeesData.find(emp => emp.id === participantId);
+ const participant = teamMembers.find(member => member.id === participantId);
return participant ? (
{participant.name}
toggleParticipant(participant)}
- className="ml-1 hover:text-blue-600"
+ className="ml-2 text-blue-600 hover:text-blue-800"
>
×
@@ -453,4 +515,4 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false }) {
);
}
-export default MeetingCreate;
+export default MeetingCreate;
\ No newline at end of file
diff --git a/src/components/meeting/MeetingDetail.jsx b/src/components/meeting/MeetingDetail.jsx
index e65dd03..07ecac2 100644
--- a/src/components/meeting/MeetingDetail.jsx
+++ b/src/components/meeting/MeetingDetail.jsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import {
ArrowBack,
CalendarToday,
@@ -7,17 +8,26 @@ import {
Email,
Article,
} from "@mui/icons-material";
+import { downloadMeetingReport } from "../../api/meetingAPI";
+
import BadgeComponent from "../common/BadgeComponent";
import ParticipantList from "../common/ParticipantList";
import MeetingMetadata from "../common/MeetingMetadata";
import AudioPlayer from "../common/AudioPlayer";
import AISummaryPanel from "../common/AISummaryPanel";
import { useToast } from "../../hooks/useToast";
+import EmailSendModal from "./EmailSendModal";
function MeetingDetail({ meeting, onBack, onEdit, onDelete }) {
// Toast hook
const { showError } = useToast();
+ // 다운로드 상태 추가
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ // 이메일 전송 모달 상태 추가
+ const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
+
if (!meeting) {
return (
@@ -34,6 +44,42 @@ function MeetingDetail({ meeting, onBack, onEdit, onDelete }) {
);
}
+ // 이메일 전송 핸들러 함수 추가
+ const handleEmailSend = () => {
+ if (!meeting?.projectId || !meeting?.id) {
+ showError("프로젝트 ID 또는 회의 ID가 없습니다.");
+ return;
+ }
+ setIsEmailModalOpen(true);
+ };
+
+ // 다운로드 핸들러 함수 추가 (handleDelete 함수 아래에 추가)
+ const handleDownloadReport = async () => {
+ if (!meeting?.projectId || !meeting?.id) {
+ showError("프로젝트 ID 또는 회의 ID가 없습니다.");
+ return;
+ }
+
+ setIsDownloading(true);
+ try {
+ const result = await downloadMeetingReport(meeting.projectId, meeting.id);
+ // 성공 메시지는 부모 컴포넌트에서 처리하거나 여기서 처리
+ console.log(`${result.fileName} 다운로드가 완료되었습니다.`);
+ } catch (error) {
+ console.error('다운로드 중 오류가 발생했습니다:', error);
+ if (error.response?.status === 404) {
+ showError("회의록을 찾을 수 없습니다.");
+ } else if (error.response?.status === 500) {
+ showError("서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
+ } else {
+ showError("다운로드 중 오류가 발생했습니다.");
+ }
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+
// 날짜 포맷팅
const formatDateTime = (dateTimeStr) => {
const date = new Date(dateTimeStr);
@@ -137,12 +183,15 @@ function MeetingDetail({ meeting, onBack, onEdit, onDelete }) {
-
{}}
- className="px-4 py-2 text-black hover:bg-blue-50 rounded-lg transition-colors flex items-center gap-2"
+
- AI 리포트 생성
+ {isDownloading ? '다운로드 중...' : 'AI 리포트 생성'}
{
@@ -150,9 +199,14 @@ function MeetingDetail({ meeting, onBack, onEdit, onDelete }) {
}}
className="px-4 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors flex items-center gap-2"
>
-
- 이메일 전송
+
+
+ 이메일 전송
+
@@ -192,10 +246,19 @@ function MeetingDetail({ meeting, onBack, onEdit, onDelete }) {
{/* 우측 영역 - AI 정리 창 */}
+
+ {/* 이메일 전송 모달 */}
+ setIsEmailModalOpen(false)}
+ projectId={meeting?.projectId}
+ meetingId={meeting?.id}
+ meetingTitle={meeting?.title}
+ />
);
}
diff --git a/src/hooks/useMeetingQueries.js b/src/hooks/useMeetingQueries.js
index 7e1b491..c2bb0f3 100644
--- a/src/hooks/useMeetingQueries.js
+++ b/src/hooks/useMeetingQueries.js
@@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient, useSuspenseQuery } from '@tansta
import { getMeetings, deleteMeeting as deleteMeetingAPI, getMeetingDetail, createMeeting, updateMeeting } from '../api/meetingAPI';
// 회의록 목록 조회
-export function useMeetings(projectId = 1, page = 1, size = 10) {
+export function useMeetings(projectId, page = 1, size = 10) {
return useQuery({
queryKey: ['meetings', projectId, page, size],
queryFn: () => getMeetings(projectId, page, size),
@@ -22,7 +22,8 @@ export function useMeetings(projectId = 1, page = 1, size = 10) {
participants: [],
participantCount: meeting.attendeeCount,
description: meeting.content,
- createdAt: meeting.progressTime
+ createdAt: meeting.progressTime,
+ summary : meeting.aiSummary
})),
pagination: {
page: response.data.page,
@@ -40,7 +41,7 @@ export function useMeetings(projectId = 1, page = 1, size = 10) {
}
// Suspense와 함께 사용할 회의록 목록
-export function useSuspenseMeetings(projectId = 1, page = 1, size = 10) {
+export function useSuspenseMeetings(projectId , page = 1, size = 10) {
return useSuspenseQuery({
queryKey: ['meetings', projectId, page, size],
queryFn: () => getMeetings(projectId, page, size),
@@ -111,6 +112,7 @@ export function useMeetingDetail(projectId, meetingId, enabled = true) {
description: response.data.content,
memo: response.data.content,
createdAt: response.data.progressDate,
+ summary : response.data.aiSummary,
};
}
throw new Error('Failed to fetch meeting detail');
@@ -180,7 +182,7 @@ export function useCreateMeeting() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: ({ projectId, meetingData }) => createMeeting(projectId, meetingData),
+ mutationFn: ({ projectId, meetingData, audioFile }) => createMeeting(projectId, meetingData, audioFile),
onSuccess: (data, variables) => {
// 성공 시 관련 쿼리들 무효화
queryClient.invalidateQueries({ queryKey: ['meetings', variables.projectId] });
diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx
index 14fbbc3..412c8a9 100644
--- a/src/pages/ProjectDetail.jsx
+++ b/src/pages/ProjectDetail.jsx
@@ -21,6 +21,11 @@ import SettingsTab from "../components/setting/Settings";
function ProjectDetail() {
const { id } = useParams();
+
+ // 디버깅용 로그 추가
+ console.log('ProjectDetail에서 받은 id:', id);
+ console.log('id 타입:', typeof id);
+
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState("kanban");
@@ -97,7 +102,7 @@ function ProjectDetail() {
case "timeline":
return ;
case "meeting":
- return ;
+ return ;
case "team":
return ;
case "settings":
diff --git a/src/store/meetingStore.js b/src/store/meetingStore.js
index 136bb53..3f3754b 100644
--- a/src/store/meetingStore.js
+++ b/src/store/meetingStore.js
@@ -18,7 +18,7 @@ const useMeetingStore = create((set, get) => ({
},
// API에서 회의록 목록 조회
- fetchMeetings: async (projectId = 1, page = 1, size = 10) => {
+ fetchMeetings: async (projectId , page = 1, size = 10) => {
set({ loading: true, error: null });
try {
const response = await getMeetings(projectId, page, size);
@@ -34,7 +34,8 @@ const useMeetingStore = create((set, get) => ({
participants: [],
participantCount: meeting.attendeeCount,
description: meeting.content,
- createdAt: meeting.progressTime
+ createdAt: meeting.progressTime,
+ summary : meeting.aiSummary
}));
set({
@@ -57,40 +58,42 @@ const useMeetingStore = create((set, get) => ({
},
// 회의록 상세 조회
- fetchMeetingDetail: async (meetingId, projectId = 1, listMeeting = null) => {
- set({ loading: true, error: null });
- try {
- const response = await getMeetingDetail(projectId, meetingId);
-
- if (response.statusCode === 200) {
- const meetingDetail = {
- id: meetingId,
- title: response.data.title,
- organizer: response.data.writerName,
- type: listMeeting?.type || 'MEETING',
- scheduledDate: response.data.progressDate,
- location: response.data.place,
- participants: response.data.participants,
- participantCount: response.data.participants?.length || 0,
- content: response.data.content,
- description: response.data.content,
- memo: response.data.content,
- createdAt: response.data.progressDate,
- };
+ fetchMeetingDetail: async (meetingId, projectId, listMeeting = null) => {
+ set({ loading: true, error: null });
+ try {
+ const response = await getMeetingDetail(projectId, meetingId);
+
+ if (response.statusCode === 200) {
+ const meetingDetail = {
+ id: meetingId,
+ projectId: projectId, // projectId 추가
+ title: response.data.title,
+ organizer: response.data.writerName,
+ type: listMeeting?.type || 'MEETING',
+ scheduledDate: response.data.progressDate,
+ location: response.data.place,
+ participants: response.data.participants,
+ participantCount: response.data.participants?.length || 0,
+ content: response.data.content,
+ description: response.data.content,
+ memo: response.data.content,
+ createdAt: response.data.progressDate,
+ summary : response.data.aiSummary
+ };
- set({
- selectedMeeting: meetingDetail,
- currentView: 'detail',
- loading: false
- });
- }
- } catch (error) {
- console.error('회의록 상세 조회 실패:', error);
- set({ error: error.message, loading: false });
+ set({
+ selectedMeeting: meetingDetail,
+ currentView: 'detail',
+ loading: false
+ });
}
- },
+ } catch (error) {
+ console.error('회의록 상세 조회 실패:', error);
+ set({ error: error.message, loading: false });
+ }
+},
- selectMeeting: async (meeting, projectId = 1) => {
+ selectMeeting: async (meeting, projectId ) => {
// 목록에서 선택할 때는 상세 정보를 가져와야 함
// 목록 정보의 타입을 보존하기 위해 전달
await get().fetchMeetingDetail(meeting.id, projectId, meeting);
@@ -111,14 +114,14 @@ const useMeetingStore = create((set, get) => ({
}),
// 회의록 생성 후 목록 새로고침
- refreshAfterCreate: async (projectId = 1) => {
+ refreshAfterCreate: async (projectId) => {
const { fetchMeetings, pagination } = get();
await fetchMeetings(projectId, pagination.page, pagination.size);
set({ currentView: 'list' });
},
// 회의록 삭제
- deleteMeeting: async (meetingId, projectId = 1) => {
+ deleteMeeting: async (meetingId, projectId) => {
try {
await deleteMeetingAPI(projectId, meetingId);
// 삭제 후 목록 새로고침
@@ -133,7 +136,7 @@ const useMeetingStore = create((set, get) => ({
},
// 페이지 변경
- changePage: async (newPage, projectId = 1) => {
+ changePage: async (newPage, projectId ) => {
const { pagination, fetchMeetings } = get();
await fetchMeetings(projectId, newPage, pagination.size);
}
From cee97e3abf512dc7bb6498d87e54fb8de02bf2cc Mon Sep 17 00:00:00 2001
From: JIWON <82360230+qowl880@users.noreply.github.com>
Date: Mon, 15 Sep 2025 12:18:10 +0900
Subject: [PATCH 2/2] =?UTF-8?q?Feature/sysone=2091=20=EB=8C=80=EC=8B=9C?=
=?UTF-8?q?=EB=B3=B4=EB=93=9C=20api=EC=97=B0=EA=B2=B0=20(#19)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat : 회의록 API 연결 (#15) (#16)
Co-authored-by: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com>
* 회의록 / 대시보드 API 연결 및 연동
---------
Co-authored-by: KIM_DEAHO <102588838+DHowor1d@users.noreply.github.com>
---
src/components/dashboard/ErrorList.jsx | 17 ++-
src/components/dashboard/PriorityTasks.jsx | 106 ++++++++++++----
.../dashboard/ProjectProgressChart.jsx | 117 ++++++++++++++----
.../dashboard/TeamProductivityTrend.jsx | 12 +-
src/components/dashboard/WeeklySchedule.jsx | 73 ++++++++---
src/components/meeting/MeetingCreate.jsx | 7 +-
src/pages/Dashboard.jsx | 58 +++++++--
7 files changed, 310 insertions(+), 80 deletions(-)
diff --git a/src/components/dashboard/ErrorList.jsx b/src/components/dashboard/ErrorList.jsx
index 6dd7000..a9583a9 100644
--- a/src/components/dashboard/ErrorList.jsx
+++ b/src/components/dashboard/ErrorList.jsx
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { fetchProjectDashboard } from '../../api/dashboardAPI';
import { Warning, Person, AccessTime } from '@mui/icons-material';
+import { useNavigate } from 'react-router-dom';
function ErrorList({ selectedProjectId }) {
const { data: dashboardData, isLoading, error } = useQuery({
@@ -9,11 +10,18 @@ function ErrorList({ selectedProjectId }) {
enabled: !!selectedProjectId,
});
+ const navigate = useNavigate();
const priorities = dashboardData?.data?.priorities?.priority || [];
-
+ const errorPriorities = dashboardData?.data?.errorPriorities || {};
+
// WARNING 우선순위만 필터링 (에러로 간주)
const errorTasks = priorities.find(p => p.priority === 'WARNING')?.priorityDataList || [];
+ // 이슈 클릭 핸들러
+ const handleIssueClick = (issueId) => {
+ navigate(`/api/issues/${issueId}`);
+ };
+
if (isLoading) {
return (
@@ -74,7 +82,7 @@ function ErrorList({ selectedProjectId }) {
-
즉시 처리 필요
+
{errorPriorities.endDate || '즉시 처리 필요'}
@@ -82,7 +90,10 @@ function ErrorList({ selectedProjectId }) {
{/* 액션 버튼 */}
-
+ handleIssueClick(task.id)}
+ >
상세보기
diff --git a/src/components/dashboard/PriorityTasks.jsx b/src/components/dashboard/PriorityTasks.jsx
index a975a69..6b2e0b7 100644
--- a/src/components/dashboard/PriorityTasks.jsx
+++ b/src/components/dashboard/PriorityTasks.jsx
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { fetchProjectDashboard } from '../../api/dashboardAPI';
+import { useNavigate } from 'react-router-dom';
+import { useState } from 'react';
function PriorityTasks({ selectedProjectId }) {
const { data: dashboardData, isLoading, error } = useQuery({
@@ -8,8 +10,12 @@ function PriorityTasks({ selectedProjectId }) {
enabled: !!selectedProjectId,
});
+ const navigate = useNavigate();
const priorities = dashboardData?.data?.priorities?.priority || [];
+ // 각 우선순위별로 표시할 항목 수를 관리하는 상태
+ const [visibleItems, setVisibleItems] = useState({});
+
// 우선순위별 색상 매핑
const getPriorityConfig = (priority) => {
switch (priority) {
@@ -17,43 +23,77 @@ function PriorityTasks({ selectedProjectId }) {
return {
dotColor: 'bg-green-500',
label: '낮음',
- bgColor: 'bg-gray-50'
+ bgColor: 'bg-green-50',
+ borderColor: 'border-green-200'
};
case 'NORMAL':
return {
dotColor: 'bg-yellow-500',
label: '보통',
- bgColor: 'bg-gray-50'
+ bgColor: 'bg-yellow-50',
+ borderColor: 'border-yellow-200'
};
case 'HIGH':
return {
dotColor: 'bg-red-500',
label: '높음',
- bgColor: 'bg-gray-50'
+ bgColor: 'bg-red-50',
+ borderColor: 'border-red-200'
};
case 'WARNING':
return {
dotColor: 'bg-red-600',
label: '긴급',
- bgColor: 'bg-gray-50'
+ bgColor: 'bg-red-100',
+ borderColor: 'border-red-300'
};
default:
return {
dotColor: 'bg-gray-500',
label: priority,
- bgColor: 'bg-gray-50'
+ bgColor: 'bg-gray-50',
+ borderColor: 'border-gray-200'
};
}
};
+ // 우선순위 정렬 함수
+ const getPriorityOrder = (priority) => {
+ switch (priority) {
+ case 'HIGH': return 1;
+ case 'NORMAL': return 2;
+ case 'LOW': return 3;
+ case 'WARNING': return 0; // WARNING은 가장 높은 우선순위
+ default: return 4;
+ }
+ };
+
+ // 이슈 클릭 핸들러
+ const handleIssueClick = (issueId) => {
+ navigate(`/api/issues/${issueId}`);
+ };
+
+ // 더 보기 클릭 핸들러
+ const handleShowMore = (priority) => {
+ setVisibleItems(prev => ({
+ ...prev,
+ [priority]: (prev[priority] || 3) + 3
+ }));
+ };
+
+ // 더 적게 보기 클릭 핸들러
+ const handleShowLess = (priority) => {
+ setVisibleItems(prev => ({
+ ...prev,
+ [priority]: 3
+ }));
+ };
+
if (isLoading) {
return (
우선순위 작업
-
- 칸반보드로 이동
-
@@ -67,9 +107,6 @@ function PriorityTasks({ selectedProjectId }) {
우선순위 작업
-
- 칸반보드로 이동
-
{!selectedProjectId ? '프로젝트를 선택해주세요' : '데이터를 불러올 수 없습니다'}
@@ -79,38 +116,45 @@ function PriorityTasks({ selectedProjectId }) {
}
// WARNING 우선순위는 제외 (에러 리스트 컴포넌트에서 표시)
- const filteredPriorities = priorities.filter(p => p.priority !== 'WARNING');
+ const filteredPriorities = priorities
+ .filter(p => p.priority !== 'WARNING')
+ .sort((a, b) => getPriorityOrder(a.priority) - getPriorityOrder(b.priority));
return (
우선순위 작업
-
- 칸반보드로 이동
-
{filteredPriorities.map((priorityGroup) => {
const config = getPriorityConfig(priorityGroup.priority);
+ const currentVisibleItems = visibleItems[priorityGroup.priority] || 3;
+ const totalItems = priorityGroup.priorityDataList.length;
+ const hasMoreItems = totalItems > currentVisibleItems;
+ const hasLessItems = currentVisibleItems > 3;
return (
- {/* 우선순위 헤더 */}
+ {/* 우선순위 헤더 - 기존처럼 배경색 없이 */}
{config.label}
- ({priorityGroup.priorityDataList.length}개)
+ ({totalItems}개)
- {/* 작업 목록 */}
+ {/* 작업 목록 - 우선순위별 배경색 적용 */}
- {priorityGroup.priorityDataList.slice(0, 3).map((task) => (
-
+ {priorityGroup.priorityDataList.slice(0, currentVisibleItems).map((task) => (
+
handleIssueClick(task.id)}
+ >
@@ -127,10 +171,22 @@ function PriorityTasks({ selectedProjectId }) {
))}
- {/* 더 많은 항목이 있을 경우 */}
- {priorityGroup.priorityDataList.length > 3 && (
-
- +{priorityGroup.priorityDataList.length - 3}개 더
+ {/* 더 보기/더 적게 보기 버튼 */}
+ {hasMoreItems && (
+
handleShowMore(priorityGroup.priority)}
+ >
+ +{totalItems - currentVisibleItems}개 더 보기
+
+ )}
+
+ {hasLessItems && (
+
handleShowLess(priorityGroup.priority)}
+ >
+ 접기
)}
@@ -151,4 +207,4 @@ function PriorityTasks({ selectedProjectId }) {
);
}
-export default PriorityTasks;
+export default PriorityTasks;
\ No newline at end of file
diff --git a/src/components/dashboard/ProjectProgressChart.jsx b/src/components/dashboard/ProjectProgressChart.jsx
index 452d160..f4dde64 100644
--- a/src/components/dashboard/ProjectProgressChart.jsx
+++ b/src/components/dashboard/ProjectProgressChart.jsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts';
+import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend, Label } from 'recharts';
import { useQuery } from '@tanstack/react-query';
import { fetchProjectDashboard } from '../../api/dashboardAPI';
@@ -14,45 +14,51 @@ function ProjectProgressChart({ selectedProjectId }) {
// 디버깅을 위한 콘솔 로그
React.useEffect(() => {
- if (projectGraph) {
- console.log('ProjectGraph 데이터:', projectGraph);
+ if (dashboardData) {
+ console.log('전체 대시보드 데이터:', dashboardData);
+ console.log('projectGraph 데이터:', projectGraph);
}
- }, [projectGraph]);
+ }, [dashboardData, projectGraph]);
const pieData = React.useMemo(() => {
if (!projectGraph) return [];
const data = [];
- if (projectGraph.todo > 0 || projectGraph.TODO > 0) {
+ // 각 상태별 데이터 처리 (대소문자 구분 없이)
+ const todo = projectGraph.todo || projectGraph.TODO || 0;
+ const progress = projectGraph.progress || projectGraph.IN_PROGRESS || projectGraph.inProgress || 0;
+ const review = projectGraph.review || projectGraph.REVIEW || 0;
+ const done = projectGraph.done || projectGraph.DONE || 0;
+
+ if (todo > 0) {
data.push({
- name: '할 일',
- value: Number(projectGraph.todo || projectGraph.TODO || 0),
+ name: '계획',
+ value: Number(todo),
color: '#6B7280'
});
}
- if (projectGraph.progress > 0 || projectGraph.IN_PROGRESS > 0) {
+ if (progress > 0) {
data.push({
name: '진행중',
- value: Number(projectGraph.progress || projectGraph.IN_PROGRESS || 0),
+ value: Number(progress),
color: '#3B82F6'
});
}
- if (projectGraph.review > 0 || projectGraph.REVIEW > 0) {
+ if (review > 0) {
data.push({
- name: '검토',
- value: Number(projectGraph.review || projectGraph.REVIEW || 0),
+ name: '리뷰중',
+ value: Number(review),
color: '#F59E0B'
});
}
-
- if (projectGraph.done > 0 || projectGraph.DONE > 0) {
+ if (done > 0) {
data.push({
name: '완료',
- value: Number(projectGraph.done || projectGraph.DONE || 0),
+ value: Number(done),
color: '#10B981'
});
}
@@ -61,6 +67,55 @@ function ProjectProgressChart({ selectedProjectId }) {
return data;
}, [projectGraph]);
+ // 완료율 계산 - 더 안전한 방식
+ const completionRate = React.useMemo(() => {
+ if (!projectGraph) return 0;
+
+ // 총 개수 계산
+ const total = Number(projectGraph.total || 0);
+ const completed = Number(projectGraph.done || projectGraph.DONE || 0);
+
+ console.log('완료율 계산:', { total, completed });
+
+ if (total === 0) return 0;
+
+ const rate = completed;
+ console.log('계산된 완료율:', rate);
+ return rate;
+ }, [projectGraph]);
+
+ // 각 항목별 퍼센트 계산
+ const itemPercentages = React.useMemo(() => {
+ if (!projectGraph) return [];
+
+ const total = Number(projectGraph.total || 0);
+ if (total === 0) return [];
+
+ return pieData.map(item => {
+ const percentage = item.value ;
+ console.log(`${item.name} 퍼센트:`, { value: item.value, total, percentage });
+ return {
+ ...item,
+ percentage
+ };
+ });
+ }, [pieData, projectGraph]);
+
+ // 중앙 라벨 컴포넌트
+ const renderCustomLabel = () => {
+ return (
+
+ {completionRate}%
+
+ );
+ };
+
if (isLoading) {
return (
@@ -87,7 +142,7 @@ function ProjectProgressChart({ selectedProjectId }) {
프로젝트 진행률
-
+
{pieData && pieData.length > 0 ? (
@@ -103,20 +158,16 @@ function ProjectProgressChart({ selectedProjectId }) {
{pieData.map((entry, index) => (
|
))}
+
[`${value}%`, name]}
+ formatter={(value, name) => [`${value}개`, name]}
contentStyle={{
backgroundColor: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px'
}}
/>
-
) : (
@@ -129,6 +180,26 @@ function ProjectProgressChart({ selectedProjectId }) {
)}
+ {/* 각 항목별 퍼센트 표시 */}
+ {itemPercentages.length > 0 && (
+
+ {itemPercentages.map((item, index) => (
+
+
+
+ {item.percentage}%
+
+
+ ))}
+
+ )}
+
{/* 총 이슈 개수 표시 */}
@@ -139,4 +210,4 @@ function ProjectProgressChart({ selectedProjectId }) {
);
}
-export default ProjectProgressChart
+export default ProjectProgressChart;
\ No newline at end of file
diff --git a/src/components/dashboard/TeamProductivityTrend.jsx b/src/components/dashboard/TeamProductivityTrend.jsx
index b61bcf6..75c3447 100644
--- a/src/components/dashboard/TeamProductivityTrend.jsx
+++ b/src/components/dashboard/TeamProductivityTrend.jsx
@@ -3,7 +3,7 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsive
import { useQuery } from '@tanstack/react-query';
import { fetchProjectDashboard } from '../../api/dashboardAPI';
-function TeamProductivityTrend({ selectedProjectId }) {
+function TeamProductivityTrend({ selectedProjectId, title = "인원별 이슈 진행률" }) {
const { data: dashboardData, isLoading, error } = useQuery({
queryKey: ['projectDashboard', selectedProjectId],
queryFn: () => fetchProjectDashboard(selectedProjectId),
@@ -25,8 +25,8 @@ function TeamProductivityTrend({ selectedProjectId }) {
if (isLoading) {
return (
-
-
인원별 이슈 진행률
+
+
{title}
@@ -36,8 +36,8 @@ function TeamProductivityTrend({ selectedProjectId }) {
if (error || !selectedProjectId) {
return (
-
-
인원별 이슈 진행률
+
+
{title}
{!selectedProjectId ? '프로젝트를 선택해주세요' : '데이터를 불러올 수 없습니다'}
@@ -47,7 +47,7 @@ function TeamProductivityTrend({ selectedProjectId }) {
return (
-
인원별 이슈 진행률
+
{title}
{barData.length > 0 ? (
diff --git a/src/components/dashboard/WeeklySchedule.jsx b/src/components/dashboard/WeeklySchedule.jsx
index d480b67..1bc0042 100644
--- a/src/components/dashboard/WeeklySchedule.jsx
+++ b/src/components/dashboard/WeeklySchedule.jsx
@@ -1,5 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { fetchProjectDashboard } from '../../api/dashboardAPI';
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
function WeeklySchedule({ selectedProjectId }) {
const { data: dashboardData, isLoading, error } = useQuery({
@@ -8,17 +10,37 @@ function WeeklySchedule({ selectedProjectId }) {
enabled: !!selectedProjectId,
});
+ const navigate = useNavigate();
const weekendIssues = dashboardData?.data?.weekendIssues?.weekendIssue || {};
const weekDays = ['월', '화', '수', '목', '금', '토', '일'];
+
+ // 현재 날짜 가져오기
const today = new Date();
const currentDay = today.getDay() === 0 ? 7 : today.getDay(); // 일요일을 7로 변환
+
+ // 이번 주의 시작일(월요일) 계산
+ const startOfWeek = new Date(today);
+ const dayOfWeek = today.getDay();
+ const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 일요일이면 6일 빼기
+ startOfWeek.setDate(today.getDate() - daysToMonday);
+
+ // 이번 주의 날짜들 생성 (1일부터 7일까지)
+ const weekDates = [];
+ for (let i = 0; i < 7; i++) {
+ const date = new Date(startOfWeek);
+ date.setDate(startOfWeek.getDate() + i);
+ weekDates.push(date.getDate());
+ }
+
+ // 선택된 날짜 상태 관리
+ const [selectedDay, setSelectedDay] = useState(currentDay);
// 상태별 색상 매핑
const getStatusColor = (status) => {
switch (status) {
case 'TODO': return 'bg-gray-400';
- case 'PROGRESS': return 'bg-blue-400';
+ case 'IN_PROGRESS': return 'bg-blue-400';
case 'REVIEW': return 'bg-orange-400';
case 'DONE': return 'bg-green-400';
default: return 'bg-gray-400';
@@ -28,13 +50,23 @@ function WeeklySchedule({ selectedProjectId }) {
const getStatusText = (status) => {
switch (status) {
case 'TODO': return '할 일';
- case 'PROGRESS': return '진행중';
+ case 'IN_PROGRESS': return '진행중';
case 'REVIEW': return '검토';
case 'DONE': return '완료';
default: return status;
}
};
+ // 날짜 클릭 핸들러
+ const handleDateClick = (dayNumber) => {
+ setSelectedDay(dayNumber);
+ };
+
+ // 이슈 클릭 핸들러
+ const handleIssueClick = (issueId) => {
+ navigate(`/api/issues/${issueId}`);
+ };
+
if (isLoading) {
return (
@@ -67,16 +99,23 @@ function WeeklySchedule({ selectedProjectId }) {
const dayNumber = index + 1;
const dayIssues = weekendIssues[dayNumber] || [];
const isToday = dayNumber === currentDay;
+ const isSelected = dayNumber === selectedDay;
+ const dateNumber = weekDates[index];
return (
{day}
-
- {dayNumber}
+
handleDateClick(dayNumber)}
+ >
+ {dateNumber}
{dayIssues.length > 0 && (
{dayIssues.length}
@@ -88,16 +127,20 @@ function WeeklySchedule({ selectedProjectId }) {
})}
- {/* 오늘 일정 */}
+ {/* 선택된 날짜의 일정 */}
- 오늘 일정 ({weekDays[currentDay - 1]})
+ {selectedDay === currentDay ? '오늘 일정' : `${weekDays[selectedDay - 1]}요일 일정`} ({weekDays[selectedDay - 1]})
- {weekendIssues[currentDay] && weekendIssues[currentDay].length > 0 ? (
+ {weekendIssues[selectedDay] && weekendIssues[selectedDay].length > 0 ? (
- {weekendIssues[currentDay].map((issue) => (
-
+ {weekendIssues[selectedDay].map((issue) => (
+
handleIssueClick(issue.id)}
+ >
{issue.title}
@@ -113,7 +156,7 @@ function WeeklySchedule({ selectedProjectId }) {
) : (
- 오늘 예정된 이슈가 없습니다
+ {selectedDay === currentDay ? '오늘 예정된 이슈가 없습니다' : '해당 날짜에 예정된 이슈가 없습니다'}
)}
@@ -129,4 +172,4 @@ function WeeklySchedule({ selectedProjectId }) {
);
}
-export default WeeklySchedule
+export default WeeklySchedule;
\ No newline at end of file
diff --git a/src/components/meeting/MeetingCreate.jsx b/src/components/meeting/MeetingCreate.jsx
index 9cda746..4b9a620 100644
--- a/src/components/meeting/MeetingCreate.jsx
+++ b/src/components/meeting/MeetingCreate.jsx
@@ -14,7 +14,12 @@ function MeetingCreate({ onBack, onSave, meeting = null, isEditing = false, proj
participants: meeting?.participants || [],
content: meeting?.description || meeting?.memo || meeting?.content || "",
type: meeting?.type || "MEETING",
- progressDate: meeting?.progressDate || new Date().toISOString().slice(0, -1) + '+09:00',
+ progressDate: meeting?.progressDate || (() => {
+ // 현재 시간에 18시간 추가
+ const now = new Date();
+ now.setHours(now.getHours() + 18);
+ return now.toISOString().slice(0, -1) + '+09:00'; // 한국 시간대
+ })(),
});
console.log('MeetingCreate에서 받은 projectId:', projectId);
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index 5205b7a..b103ad5 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -1,3 +1,4 @@
+import React, { useEffect } from "react";
import KPICard from "../components/dashboard/KPICard";
import ProjectProgressChart from "../components/dashboard/ProjectProgressChart";
import WeeklySchedule from "../components/dashboard/WeeklySchedule";
@@ -8,15 +9,49 @@ import ProjectList from "../components/dashboard/ProjectList";
import useProjectStore from "../store/projectStore";
import { Source, Warning } from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { fetchDashboardProjects, fetchProjectDashboard } from "../api/dashboardAPI";
function Dashboard() {
const navigate = useNavigate();
const { selectedProjectId } = useProjectStore();
+ // fetchDashboardProjects API에서 데이터 가져오기
+ const { data: dashboardData } = useQuery({
+ queryKey: ['dashboardProjects'],
+ queryFn: fetchDashboardProjects,
+ });
+
+ // fetchProjectDashboard API에서 프로젝트별 데이터 가져오기
+ const { data: projectDashboardData } = useQuery({
+ queryKey: ['projectDashboard', selectedProjectId],
+ queryFn: () => fetchProjectDashboard(selectedProjectId),
+ enabled: !!selectedProjectId,
+ });
+
+ // 서버 데이터에서 KPI 값 추출
+ const projectCount = dashboardData?.data?.projectCount || 0;
+ const issueCount = dashboardData?.data?.issueCount || 0;
+
+ // 사용자 역할 확인
+ const userRole = projectDashboardData?.data?.role || 'User';
+ const isPM = userRole === 'PM';
+ const isUser = userRole === 'User';
+
+ // 디버깅용 로그
+ useEffect(() => {
+ if (projectDashboardData) {
+ console.log('프로젝트 대시보드 데이터:', projectDashboardData);
+ console.log('사용자 역할:', userRole);
+ console.log('PM 여부:', isPM);
+ console.log('User 여부:', isUser);
+ }
+ }, [projectDashboardData, userRole, isPM, isUser]);
+
const kpiData = [
{
title: "진행 중인 프로젝트",
- value: "12",
+ value: projectCount.toString(),
icon:
,
trend: "up",
path: "/projects",
@@ -24,7 +59,7 @@ function Dashboard() {
},
{
title: "이번 주 마감 작업",
- value: "8",
+ value: issueCount.toString(),
icon:
,
trend: "down",
trendValue: "-3 from last week",
@@ -36,11 +71,14 @@ function Dashboard() {
- {/* KPI 카드들 */}
-
-
+
+ {/* 에러 발생 창 - PM일 경우에만 표시 */}
+ {isPM && (
+
+ )}
+
@@ -60,7 +98,13 @@ function Dashboard() {
/>
))}
-
+
+ {/* 인원별 이슈 진행률 - 역할에 따라 제목 변경 */}
+
+
@@ -75,4 +119,4 @@ function Dashboard() {
);
}
-export default Dashboard;
+export default Dashboard;
\ No newline at end of file