BIG_UPDATE deleted vps, added s3 infrastructure.
This commit is contained in:
@@ -1,217 +1,282 @@
|
||||
import type { UserData, Ticket } from './types';
|
||||
import type { UserData } from './types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useAuth from '../../context/useAuth';
|
||||
import axios from 'axios';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
|
||||
// Глобальный логгер ошибок для axios
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response) {
|
||||
console.error('Ошибка ответа:', error.response.data);
|
||||
} else if (error.request) {
|
||||
console.error('Нет ответа от сервера:', error.request);
|
||||
} else {
|
||||
console.error('Ошибка запроса:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
interface Ticket {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
responses?: Response[];
|
||||
assignedTo?: number;
|
||||
closedAt?: string;
|
||||
}
|
||||
|
||||
interface Response {
|
||||
id: number;
|
||||
message: string;
|
||||
isInternal: boolean;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
user: {
|
||||
username: string;
|
||||
operator: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type TicketsPageProps = {
|
||||
setUserData: (data: UserData) => void;
|
||||
};
|
||||
|
||||
const TicketsPage: React.FC<TicketsPageProps> = ({ setUserData }) => {
|
||||
const { user } = useAuth() as { user?: { username: string; operator?: number } };
|
||||
const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
const navigate = useNavigate();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formSuccess, setFormSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [responseMsg, setResponseMsg] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
status: 'all',
|
||||
category: 'all',
|
||||
priority: 'all'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
fetchTickets();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters]);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await axios.get('https://ospab.host:5000/api/ticket', {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
setTickets(res.data);
|
||||
} else {
|
||||
setTickets([]);
|
||||
}
|
||||
} catch {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (filters.status !== 'all') params.status = filters.status;
|
||||
if (filters.category !== 'all') params.category = filters.category;
|
||||
if (filters.priority !== 'all') params.priority = filters.priority;
|
||||
|
||||
const response = await apiClient.get('/api/ticket', { params });
|
||||
|
||||
setTickets(response.data.tickets || response.data || []);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки тикетов:', error);
|
||||
setTickets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('https://ospab.host:5000/api/auth/me', { headers });
|
||||
setUserData({
|
||||
user: userRes.data.user,
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка обновления userData после тикета:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const createTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError('');
|
||||
setFormSuccess('');
|
||||
if (!title.trim() || !message.trim()) {
|
||||
setFormError('Заполните тему и сообщение');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
await axios.post('https://ospab.host:5000/api/ticket/create', { title, message }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setTitle('');
|
||||
setMessage('');
|
||||
setFormSuccess('Тикет успешно создан!');
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
} catch {
|
||||
setFormError('Ошибка создания тикета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const respondTicket = async (ticketId: number) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
await axios.post('https://ospab.host:5000/api/ticket/respond', { ticketId, message: responseMsg }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setResponseMsg('');
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.open;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const closeTicket = async (ticketId: number) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
await axios.post('https://ospab.host:5000/api/ticket/close', { ticketId }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно', emoji: '🔴' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий', emoji: '🟠' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный', emoji: '⚪' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий', emoji: '🟢' }
|
||||
};
|
||||
|
||||
const badge = badges[priority] || badges.normal;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
general: '💬',
|
||||
technical: '⚙️',
|
||||
billing: '💰',
|
||||
other: '📝'
|
||||
};
|
||||
|
||||
return icons[category] || icons.general;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка тикетов...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Мои тикеты</h2>
|
||||
<form onSubmit={createTicket} className="mb-8 max-w-xl bg-gray-50 rounded-2xl shadow p-6 flex flex-col gap-4">
|
||||
<label className="font-semibold text-lg">Тема тикета</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Введите тему..."
|
||||
className="border rounded-xl p-3 focus:outline-blue-400 text-base"
|
||||
/>
|
||||
<label className="font-semibold text-lg">Сообщение</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Опишите проблему или вопрос..."
|
||||
className="border rounded-xl p-3 min-h-[80px] resize-y focus:outline-blue-400 text-base"
|
||||
/>
|
||||
{formError && <div className="text-red-500 text-sm">{formError}</div>}
|
||||
{formSuccess && <div className="text-green-600 text-sm">{formSuccess}</div>}
|
||||
<button
|
||||
type="submit"
|
||||
className={`bg-blue-500 text-white px-6 py-3 rounded-xl hover:bg-blue-600 transition text-lg font-semibold ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Создать тикет'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-8">
|
||||
{tickets.map(ticket => (
|
||||
<div key={ticket.id} className="border rounded-2xl p-6 shadow flex flex-col">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-2">
|
||||
<div className="font-bold text-xl text-blue-900">{ticket.title}</div>
|
||||
<div className="text-sm text-gray-500">Статус: <span className={ticket.status === 'closed' ? 'text-red-600 font-bold' : 'text-green-600 font-bold'}>{ticket.status === 'closed' ? 'Закрыт' : 'Открыт'}</span></div>
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Управляйте вашими обращениями в службу поддержки</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||||
>
|
||||
<span>➕</span>
|
||||
Создать тикет
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Статус</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
<option value="in_progress">В работе</option>
|
||||
<option value="awaiting_reply">Ожидает ответа</option>
|
||||
<option value="resolved">Решён</option>
|
||||
<option value="closed">Закрыт</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 mb-2">Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}</div>
|
||||
{/* Чат сообщений */}
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="bg-blue-100 text-blue-900 px-3 py-2 rounded-xl max-w-xl">
|
||||
<span className="font-semibold">{ticket.user?.username || 'Клиент'}:</span> {ticket.message}
|
||||
</div>
|
||||
</div>
|
||||
{((Array.isArray(ticket.responses) ? ticket.responses : []) as {
|
||||
id: number;
|
||||
operator?: { username?: string };
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}[]).map((r) => (
|
||||
<div key={r.id} className="flex items-start gap-2">
|
||||
<div className="bg-green-100 text-green-900 px-3 py-2 rounded-xl max-w-xl ml-8">
|
||||
<span className="font-semibold">{r.operator?.username || 'Оператор'}:</span> {r.message}
|
||||
<span className="text-gray-400 ml-2 text-xs">{new Date(r.createdAt).toLocaleString()}</span>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Категория</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Приоритет</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="low">Низкий</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickets Grid */}
|
||||
{tickets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<div className="text-6xl mb-4">📭</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
|
||||
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Создать первый тикет
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to={`/dashboard/tickets/${ticket.id}`}
|
||||
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-all duration-200 overflow-hidden"
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">{getCategoryIcon(ticket.category || 'general')}</span>
|
||||
<h3 className="text-xl font-semibold text-gray-900">{ticket.title}</h3>
|
||||
{getPriorityBadge(ticket.priority || 'normal')}
|
||||
</div>
|
||||
<p className="text-gray-600 line-clamp-2">{ticket.message.substring(0, 150)}...</p>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
{getStatusBadge(ticket.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🕒</span>
|
||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>💬</span>
|
||||
<span>{ticket.responses?.length || 0} ответов</span>
|
||||
</span>
|
||||
{ticket.closedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🔒</span>
|
||||
<span>Закрыт</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
||||
Открыть →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Форма ответа и кнопка закрытия */}
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
|
||||
{user?.operator === 1 && (
|
||||
<>
|
||||
<input
|
||||
value={responseMsg}
|
||||
onChange={e => setResponseMsg(e.target.value)}
|
||||
placeholder="Ваш ответ..."
|
||||
className="border rounded-xl p-2 flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respondTicket(ticket.id)}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded-xl hover:bg-green-600 transition"
|
||||
disabled={loading || !(responseMsg && responseMsg.trim())}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Ответить'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTicket(ticket.id)}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded-xl hover:bg-red-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Закрыть тикет
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ticket.status === 'closed' && (
|
||||
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user