Add System Ticket
This commit is contained in:
93
frontend/src/components/CreateTicketModal.jsx
Normal file
93
frontend/src/components/CreateTicketModal.jsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
export default function CreateTicketModal({ token, theme, onClose, onCreated }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
`${API_URL}/api/tickets/create`,
|
||||||
|
formData,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
);
|
||||||
|
onCreated();
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.response?.data?.detail || 'Ошибка создания тикета');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className={`${theme.secondary} rounded-2xl p-6 w-full max-w-md shadow-2xl ${theme.border} border`}>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className={`text-xl font-bold ${theme.text}`}>Создать тикет</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`${theme.textSecondary} hover:${theme.text} transition`}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||||
|
Тема тикета
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||||
|
placeholder="Краткое описание проблемы"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={`block text-sm font-medium mb-2 ${theme.text}`}>
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className={`w-full ${theme.input} ${theme.border} border rounded-xl px-4 py-2 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition resize-none`}
|
||||||
|
placeholder="Подробное описание проблемы"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className={`flex-1 ${theme.card} ${theme.hover} px-4 py-2 rounded-xl transition`}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex-1 ${theme.accent} ${theme.accentHover} px-4 py-2 rounded-xl disabled:opacity-50 transition text-white`}
|
||||||
|
>
|
||||||
|
{loading ? 'Создание...' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
frontend/src/components/TicketChat.jsx
Normal file
241
frontend/src/components/TicketChat.jsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { ArrowLeft, Send, Clock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
|
||||||
|
export default function TicketChat({ ticket, token, user, theme, onBack }) {
|
||||||
|
const [messages, setMessages] = useState(ticket.messages || []);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const [currentTicket, setCurrentTicket] = useState(ticket);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
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}` }
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
setNewMessage('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отправки сообщения:', error);
|
||||||
|
alert(error.response?.data?.detail || 'Ошибка отправки сообщения');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeStatus = async (newStatus) => {
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка изменения статуса:', error);
|
||||||
|
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 === 'admin' || user.role === 'support';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-full flex flex-col ${theme.primary}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`${theme.secondary} ${theme.border} border-b p-4`}>
|
||||||
|
<div className="flex items-center gap-4 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className={`${theme.hover} 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 ${theme.textSecondary}`}>
|
||||||
|
Автор: {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'
|
||||||
|
: `${theme.card} ${theme.hover} ${theme.border}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
: `${theme.card} ${theme.hover} ${theme.border}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
: `${theme.card} ${theme.hover} ${theme.border}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
? `${theme.tertiary} ${theme.border} border text-center`
|
||||||
|
: msg.author === user.username
|
||||||
|
? `${theme.accent} text-white`
|
||||||
|
: `${theme.card} ${theme.border} border`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.author !== 'system' && msg.author !== user.username && (
|
||||||
|
<div className={`text-xs font-semibold mb-1 ${theme.textSecondary}`}>
|
||||||
|
{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' : theme.textSecondary
|
||||||
|
}`}>
|
||||||
|
{new Date(msg.timestamp).toLocaleTimeString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
{currentTicket.status !== 'closed' && (
|
||||||
|
<form onSubmit={sendMessage} className={`${theme.border} border-t p-4`}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
placeholder="Введите сообщение..."
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex-1 ${theme.input} ${theme.border} border rounded-xl px-4 py-3 ${theme.text} focus:outline-none focus:ring-2 focus:ring-blue-500 transition`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !newMessage.trim()}
|
||||||
|
className={`${theme.accent} ${theme.accentHover} px-6 py-3 rounded-xl flex items-center gap-2 text-white transition disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
frontend/src/components/Tickets.jsx
Normal file
179
frontend/src/components/Tickets.jsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { MessageSquare, Plus, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { API_URL } from '../config';
|
||||||
|
import TicketChat from './TicketChat';
|
||||||
|
import CreateTicketModal from './CreateTicketModal';
|
||||||
|
|
||||||
|
export default function Tickets({ token, user, theme }) {
|
||||||
|
const [tickets, setTickets] = useState([]);
|
||||||
|
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTickets();
|
||||||
|
const interval = setInterval(loadTickets, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTickets = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(`${API_URL}/api/tickets`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
setTickets(data);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки тикетов:', error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTicketCreated = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
loadTickets();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="w-4 h-4 text-yellow-500" />;
|
||||||
|
case 'in_progress':
|
||||||
|
return <AlertCircle className="w-4 h-4 text-blue-500" />;
|
||||||
|
case 'closed':
|
||||||
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedTicket) {
|
||||||
|
return (
|
||||||
|
<TicketChat
|
||||||
|
ticket={selectedTicket}
|
||||||
|
token={token}
|
||||||
|
user={user}
|
||||||
|
theme={theme}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedTicket(null);
|
||||||
|
loadTickets();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-full ${theme.primary} ${theme.text} p-6`}>
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Тикеты</h1>
|
||||||
|
<p className={theme.textSecondary}>Система поддержки</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className={`${theme.accent} ${theme.accentHover} px-4 py-2 rounded-xl flex items-center gap-2 text-white transition`}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Создать тикет
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tickets List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className={theme.textSecondary}>Загрузка тикетов...</p>
|
||||||
|
</div>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<div className={`${theme.card} ${theme.border} border rounded-2xl p-12 text-center`}>
|
||||||
|
<MessageSquare className={`w-16 h-16 mx-auto mb-4 ${theme.textSecondary} opacity-50`} />
|
||||||
|
<p className="text-lg font-medium mb-2">Нет тикетов</p>
|
||||||
|
<p className={`text-sm ${theme.textSecondary} mb-4`}>
|
||||||
|
Создайте первый тикет для обращения в поддержку
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className={`${theme.accent} ${theme.accentHover} px-6 py-2 rounded-xl text-white transition`}
|
||||||
|
>
|
||||||
|
Создать тикет
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{tickets.map((ticket) => (
|
||||||
|
<div
|
||||||
|
key={ticket.id}
|
||||||
|
onClick={() => setSelectedTicket(ticket)}
|
||||||
|
className={`${theme.card} ${theme.border} border rounded-2xl p-6 cursor-pointer ${theme.hover} transition-all duration-200`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{ticket.title}</h3>
|
||||||
|
<p className={`text-sm ${theme.textSecondary} line-clamp-2`}>
|
||||||
|
{ticket.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 rounded-lg border flex items-center gap-2 ${getStatusColor(ticket.status)}`}>
|
||||||
|
{getStatusIcon(ticket.status)}
|
||||||
|
<span className="text-sm font-medium">{getStatusText(ticket.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className={theme.textSecondary}>
|
||||||
|
Автор: <span className={theme.text}>{ticket.author}</span>
|
||||||
|
</span>
|
||||||
|
<span className={theme.textSecondary}>•</span>
|
||||||
|
<span className={theme.textSecondary}>
|
||||||
|
Сообщений: <span className={theme.text}>{ticket.messages?.length || 0}</span>
|
||||||
|
</span>
|
||||||
|
<span className={theme.textSecondary}>•</span>
|
||||||
|
<span className={theme.textSecondary}>
|
||||||
|
{new Date(ticket.created_at).toLocaleString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateTicketModal
|
||||||
|
token={token}
|
||||||
|
theme={theme}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
onCreated={handleTicketCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user