Добавлена функция создания новых чатов

- Добавлены API методы в api.ts:
  * getChats, getChat, createChat
  * getChatMessages, sendMessage, sendMessageWithFiles
  * updateMessage, deleteMessage
  * markChatAsRead, togglePinChat, toggleChatNotifications

- Создан компонент NewChatModal:
  * Поиск и выбор сотрудников
  * Поддержка приватных и групповых чатов
  * Автоматическое название для групп

- Интегрирована кнопка 'Новый чат' в Chatscope UI
- При создании чата автоматически открывается диалог
This commit is contained in:
igor_nadein 2026-02-25 12:30:16 +03:00
parent e328e31d32
commit eb928f8fb1
3 changed files with 475 additions and 0 deletions

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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();