All checks were successful
continuous-integration/drone/push Build is passing
280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import { ArrowLeft, Send, Clock, AlertCircle, CheckCircle } from 'lucide-react';
|
||
import axios from 'axios';
|
||
import { API_URL } from '../config';
|
||
import { notify } from './NotificationSystem';
|
||
|
||
export default function TicketChat({ ticket, token, user, onBack }) {
|
||
const [messages, setMessages] = useState(ticket.messages || []);
|
||
const [newMessage, setNewMessage] = useState('');
|
||
const [currentTicket, setCurrentTicket] = useState(ticket);
|
||
const [loading, setLoading] = useState(false);
|
||
const [previousMessagesCount, setPreviousMessagesCount] = useState(ticket.messages?.length || 0);
|
||
const messagesEndRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
const interval = setInterval(loadTicket, 3000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
const loadTicket = async () => {
|
||
try {
|
||
const { data } = await axios.get(`${API_URL}/api/tickets/${ticket.id}`, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
|
||
// Проверяем новые сообщения
|
||
if (data.messages.length > previousMessagesCount) {
|
||
const newMessagesCount = data.messages.length - previousMessagesCount;
|
||
const lastMessage = data.messages[data.messages.length - 1];
|
||
|
||
// Уведомляем только если сообщение не от текущего пользователя
|
||
if (lastMessage.author !== user.username && lastMessage.author !== 'system') {
|
||
notify('info', 'Новое сообщение', `${lastMessage.author}: ${lastMessage.text.substring(0, 50)}${lastMessage.text.length > 50 ? '...' : ''}`);
|
||
}
|
||
|
||
setPreviousMessagesCount(data.messages.length);
|
||
}
|
||
|
||
// Проверяем изменение статуса
|
||
if (data.status !== currentTicket.status) {
|
||
const statusNames = {
|
||
'pending': 'На рассмотрении',
|
||
'in_progress': 'В работе',
|
||
'closed': 'Закрыт'
|
||
};
|
||
notify('info', 'Статус изменён', `Тикет #${ticket.id}: ${statusNames[data.status]}`);
|
||
}
|
||
|
||
setCurrentTicket(data);
|
||
setMessages(data.messages || []);
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки тикета:', error);
|
||
}
|
||
};
|
||
|
||
const sendMessage = async (e) => {
|
||
e.preventDefault();
|
||
if (!newMessage.trim() || loading) return;
|
||
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await axios.post(
|
||
`${API_URL}/api/tickets/${ticket.id}/message`,
|
||
{ text: newMessage.trim() },
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
setMessages(data.ticket.messages);
|
||
setCurrentTicket(data.ticket);
|
||
setPreviousMessagesCount(data.ticket.messages.length);
|
||
setNewMessage('');
|
||
notify('success', 'Сообщение отправлено', 'Ваше сообщение успешно отправлено');
|
||
} catch (error) {
|
||
console.error('Ошибка отправки сообщения:', error);
|
||
notify('error', 'Ошибка отправки', error.response?.data?.detail || 'Не удалось отправить сообщение');
|
||
alert(error.response?.data?.detail || 'Ошибка отправки сообщения');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const changeStatus = async (newStatus) => {
|
||
const statusNames = {
|
||
'pending': 'На рассмотрении',
|
||
'in_progress': 'В работе',
|
||
'closed': 'Закрыт'
|
||
};
|
||
|
||
try {
|
||
const { data } = await axios.put(
|
||
`${API_URL}/api/tickets/${ticket.id}/status`,
|
||
{ status: newStatus },
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
setCurrentTicket(data.ticket);
|
||
setMessages(data.ticket.messages);
|
||
setPreviousMessagesCount(data.ticket.messages.length);
|
||
notify('success', 'Статус изменён', `Тикет #${ticket.id} теперь: ${statusNames[newStatus]}`);
|
||
} catch (error) {
|
||
console.error('Ошибка изменения статуса:', error);
|
||
notify('error', 'Ошибка изменения статуса', error.response?.data?.detail || 'Не удалось изменить статус');
|
||
alert(error.response?.data?.detail || 'Ошибка изменения статуса');
|
||
}
|
||
};
|
||
|
||
const getStatusIcon = (status) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return <Clock className="w-5 h-5 text-yellow-500" />;
|
||
case 'in_progress':
|
||
return <AlertCircle className="w-5 h-5 text-blue-500" />;
|
||
case 'closed':
|
||
return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||
default:
|
||
return <Clock className="w-5 h-5" />;
|
||
}
|
||
};
|
||
|
||
const getStatusText = (status) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return 'На рассмотрении';
|
||
case 'in_progress':
|
||
return 'В работе';
|
||
case 'closed':
|
||
return 'Закрыт';
|
||
default:
|
||
return status;
|
||
}
|
||
};
|
||
|
||
const getStatusColor = (status) => {
|
||
switch (status) {
|
||
case 'pending':
|
||
return 'bg-yellow-500/20 text-yellow-500 border-yellow-500/50';
|
||
case 'in_progress':
|
||
return 'bg-blue-500/20 text-blue-500 border-blue-500/50';
|
||
case 'closed':
|
||
return 'bg-green-500/20 text-green-500 border-green-500/50';
|
||
default:
|
||
return 'bg-gray-500/20 text-gray-500 border-gray-500/50';
|
||
}
|
||
};
|
||
|
||
const canChangeStatus = user.role === 'owner' || user.role === 'admin' || user.role === 'support';
|
||
|
||
return (
|
||
<div className="h-full flex flex-col bg-dark-900">
|
||
{/* Header */}
|
||
<div className="bg-dark-800 border-gray-700 border-b p-4">
|
||
<div className="flex items-center gap-4 mb-3">
|
||
<button
|
||
onClick={onBack}
|
||
className="hover:bg-dark-700 p-2 rounded-lg transition"
|
||
>
|
||
<ArrowLeft className="w-5 h-5" />
|
||
</button>
|
||
<div className="flex-1">
|
||
<h2 className="text-lg font-bold">{currentTicket.title}</h2>
|
||
<p className="text-sm text-gray-400">
|
||
Автор: {currentTicket.author} • Создан: {new Date(currentTicket.created_at).toLocaleString('ru-RU')}
|
||
</p>
|
||
</div>
|
||
<div className={`px-3 py-2 rounded-lg border flex items-center gap-2 ${getStatusColor(currentTicket.status)}`}>
|
||
{getStatusIcon(currentTicket.status)}
|
||
<span className="font-medium">{getStatusText(currentTicket.status)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Status Controls */}
|
||
{canChangeStatus && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => changeStatus('pending')}
|
||
disabled={currentTicket.status === 'pending'}
|
||
className={`flex-1 px-3 py-2 rounded-lg border transition ${
|
||
currentTicket.status === 'pending'
|
||
? 'bg-yellow-500/20 text-yellow-500 border-yellow-500/50'
|
||
: 'bg-dark-800 hover:bg-dark-700 border-gray-700'
|
||
}`}
|
||
>
|
||
<Clock className="w-4 h-4 inline mr-2" />
|
||
На рассмотрении
|
||
</button>
|
||
<button
|
||
onClick={() => changeStatus('in_progress')}
|
||
disabled={currentTicket.status === 'in_progress'}
|
||
className={`flex-1 px-3 py-2 rounded-lg border transition ${
|
||
currentTicket.status === 'in_progress'
|
||
? 'bg-blue-500/20 text-blue-500 border-blue-500/50'
|
||
: 'bg-dark-800 hover:bg-dark-700 border-gray-700'
|
||
}`}
|
||
>
|
||
<AlertCircle className="w-4 h-4 inline mr-2" />
|
||
В работе
|
||
</button>
|
||
<button
|
||
onClick={() => changeStatus('closed')}
|
||
disabled={currentTicket.status === 'closed'}
|
||
className={`flex-1 px-3 py-2 rounded-lg border transition ${
|
||
currentTicket.status === 'closed'
|
||
? 'bg-green-500/20 text-green-500 border-green-500/50'
|
||
: 'bg-dark-800 hover:bg-dark-700 border-gray-700'
|
||
}`}
|
||
>
|
||
<CheckCircle className="w-4 h-4 inline mr-2" />
|
||
Закрыт
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||
{messages.map((msg, index) => (
|
||
<div
|
||
key={index}
|
||
className={`flex ${msg.author === user.username ? 'justify-end' : 'justify-start'}`}
|
||
>
|
||
<div
|
||
className={`max-w-[70%] rounded-2xl px-4 py-3 ${
|
||
msg.author === 'system'
|
||
? 'bg-dark-700 border-gray-700 border text-center'
|
||
: msg.author === user.username
|
||
? 'bg-primary-600 text-white'
|
||
: 'bg-dark-800 border-gray-700 border'
|
||
}`}
|
||
>
|
||
{msg.author !== 'system' && msg.author !== user.username && (
|
||
<div className="text-xs font-semibold mb-1 text-gray-400">
|
||
{msg.author}
|
||
</div>
|
||
)}
|
||
<div className="whitespace-pre-wrap break-words">{msg.text}</div>
|
||
<div className={`text-xs mt-1 ${
|
||
msg.author === user.username ? 'text-white/70' : 'text-gray-400'
|
||
}`}>
|
||
{new Date(msg.timestamp).toLocaleTimeString('ru-RU')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Input */}
|
||
{currentTicket.status !== 'closed' && (
|
||
<form onSubmit={sendMessage} className="border-gray-700 border-t p-4">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={newMessage}
|
||
onChange={(e) => setNewMessage(e.target.value)}
|
||
placeholder="Введите сообщение..."
|
||
disabled={loading}
|
||
className="input flex-1"
|
||
/>
|
||
<button
|
||
type="submit"
|
||
disabled={loading || !newMessage.trim()}
|
||
className="btn-primary px-6 py-3 flex items-center gap-2 disabled:opacity-50"
|
||
>
|
||
<Send className="w-4 h-4" />
|
||
Отправить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|