Files
NeveTimePanel/frontend/src/components/TicketChat.jsx
arkonsadter fbfddf3c7a
All checks were successful
continuous-integration/drone/push Build is passing
Changed design and bug fixes
2026-01-16 15:40:14 +06:00

280 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}