diff --git a/src/api/chatApi.js b/src/api/chatApi.js index 1ca7459..642de59 100644 --- a/src/api/chatApi.js +++ b/src/api/chatApi.js @@ -1,38 +1,50 @@ import api from './client'; +// 공통 API 호출 함수 +const apiCall = (method, url, data = null, params = null) => { + return api({ + method, + url, + data, + params, + }); +}; + // --- ChatRoomController API --- // 모든 채팅방 목록 가져오기 -export const fetchChatRooms = () => api.get('/chat-room/all'); +export const fetchChatRooms = () => apiCall('get', '/chat-room/all'); // 특정 채팅방의 메시지 내역 가져오기 (페이지네이션) export const fetchMessages = (chatRoomId, page = 0, size = 50) => - api.get(`/chat-room/${chatRoomId}/messages`, { params: { page, size } }); + apiCall('get', `/chat-room/${chatRoomId}/messages`, null, { page, size }); // 채팅방 생성 export const createChatRoom = (memberIdList, roomName, type) => - api.post('/chat-room/create', { memberIdList, name: roomName, type }); + apiCall('post', '/chat-room/create', { memberIdList, name: roomName, type }); // 채팅방에 사용자 초대 export const inviteToChatRoom = (chatRoomId, memberIdList) => - api.post(`/chat-room/${chatRoomId}/invite`, { memberIdList }); + apiCall('post', `/chat-room/${chatRoomId}/invite`, { memberIdList }); // 이미 존재하는 채팅방에 멤버 추가 export const addMembersToExistingChatRoom = (chatRoomId, memberIdList) => - api.post(`/chat-room/${chatRoomId}/add-members`, { chatRoomId, memberIdList }); + apiCall('post', `/chat-room/${chatRoomId}/add-members`, { chatRoomId, memberIdList }); // 메시지 삭제 -export const deleteMessage = (messageId) => api.delete(`/chat-room/messages/${messageId}`); +export const deleteMessage = (messageId) => + apiCall('delete', `/chat-room/messages/${messageId}`); // 채팅방 퇴장 -export const leaveChatRoom = (chatRoomId) => api.delete(`/chat-room/delete/${chatRoomId}`); +export const leaveChatRoom = (chatRoomId) => + apiCall('delete', `/chat-room/delete/${chatRoomId}`); // 전체 안 읽은 메시지 개수 조회 -export const fetchTotalUnreadCount = () => api.get('/chat-room/total-unread-count'); +export const fetchTotalUnreadCount = () => apiCall('get', '/chat-room/total-unread-count'); // 특정 채팅방 메시지 모두 읽음 처리 export const markAllAsRead = (chatRoomId, lastReadMessageId) => - api.get(`/chat-room/${chatRoomId}/mark-all-as-read`, { params: { lastReadMessageId } }); + apiCall('get', `/chat-room/${chatRoomId}/mark-all-as-read`, null, { lastReadMessageId }); // --- MemberProfileController API --- diff --git a/src/api/socketService.js b/src/api/socketService.js index 3a5c5e0..13abeb0 100644 --- a/src/api/socketService.js +++ b/src/api/socketService.js @@ -40,6 +40,10 @@ const performSubscribe = (destination, callback) => { return subscription; }; +const BASE_URL = window.location.hostname === "localhost" + ? "http://localhost:8081/ws/chat" + : "https://api.sysonetaskmanager.store/ws/chat"; + // WebSocket 연결 export const connectWebSocket = (onConnected) => { if (stompClient && stompClient.active) { @@ -50,7 +54,7 @@ export const connectWebSocket = (onConnected) => { } stompClient = new Client({ - webSocketFactory: () => new SockJS('http://localhost:8081/ws/chat'), // 백엔드 포트 8081로 수정 + webSocketFactory: () => new SockJS(BASE_URL), reconnectDelay: 5000, heartbeatIncoming: 4000, heartbeatOutgoing: 4000, @@ -85,22 +89,40 @@ export const disconnectWebSocket = () => { } }; -export const subscribe = (destination, callback) => { - return new Promise((resolve, reject) => { - if (isConnected && stompClient && stompClient.active) { - // 이미 연결되어 있으면 바로 구독 - try { - const subscription = performSubscribe(destination, callback); - resolve(subscription); - } catch (error) { - reject(error); - } - } else { - // 연결이 안 되어 있으면 큐에 저장 - console.warn('STOMP client is not connected. Queueing subscription for ', destination); - subscriptionQueue.push({ destination, callback, resolve, reject }); - } +// 공통 구독/해제 유틸 함수 +const manageSubscription = (action, destination, callback) => { + if (action === 'subscribe') { + if (subscriptions.has(destination)) { + subscriptions.get(destination).unsubscribe(); + subscriptions.delete(destination); + } + const subscription = stompClient.subscribe(destination, (message) => { + callback(JSON.parse(message.body)); }); + subscriptions.set(destination, subscription); + return subscription; + } else if (action === 'unsubscribe') { + if (subscriptions.has(destination)) { + subscriptions.get(destination).unsubscribe(); + subscriptions.delete(destination); + } + } +}; + +export const subscribe = (destination, callback) => { + return new Promise((resolve, reject) => { + if (isConnected && stompClient && stompClient.active) { + try { + const subscription = manageSubscription('subscribe', destination, callback); + resolve(subscription); + } catch (error) { + reject(error); + } + } else { + console.warn('STOMP client is not connected. Queueing subscription for ', destination); + subscriptionQueue.push({ destination, callback, resolve, reject }); + } + }); } // 특정 채팅방 토픽 구독 @@ -115,10 +137,7 @@ export const subscribeToUserQueue = (queueName, onNotificationReceived) => { // 구독 해지 export const unsubscribe = (destination) => { - if (subscriptions.has(destination)) { - subscriptions.get(destination).unsubscribe(); - subscriptions.delete(destination); - } + manageSubscription('unsubscribe', destination); } // 메시지 전송 (발행) @@ -131,4 +150,20 @@ export const sendMessage = (chatMessage) => { } else { console.error('Cannot send message, STOMP client is not connected.'); } +}; + +// 초대 메시지 전송 +export const sendInviteMessage = (chatRoomId, memberId, memberIdList) => { + if (stompClient && stompClient.active) { + stompClient.publish({ + destination: '/app/invite', + body: JSON.stringify({ + chatRoomId, + memberId, + memberIdList, + }), + }); + } else { + console.error('Cannot send invite message, STOMP client is not connected.'); + } }; \ No newline at end of file diff --git a/src/components/common/chat/ChatRoom.jsx b/src/components/common/chat/ChatRoom.jsx index 1ba3365..ae51476 100644 --- a/src/components/common/chat/ChatRoom.jsx +++ b/src/components/common/chat/ChatRoom.jsx @@ -1,6 +1,9 @@ -import { useEffect, useRef} from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Send } from '@mui/icons-material'; import useChatStore from '../../../store/chatStore'; +import { sendInviteMessage } from '../../../api/socketService'; +import { useDebounce } from '../../../hooks/useDebounce'; +import * as chatApi from '../../../api/chatApi'; const ChatRoom = ({ selectedChat, @@ -9,6 +12,12 @@ const ChatRoom = ({ onSendMessage }) => { const messagesEndRef = useRef(null); + const [inviteModalOpen, setInviteModalOpen] = useState(false); + const [employeeSearchTerm, setEmployeeSearchTerm] = useState(''); + const [allEmployees, setAllEmployees] = useState([]); + const [selectedEmployees, setSelectedEmployees] = useState([]); + const [inviteSuccessMessage, setInviteSuccessMessage] = useState(''); + const debouncedSearchTerm = useDebounce(employeeSearchTerm, 500); const { getMessages, connectWebSocket, disconnectWebSocket, markAsRead, currentUser } = useChatStore(); const messages = [...getMessages(selectedChat?.id || 0)].sort((a, b) => { @@ -46,8 +55,97 @@ const ChatRoom = ({ }); }; + const handleToggleEmployee = (employee) => { + setSelectedEmployees((prev) => + prev.some((e) => e.id === employee.id) + ? prev.filter((e) => e.id !== employee.id) + : [...prev, employee] + ); + }; + + const handleInviteMembers = async () => { + if (selectedEmployees.length === 0) { + alert('초대할 사원을 선택하세요.'); + return; + } + + try { + sendInviteMessage( + selectedChat.id, + currentUser.id, + selectedEmployees.map((e) => e.id) + ); + setInviteSuccessMessage('초대가 완료되었습니다.'); + setTimeout(() => setInviteSuccessMessage(''), 3000); // 3초 후 메시지 제거 + setInviteModalOpen(false); + } catch (error) { + console.error('Failed to invite members:', error); + alert('초대에 실패했습니다. 다시 시도해주세요.'); + } + }; + + useEffect(() => { + const fetchEmployees = async () => { + try { + const response = debouncedSearchTerm + ? await chatApi.searchMembers(debouncedSearchTerm) + : await chatApi.fetchAllMembers(); + setAllEmployees(response.data.data); + } catch (error) { + console.error('Failed to fetch employees:', error); + } + }; + + fetchEmployees(); + }, [debouncedSearchTerm]); + + // 메시지 렌더링 컴포넌트 분리 + const Message = ({ msg, isMine, formatTime }) => ( +
+
+ {!isMine && msg.type !== 'DELETED' && msg.type !== 'SYSTEM' && ( +

{msg.senderName}

+ )} + {msg.type === 'DELETED' ? ( +

삭제된 메시지입니다.

+ ) : ( +

{msg.content}

+ )} +
+ + {formatTime(msg.createdAt)} + + {isMine && msg.readCount > 0 && msg.type !== 'DELETED' && msg.type !== 'SYSTEM' && ( + {msg.readCount} + )} +
+
+
+ ); + return (
+ {inviteSuccessMessage && ( +
+
+ {inviteSuccessMessage} +
+
+ )} {/* 메시지 영역 */}
@@ -58,36 +156,7 @@ const ChatRoom = ({ ) : ( messages.map((msg) => { const isMine = msg.senderId === currentUser.id; - return ( -
-
- {!isMine && msg.type !== 'DELETED' && msg.type !== 'SYSTEM' && ( -

{msg.senderName}

- )} - {msg.type === 'DELETED' ? ( -

삭제된 메시지입니다.

- ) : ( -

{msg.content}

- )} -
- - {formatTime(msg.createdAt)} - - {isMine && msg.readCount > 0 && msg.type !== 'DELETED' && ( - {msg.readCount} - )} -
-
-
- ) + return ; }) )}
@@ -98,6 +167,27 @@ const ChatRoom = ({
+ {/* 초대 아이콘 */} +
+ + {/* 초대 모달 */} + {inviteModalOpen && ( +
+
+

사원 초대

+ setEmployeeSearchTerm(e.target.value)} + placeholder="사원을 검색하세요..." + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 text-sm mb-4" + /> +
    + {allEmployees.map((employee) => ( +
  • + e.id === employee.id)} + onChange={() => handleToggleEmployee(employee)} + className="w-4 h-4 text-emerald-500 border-gray-300 rounded focus:ring-emerald-500" + /> + {employee.name} +
  • + ))} +
+
+ + +
+
+
+ )}
); }; diff --git a/src/store/chatStore.js b/src/store/chatStore.js index 712e7c9..28afa7b 100644 --- a/src/store/chatStore.js +++ b/src/store/chatStore.js @@ -5,6 +5,20 @@ import { Stomp } from '@stomp/stompjs'; let stompClient = null; let subscriptions = {}; +// 공통 상태 업데이트 유틸 함수 +const updateStateArray = (stateArray, newArray, key = 'id') => { + const updatedArray = newArray.map((newItem) => { + const existingItem = stateArray.find((item) => item[key] === newItem[key]); + return existingItem ? { ...existingItem, ...newItem } : newItem; + }); + + const mergedArray = stateArray.filter( + (item) => !newArray.some((newItem) => newItem[key] === item[key]) + ); + + return [...updatedArray, ...mergedArray]; +}; + const useChatStore = create((set, get) => ({ chatRooms: [], messages: {}, @@ -19,23 +33,11 @@ const useChatStore = create((set, get) => ({ }, setChatRooms: (rooms) => set((state) => { - const prevChatRooms = Array.isArray(state.chatRooms) ? state.chatRooms : []; if (!Array.isArray(rooms)) { console.error('setChatRooms: rooms is not an array', rooms); - return state; // 상태를 변경하지 않음 + return state; } - - const updatedRooms = rooms.map((newRoom) => { - const existingRoom = prevChatRooms.find((room) => room.id === newRoom.id); - return existingRoom ? { ...existingRoom, ...newRoom } : newRoom; - }); - - // Add any rooms that are in the current state but not in the new rooms - const mergedRooms = prevChatRooms.filter( - (room) => !rooms.some((newRoom) => newRoom.id === room.id) - ); - - return { chatRooms: [...updatedRooms, ...mergedRooms] }; + return { chatRooms: updateStateArray(state.chatRooms, rooms) }; }), setMessages: (roomId, messages) => set((state) => ({