Добавлена функция создания новых чатов
- Добавлены API методы в api.ts: * getChats, getChat, createChat * getChatMessages, sendMessage, sendMessageWithFiles * updateMessage, deleteMessage * markChatAsRead, togglePinChat, toggleChatNotifications - Создан компонент NewChatModal: * Поиск и выбор сотрудников * Поддержка приватных и групповых чатов * Автоматическое название для групп - Интегрирована кнопка 'Новый чат' в Chatscope UI - При создании чата автоматически открывается диалог
This commit is contained in:
parent
e328e31d32
commit
eb928f8fb1
@ -20,6 +20,7 @@ import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css';
|
||||
import { AppShell } from '@/components/AppShell';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { useUser } from '@/contexts/UserContext';
|
||||
import NewChatModal from '@/components/NewChatModal';
|
||||
import type { Chat as ChatType, Message as MessageType } from '@/types/api';
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "https://corp.robotail.pro";
|
||||
@ -96,6 +97,7 @@ export default function ChatscapeMessenger() {
|
||||
const [sending, setSending] = useState(false);
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [typingTimeout, setTypingTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
const [isNewChatModalOpen, setIsNewChatModalOpen] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
@ -122,6 +124,21 @@ export default function ChatscapeMessenger() {
|
||||
loadChats();
|
||||
}, []);
|
||||
|
||||
// Обработчик создания нового чата
|
||||
const handleChatCreated = async (chatId: number) => {
|
||||
try {
|
||||
// Перезагружаем список чатов
|
||||
const response = await apiClient.getChats();
|
||||
const chatsList = response.results || [];
|
||||
setChats(chatsList);
|
||||
|
||||
// Открываем новый чат
|
||||
setSelectedChatId(chatId);
|
||||
} catch (error) {
|
||||
console.error('Error reloading chats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка сообщений выбранного чата
|
||||
useEffect(() => {
|
||||
if (!selectedChatId) return;
|
||||
@ -282,6 +299,29 @@ export default function ChatscapeMessenger() {
|
||||
<MainContainer responsive style={{ height: 'calc(100% - 60px)' }}>
|
||||
{/* Боковая панель со списком чатов */}
|
||||
<Sidebar position="left" scrollable={false}>
|
||||
{/* Кнопка Новый чат */}
|
||||
<div style={{ padding: '12px', borderBottom: '1px solid #e5e7eb' }}>
|
||||
<button
|
||||
onClick={() => setIsNewChatModalOpen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '500',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#2563eb'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3b82f6'}
|
||||
>
|
||||
+ Новый чат
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Search
|
||||
placeholder="Поиск чатов..."
|
||||
value={search}
|
||||
@ -421,6 +461,13 @@ export default function ChatscapeMessenger() {
|
||||
)}
|
||||
</MainContainer>
|
||||
</div>
|
||||
|
||||
{/* Модальное окно для создания нового чата */}
|
||||
<NewChatModal
|
||||
isOpen={isNewChatModalOpen}
|
||||
onClose={() => setIsNewChatModalOpen(false)}
|
||||
onChatCreated={handleChatCreated}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
265
frontend/src/components/NewChatModal.tsx
Normal file
265
frontend/src/components/NewChatModal.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Search, Check, Users } from "lucide-react";
|
||||
import apiClient from "@/lib/api";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
middle_name?: string;
|
||||
avatar?: string;
|
||||
position?: string;
|
||||
department?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface NewChatModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onChatCreated: (chatId: number) => void;
|
||||
}
|
||||
|
||||
export default function NewChatModal({ isOpen, onClose, onChatCreated }: NewChatModalProps) {
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [selectedEmployees, setSelectedEmployees] = useState<number[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [chatName, setChatName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Загрузка сотрудников
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadEmployees();
|
||||
}
|
||||
}, [isOpen, searchQuery]);
|
||||
|
||||
const loadEmployees = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.getEmployees({ search: searchQuery });
|
||||
setEmployees(response.results || response);
|
||||
} catch (error) {
|
||||
console.error("Failed to load employees:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEmployee = (employeeId: number) => {
|
||||
setSelectedEmployees(prev =>
|
||||
prev.includes(employeeId)
|
||||
? prev.filter(id => id !== employeeId)
|
||||
: [...prev, employeeId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateChat = async () => {
|
||||
if (selectedEmployees.length === 0) {
|
||||
alert("Выберите хотя бы одного участника");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
// Определяем тип чата (приватный или групповой)
|
||||
const isGroup = selectedEmployees.length > 1;
|
||||
const chatType = isGroup ? "group" : "private";
|
||||
|
||||
const chatData: any = {
|
||||
type: chatType,
|
||||
participants: selectedEmployees,
|
||||
};
|
||||
|
||||
// Для групповых чатов добавляем название
|
||||
if (isGroup && chatName.trim()) {
|
||||
chatData.name = chatName.trim();
|
||||
}
|
||||
|
||||
const newChat = await apiClient.createChat(chatData);
|
||||
|
||||
// Уведомляем родительский компонент о новом чате
|
||||
onChatCreated(newChat.id);
|
||||
|
||||
// Сбрасываем форму
|
||||
setSelectedEmployees([]);
|
||||
setChatName("");
|
||||
setSearchQuery("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create chat:", error);
|
||||
alert("Не удалось создать чат. Попробуйте еще раз.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEmployeeFullName = (emp: Employee) => {
|
||||
return `${emp.last_name} ${emp.first_name} ${emp.middle_name || ""}`.trim();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isGroup = selectedEmployees.length > 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-gray-600" />
|
||||
<h2 className="text-xl font-semibold">Новый чат</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat Name (for groups) */}
|
||||
{isGroup && (
|
||||
<div className="p-4 border-b">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название группы (необязательно)"
|
||||
value={chatName}
|
||||
onChange={(e) => setChatName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Employees */}
|
||||
{selectedEmployees.length > 0 && (
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
Выбрано: {selectedEmployees.length}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{employees
|
||||
.filter(emp => selectedEmployees.includes(emp.id))
|
||||
.map(emp => (
|
||||
<div
|
||||
key={emp.id}
|
||||
className="flex items-center gap-2 bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
<span>{getEmployeeFullName(emp)}</span>
|
||||
<button
|
||||
onClick={() => toggleEmployee(emp.id)}
|
||||
className="hover:bg-blue-200 rounded-full p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск сотрудников..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employee List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Загрузка сотрудников...
|
||||
</div>
|
||||
) : employees.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Сотрудники не найдены
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{employees.map((emp) => {
|
||||
const isSelected = selectedEmployees.includes(emp.id);
|
||||
return (
|
||||
<button
|
||||
key={emp.id}
|
||||
onClick={() => toggleEmployee(emp.id)}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg transition-colors
|
||||
${isSelected
|
||||
? "bg-blue-50 border-2 border-blue-500"
|
||||
: "bg-gray-50 hover:bg-gray-100 border-2 border-transparent"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{emp.avatar ? (
|
||||
<img
|
||||
src={emp.avatar}
|
||||
alt={getEmployeeFullName(emp)}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-500 flex items-center justify-center text-white font-semibold">
|
||||
{emp.first_name[0]}{emp.last_name[0]}
|
||||
</div>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1 -right-1 bg-blue-500 rounded-full p-0.5">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Employee Info */}
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-gray-900">
|
||||
{getEmployeeFullName(emp)}
|
||||
</div>
|
||||
{emp.position && (
|
||||
<div className="text-sm text-gray-600">{emp.position}</div>
|
||||
)}
|
||||
{emp.department && (
|
||||
<div className="text-xs text-gray-500">{emp.department.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={creating}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateChat}
|
||||
disabled={selectedEmployees.length === 0 || creating}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating ? "Создание..." : "Создать чат"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -768,6 +768,169 @@ class ApiClient {
|
||||
body: JSON.stringify({ emoji }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============= Communications (Chats & Messages) API =============
|
||||
|
||||
/**
|
||||
* Получить список чатов пользователя
|
||||
*/
|
||||
async getChats(params?: { search?: string; page?: number }): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
|
||||
const query = queryParams.toString();
|
||||
return this.request(`/api/v1/communications/chats/${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить детали чата по ID
|
||||
*/
|
||||
async getChat(chatId: number): Promise<any> {
|
||||
return this.request(`/api/v1/communications/chats/${chatId}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать новый чат
|
||||
* type: 'private' | 'group' | 'department' | 'general'
|
||||
*/
|
||||
async createChat(data: {
|
||||
type: string;
|
||||
participants?: number[];
|
||||
name?: string;
|
||||
description?: string;
|
||||
department?: number;
|
||||
include_all_employees?: boolean;
|
||||
}): Promise<any> {
|
||||
return this.request('/api/v1/communications/chats/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить сообщения чата с пагинацией
|
||||
*/
|
||||
async getChatMessages(
|
||||
chatId: number,
|
||||
params?: {
|
||||
limit?: number;
|
||||
before_id?: number;
|
||||
after_id?: number;
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
): Promise<any> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.before_id) queryParams.append('before_id', params.before_id.toString());
|
||||
if (params?.after_id) queryParams.append('after_id', params.after_id.toString());
|
||||
if (params?.before) queryParams.append('before', params.before);
|
||||
if (params?.after) queryParams.append('after', params.after);
|
||||
|
||||
const query = queryParams.toString();
|
||||
return this.request(`/api/v1/communications/chats/${chatId}/messages/${query ? '?' + query : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить текстовое сообщение
|
||||
*/
|
||||
async sendMessage(
|
||||
chatId: number,
|
||||
content: string,
|
||||
replyTo?: number
|
||||
): Promise<any> {
|
||||
return this.request('/api/v1/communications/messages/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сообщение с файлами
|
||||
*/
|
||||
async sendMessageWithFiles(
|
||||
chatId: number,
|
||||
content: string,
|
||||
files: File[],
|
||||
replyTo?: number
|
||||
): Promise<any> {
|
||||
const formData = new FormData();
|
||||
formData.append('chat_id', chatId.toString());
|
||||
formData.append('content', content);
|
||||
if (replyTo) formData.append('reply_to', replyTo.toString());
|
||||
|
||||
// Добавляем файлы с префиксом file_
|
||||
files.forEach((file, index) => {
|
||||
formData.append(`file_${index}`, file);
|
||||
});
|
||||
|
||||
const token = this.getToken();
|
||||
const response = await fetch('/api/v1/communications/messages/upload/', {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактировать сообщение
|
||||
*/
|
||||
async updateMessage(messageId: number, content: string): Promise<any> {
|
||||
return this.request(`/api/v1/communications/messages/${messageId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить сообщение
|
||||
*/
|
||||
async deleteMessage(messageId: number): Promise<void> {
|
||||
await this.request(`/api/v1/communications/messages/${messageId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Пометить чат как прочитанный
|
||||
*/
|
||||
async markChatAsRead(chatId: number, lastReadMessageId?: number): Promise<any> {
|
||||
return this.request(`/api/v1/communications/chats/${chatId}/mark_read/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
last_read_message_id: lastReadMessageId
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрепить/открепить чат
|
||||
*/
|
||||
async togglePinChat(chatId: number): Promise<any> {
|
||||
return this.request(`/api/v1/communications/chats/${chatId}/pin/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Включить/выключить уведомления для чата
|
||||
*/
|
||||
async toggleChatNotifications(chatId: number): Promise<any> {
|
||||
return this.request(`/api/v1/communications/chats/${chatId}/notifications/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user