Сделан баланс, проверка чеков, начата система создания серверов
This commit is contained in:
@@ -1,41 +1,62 @@
|
||||
// 3. Исправляем frontend/src/pages/dashboard/billing.tsx
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import QRCode from 'react-qr-code';
|
||||
|
||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL;
|
||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER;
|
||||
|
||||
|
||||
const Billing = () => {
|
||||
const [amount, setAmount] = useState(0);
|
||||
const [isPaymentGenerated, setIsPaymentGenerated] = useState(false);
|
||||
const [copyStatus, setCopyStatus] = useState('');
|
||||
|
||||
// ИСПРАВЛЕНО: используем правильные переменные окружения для Vite
|
||||
const cardNumber = import.meta.env.VITE_CARD_NUMBER || '';
|
||||
const sbpUrl = import.meta.env.VITE_SBP_QR_URL || '';
|
||||
const [checkFile, setCheckFile] = useState<File | null>(null);
|
||||
const [checkStatus, setCheckStatus] = useState('');
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
|
||||
const handleGeneratePayment = () => {
|
||||
if (amount <= 0) {
|
||||
alert('Пожалуйста, введите сумму больше нуля.');
|
||||
return;
|
||||
}
|
||||
if (!cardNumber || !sbpUrl) {
|
||||
alert('Данные для оплаты не настроены. Пожалуйста, обратитесь к администратору.');
|
||||
return;
|
||||
}
|
||||
setIsPaymentGenerated(true);
|
||||
if (amount > 0) setIsPaymentGenerated(true);
|
||||
};
|
||||
|
||||
const handleCopyCard = () => {
|
||||
if (cardNumber) {
|
||||
navigator.clipboard.writeText(cardNumber);
|
||||
setCopyStatus('Номер карты скопирован!');
|
||||
setCopyStatus('Скопировано!');
|
||||
setTimeout(() => setCopyStatus(''), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckUpload = async () => {
|
||||
if (!checkFile || amount <= 0) return;
|
||||
setUploadLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', checkFile);
|
||||
formData.append('amount', String(amount));
|
||||
const response = await axios.post('http://localhost:5000/api/check/upload', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
// 'Content-Type' не указываем вручную для FormData!
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
setCheckStatus('Чек успешно загружен! Ожидайте проверки.');
|
||||
setCheckFile(null);
|
||||
console.log('Чек успешно загружен:', response.data);
|
||||
} catch (error) {
|
||||
setCheckStatus('Ошибка загрузки чека.');
|
||||
console.error('Ошибка загрузки чека:', error);
|
||||
}
|
||||
setUploadLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-2xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Пополнение баланса</h2>
|
||||
|
||||
{/* Только QR-код и карта, без реквизитов */}
|
||||
{!isPaymentGenerated ? (
|
||||
<div>
|
||||
<p className="text-lg text-gray-500 mb-4">
|
||||
@@ -61,52 +82,53 @@ const Billing = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
Для пополнения баланса переведите <strong>₽{amount}</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Ваш заказ будет обработан вручную после проверки чека.
|
||||
</p>
|
||||
|
||||
{sbpUrl && (
|
||||
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={sbpUrl} size={256} />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Отсканируйте QR-код через мобильное приложение вашего банка.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-lg text-gray-700 mb-4">
|
||||
Для пополнения баланса переведите <strong>₽{amount}</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Ваш заказ будет обработан вручную после проверки чека.
|
||||
</p>
|
||||
</div>
|
||||
{/* QR-код для оплаты по СБП */}
|
||||
<div className="bg-gray-100 p-6 rounded-2xl inline-block mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по СБП</h3>
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<QRCode value={sbpUrl || 'https://qr.nspk.ru/FAKE-QR-LINK'} size={256} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardNumber && (
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
|
||||
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber}</p>
|
||||
<button
|
||||
onClick={handleCopyCard}
|
||||
className="mt-4 px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-gray-700"
|
||||
>
|
||||
Скопировать номер карты
|
||||
</button>
|
||||
{copyStatus && <p className="mt-2 text-sm text-green-500">{copyStatus}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Отсканируйте QR-код через мобильное приложение вашего банка.
|
||||
</p>
|
||||
</div>
|
||||
{/* Номер карты с кнопкой копирования */}
|
||||
<div className="bg-gray-100 p-6 rounded-2xl mb-6">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Оплата по номеру карты</h3>
|
||||
<p className="text-2xl font-bold text-gray-800 select-all">{cardNumber || '0000 0000 0000 0000'}</p>
|
||||
<button
|
||||
onClick={handleCopyCard}
|
||||
className="mt-4 px-4 py-2 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-gray-500 hover:bg-gray-700"
|
||||
>
|
||||
Скопировать номер карты
|
||||
</button>
|
||||
{copyStatus && <p className="mt-2 text-sm text-green-500">{copyStatus}</p>}
|
||||
</div>
|
||||
{/* Форма загрузки чека и инструкции */}
|
||||
<div className="bg-blue-50 p-6 rounded-2xl border-l-4 border-blue-500 text-left mb-6">
|
||||
<p className="font-bold text-blue-800">Загрузите чек для проверки:</p>
|
||||
<input type="file" accept="image/*,application/pdf" onChange={e => setCheckFile(e.target.files?.[0] || null)} className="mt-2" />
|
||||
<button onClick={handleCheckUpload} disabled={!checkFile || uploadLoading} className="mt-2 bg-blue-500 text-white px-4 py-2 rounded">
|
||||
{uploadLoading ? 'Загрузка...' : 'Отправить чек'}
|
||||
</button>
|
||||
{checkStatus && <div className="mt-2 text-green-600">{checkStatus}</div>}
|
||||
</div>
|
||||
<div className="bg-red-50 p-6 rounded-2xl border-l-4 border-red-500 text-left mb-6">
|
||||
<p className="font-bold text-red-800">Важно:</p>
|
||||
<p className="text-sm text-red-700">
|
||||
После оплаты сделайте скриншот или сохраните чек и отправьте его нам в тикет поддержки.
|
||||
После оплаты сделайте скриншот или сохраните чек и загрузите его для проверки.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-gray-600">
|
||||
После подтверждения ваш баланс будет пополнен. Перейдите в раздел{' '}
|
||||
<Link to="/dashboard/tickets" className="text-ospab-primary font-bold hover:underline">
|
||||
Тикеты
|
||||
</Link>
|
||||
, чтобы отправить нам чек.
|
||||
После подтверждения ваш баланс будет пополнен. Ожидайте проверки чека оператором.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
122
ospabhost/frontend/src/pages/dashboard/checkout.tsx
Normal file
122
ospabhost/frontend/src/pages/dashboard/checkout.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Tariff {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface OperatingSystem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
interface CheckoutProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const Checkout: React.FC<CheckoutProps> = ({ onSuccess }) => {
|
||||
const [tariffs, setTariffs] = useState<Tariff[]>([]);
|
||||
const [oses, setOses] = useState<OperatingSystem[]>([]);
|
||||
const [selectedTariff, setSelectedTariff] = useState<number | null>(null);
|
||||
const [selectedOs, setSelectedOs] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Загрузка тарифов и ОС
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [tariffRes, osRes] = await Promise.all([
|
||||
axios.get('http://localhost:5000/api/tariff'),
|
||||
axios.get('http://localhost:5000/api/os'),
|
||||
]);
|
||||
setTariffs(tariffRes.data);
|
||||
setOses(osRes.data);
|
||||
// Автовыбор тарифа из query
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tariffId = params.get('tariff');
|
||||
if (tariffId) {
|
||||
setSelectedTariff(Number(tariffId));
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка загрузки тарифов или ОС');
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [location.search]);
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!selectedTariff || !selectedOs) {
|
||||
setError('Выберите тариф и ОС');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
console.log('Покупка сервера:', { tariffId: selectedTariff, osId: selectedOs });
|
||||
const res = await axios.post('http://localhost:5000/api/server/create', {
|
||||
tariffId: selectedTariff,
|
||||
osId: selectedOs,
|
||||
});
|
||||
console.log('Ответ сервера:', res.data);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
console.error('Ошибка покупки сервера:', err);
|
||||
setError('Ошибка покупки сервера');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-4">Покупка сервера</h2>
|
||||
{error && <p className="text-red-500 mb-2">{error}</p>}
|
||||
<div className="mb-4">
|
||||
<label className="block font-semibold mb-2">Тариф:</label>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={selectedTariff ?? ''}
|
||||
onChange={e => setSelectedTariff(Number(e.target.value))}
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map(t => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} — ₽{t.price}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block font-semibold mb-2">Операционная система:</label>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-2"
|
||||
value={selectedOs ?? ''}
|
||||
onChange={e => setSelectedOs(Number(e.target.value))}
|
||||
>
|
||||
<option value="">Выберите ОС</option>
|
||||
{oses.map(os => (
|
||||
<option key={os.id} value={os.id}>
|
||||
{os.name} ({os.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
className="bg-ospab-primary text-white px-6 py-2 rounded font-bold w-full"
|
||||
onClick={handleBuy}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Покупка...' : 'Купить сервер'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkout;
|
||||
@@ -1,8 +1,140 @@
|
||||
const CheckVerification = () => {
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ICheck {
|
||||
id: number;
|
||||
userId: number;
|
||||
amount: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
fileUrl: string;
|
||||
createdAt: string;
|
||||
user?: IUser;
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:5000/api/check';
|
||||
|
||||
const CheckVerification: React.FC = () => {
|
||||
const [checks, setChecks] = useState<ICheck[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecks = async (): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get<ICheck[]>(API_URL, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
withCredentials: true,
|
||||
});
|
||||
setChecks(res.data);
|
||||
} catch {
|
||||
setError('Ошибка загрузки чеков');
|
||||
setChecks([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchChecks();
|
||||
}, []);
|
||||
|
||||
const handleAction = async (checkId: number, action: 'approve' | 'reject'): Promise<void> => {
|
||||
setActionLoading(checkId);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post(`${API_URL}/${action}`, { checkId }, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
withCredentials: true,
|
||||
});
|
||||
setChecks((prevChecks: ICheck[]) => prevChecks.map((c: ICheck) => c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c));
|
||||
// Если подтверждение — обновить баланс пользователя
|
||||
if (action === 'approve') {
|
||||
try {
|
||||
const userToken = localStorage.getItem('access_token') || token;
|
||||
const headers = { Authorization: `Bearer ${userToken}` };
|
||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||
// Глобально обновить userData через типизированное событие (для Dashboard)
|
||||
window.dispatchEvent(new CustomEvent<import('./types').UserData>('userDataUpdate', {
|
||||
detail: {
|
||||
user: userRes.data.user,
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
}
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления userData:', error);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка действия');
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Проверка чеков</h2>
|
||||
<p className="text-lg text-gray-500">Здесь будут отображаться чеки для проверки.</p>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500">Загрузка чеков...</p>
|
||||
) : error ? (
|
||||
<p className="text-lg text-red-500">{error}</p>
|
||||
) : checks.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">Нет чеков для проверки.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{checks.map((check: ICheck) => (
|
||||
<div key={check.id} className="border rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between bg-gray-50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Пользователь:</span> <span className="text-gray-700">{check.user?.username || check.user?.email}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Сумма:</span> <span className="text-gray-700">₽{check.amount}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Статус:</span> <span className={`font-bold ${check.status === 'pending' ? 'text-yellow-600' : check.status === 'approved' ? 'text-green-600' : 'text-red-600'}`}>{check.status === 'pending' ? 'На проверке' : check.status === 'approved' ? 'Подтверждён' : 'Отклонён'}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Дата:</span> <span className="text-gray-700">{new Date(check.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 md:ml-8">
|
||||
<a href={`http://localhost:5000${check.fileUrl}`} target="_blank" rel="noopener noreferrer" className="block mb-2">
|
||||
<img src={`http://localhost:5000${check.fileUrl}`} alt="Чек" className="w-32 h-32 object-contain rounded-xl border" />
|
||||
</a>
|
||||
{check.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleAction(check.id, 'approve')}
|
||||
disabled={actionLoading === check.id}
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-full font-bold mb-2"
|
||||
>
|
||||
{actionLoading === check.id ? 'Подтверждение...' : 'Подтвердить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(check.id, 'reject')}
|
||||
disabled={actionLoading === check.id}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-bold"
|
||||
>
|
||||
{actionLoading === check.id ? 'Отклонение...' : 'Отклонить'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,16 +7,18 @@ import { useContext } from 'react';
|
||||
|
||||
// Импортируем компоненты для вкладок
|
||||
import Summary from './summary';
|
||||
import Servers from './servers';
|
||||
import Tickets from './tickets';
|
||||
import ServerManagementPage from './servermanagement';
|
||||
import TicketsPage from './tickets';
|
||||
import Billing from './billing';
|
||||
import Settings from './settings';
|
||||
import CheckVerification from './checkverification';
|
||||
import TicketResponse from './ticketresponse';
|
||||
import Checkout from './checkout';
|
||||
import TariffsPage from '../tariffs';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [userData, setUserData] = useState<import('./types').UserData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { logout } = useContext(AuthContext);
|
||||
@@ -44,15 +46,13 @@ const Dashboard = () => {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('http://localhost:5000/api/auth/me', { headers });
|
||||
|
||||
setUserData({
|
||||
user: userRes.data.user,
|
||||
balance: 1500,
|
||||
servers: [],
|
||||
tickets: [],
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
servers: userRes.data.user.servers ?? [],
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки данных:', err);
|
||||
@@ -67,35 +67,48 @@ const Dashboard = () => {
|
||||
fetchData();
|
||||
}, [logout, navigate]);
|
||||
|
||||
// Функция для обновления userData из API
|
||||
const updateUserData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('http://localhost: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);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleUserDataUpdate = () => {
|
||||
try {
|
||||
updateUserData();
|
||||
} catch (err) {
|
||||
console.error('Ошибка в обработчике userDataUpdate:', err);
|
||||
}
|
||||
};
|
||||
window.addEventListener('userDataUpdate', handleUserDataUpdate);
|
||||
return () => {
|
||||
window.removeEventListener('userDataUpdate', handleUserDataUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isOperator = userData?.user?.operator === 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary mx-auto mb-4"></div>
|
||||
<h1 className="text-2xl text-gray-800">Загрузка...</h1>
|
||||
</div>
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<span className="text-gray-500 text-lg">Загрузка...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userData || !userData.user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl text-gray-800 mb-4">Ошибка загрузки данных</h1>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-ospab-primary text-white rounded-lg"
|
||||
>
|
||||
Перезагрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOperator = userData.user.operator === 1;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
{/* Sidebar - фиксированный слева */}
|
||||
@@ -103,7 +116,7 @@ const Dashboard = () => {
|
||||
{/* Заголовок сайдбара */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
Привет, {userData.user.username}!
|
||||
Привет, {userData?.user?.username || 'Гость'}!
|
||||
</h2>
|
||||
{isOperator && (
|
||||
<span className="inline-block px-2 py-1 bg-red-100 text-red-800 text-xs font-semibold rounded-full mt-1">
|
||||
@@ -111,7 +124,7 @@ const Dashboard = () => {
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
Баланс: <span className="font-semibold text-ospab-primary">₽{userData.balance}</span>
|
||||
Баланс: <span className="font-semibold text-ospab-primary">₽{userData?.balance ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +137,6 @@ const Dashboard = () => {
|
||||
activeTab === 'summary' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">📊</span>
|
||||
Сводка
|
||||
</Link>
|
||||
<Link
|
||||
@@ -133,7 +145,6 @@ const Dashboard = () => {
|
||||
activeTab === 'servers' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">🖥️</span>
|
||||
Серверы
|
||||
</Link>
|
||||
<Link
|
||||
@@ -142,7 +153,6 @@ const Dashboard = () => {
|
||||
activeTab === 'tickets' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">🎫</span>
|
||||
Тикеты
|
||||
</Link>
|
||||
<Link
|
||||
@@ -151,7 +161,6 @@ const Dashboard = () => {
|
||||
activeTab === 'billing' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">💳</span>
|
||||
Пополнить баланс
|
||||
</Link>
|
||||
<Link
|
||||
@@ -160,7 +169,6 @@ const Dashboard = () => {
|
||||
activeTab === 'settings' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">⚙️</span>
|
||||
Настройки
|
||||
</Link>
|
||||
</div>
|
||||
@@ -177,7 +185,6 @@ const Dashboard = () => {
|
||||
activeTab === 'checkverification' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">✅</span>
|
||||
Проверка чеков
|
||||
</Link>
|
||||
<Link
|
||||
@@ -186,7 +193,6 @@ const Dashboard = () => {
|
||||
activeTab === 'ticketresponse' ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-3">💬</span>
|
||||
Ответы на тикеты
|
||||
</Link>
|
||||
</div>
|
||||
@@ -207,7 +213,7 @@ const Dashboard = () => {
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Хлебные крошки/заголовок */}
|
||||
<div className="bg-white border-b border-gray-200 px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 capitalize">
|
||||
{activeTab === 'summary' ? 'Сводка' :
|
||||
@@ -228,32 +234,22 @@ const Dashboard = () => {
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Быстрые действия */}
|
||||
<div className="flex space-x-3">
|
||||
<Link
|
||||
to="/dashboard/billing"
|
||||
className="px-4 py-2 bg-green-100 text-green-800 rounded-lg text-sm font-medium hover:bg-green-200 transition-colors"
|
||||
>
|
||||
💰 Пополнить
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/tickets"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
🆘 Поддержка
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Контент страницы */}
|
||||
<div className="flex-1 p-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Summary userData={userData} />} />
|
||||
<Route path="servers" element={<Servers servers={userData.servers} />} />
|
||||
<Route path="tickets" element={<Tickets tickets={userData.tickets} />} />
|
||||
<Route path="billing" element={<Billing />} />
|
||||
<Route path="/" element={<Summary userData={userData ?? { user: { username: '', operator: 0 }, balance: 0, servers: [], tickets: [] }} />} />
|
||||
<Route path="servers" element={<ServerManagementPage />} />
|
||||
<Route path="checkout" element={<Checkout onSuccess={() => window.location.reload()} />} />
|
||||
<Route path="tariffs" element={<TariffsPage />} />
|
||||
{userData && (
|
||||
<Route path="tickets" element={<TicketsPage setUserData={setUserData} />} />
|
||||
)}
|
||||
{userData && (
|
||||
<Route path="billing" element={<Billing />} />
|
||||
)}
|
||||
<Route path="settings" element={<Settings />} />
|
||||
|
||||
{isOperator && (
|
||||
|
||||
96
ospabhost/frontend/src/pages/dashboard/servermanagement.tsx
Normal file
96
ospabhost/frontend/src/pages/dashboard/servermanagement.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAuth from '../../context/useAuth';
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tariff: { name: string; price: number };
|
||||
os: { name: string; type: string };
|
||||
}
|
||||
|
||||
const ServerManagementPage = () => {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const { isLoggedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const res = await axios.get('http://localhost:5000/api/server', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setServers(res.data);
|
||||
} catch {
|
||||
setError('Ошибка загрузки серверов');
|
||||
setServers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchServers();
|
||||
}, [isLoggedIn, navigate]);
|
||||
|
||||
// TODO: добавить управление сервером (включить, выключить, перезагрузить, переустановить ОС)
|
||||
|
||||
try {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-4xl font-bold text-center mb-10 text-gray-900">Управление серверами</h1>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500 text-center">Загрузка...</p>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-center">
|
||||
<p className="text-lg text-red-500">{error}</p>
|
||||
<button className="mt-4 px-4 py-2 rounded bg-ospab-primary text-white font-bold" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<p className="text-lg text-gray-500 text-center">У вас нет серверов.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{servers.map(server => (
|
||||
<div key={server.id} className="bg-white p-8 rounded-2xl shadow-xl flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">{server.tariff.name}</h2>
|
||||
<p className="text-lg text-gray-600">ОС: {server.os.name} ({server.os.type})</p>
|
||||
<p className="text-lg text-gray-600">Статус: <span className="font-bold">{server.status}</span></p>
|
||||
<p className="text-sm text-gray-400">Создан: {new Date(server.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
{/* TODO: Кнопки управления сервером */}
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button className="px-4 py-2 rounded bg-ospab-primary text-white font-bold hover:bg-ospab-accent">Включить</button>
|
||||
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Выключить</button>
|
||||
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Перезагрузить</button>
|
||||
<button className="px-4 py-2 rounded bg-gray-200 text-gray-800 font-bold hover:bg-gray-300">Переустановить ОС</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white p-8 rounded-2xl shadow-xl text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Ошибка отображения страницы</h2>
|
||||
<p className="text-gray-700 mb-4">Произошла критическая ошибка. Попробуйте перезагрузить страницу.</p>
|
||||
<button className="px-4 py-2 rounded bg-ospab-primary text-white font-bold" onClick={() => window.location.reload()}>Перезагрузить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ServerManagementPage;
|
||||
@@ -1,17 +1,70 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ServersProps {
|
||||
servers: unknown[];
|
||||
interface Server {
|
||||
id: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
os: { name: string; type: string };
|
||||
tariff: { name: string; price: number };
|
||||
}
|
||||
|
||||
const Servers: React.FC<ServersProps> = ({ servers }) => {
|
||||
const Servers: React.FC = () => {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const res = await axios.get('http://localhost:5000/api/server');
|
||||
console.log('Ответ API серверов:', res.data);
|
||||
// Защита от получения HTML вместо JSON
|
||||
if (typeof res.data === 'string' && res.data.startsWith('<!doctype html')) {
|
||||
setError('Ошибка соединения с backend: получен HTML вместо JSON. Проверьте адрес и порт.');
|
||||
setServers([]);
|
||||
} else if (Array.isArray(res.data)) {
|
||||
setServers(res.data);
|
||||
} else {
|
||||
setError('Некорректный формат данных серверов');
|
||||
setServers([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки серверов:', err);
|
||||
setError('Ошибка загрузки серверов');
|
||||
setServers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchServers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Серверы</h2>
|
||||
{servers.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">У вас пока нет активных серверов.</p>
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold text-gray-800">Серверы</h2>
|
||||
{/* Кнопка 'Купить сервер' только если серверов нет */}
|
||||
{servers.length === 0 && !loading && !error && (
|
||||
<a href="/tariffs" className="bg-ospab-primary text-white px-4 py-2 rounded font-bold hover:bg-ospab-primary-dark transition">Купить сервер</a>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500">Загрузка...</p>
|
||||
) : error ? (
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-red-500 mb-4">{error}</p>
|
||||
<button className="bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition" onClick={() => window.location.reload()}>Перезагрузить страницу</button>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-gray-500 mb-4">У вас пока нет активных серверов.</p>
|
||||
<a href="/tariffs" className="inline-block bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition">Посмотреть тарифы</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-lg text-gray-500">Список ваших серверов будет здесь...</p>
|
||||
<div className="text-center">
|
||||
<a href="/dashboard/servermanagement" className="bg-ospab-primary text-white px-6 py-3 rounded-full font-bold hover:bg-ospab-primary-dark transition">Перейти к управлению серверами</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { UserData } from './types';
|
||||
import type { UserData, Ticket, Server } from './types';
|
||||
|
||||
interface SummaryProps {
|
||||
userData: UserData;
|
||||
}
|
||||
|
||||
const Summary = ({ userData }: SummaryProps) => {
|
||||
// Фильтрация открытых тикетов и активных серверов
|
||||
const openTickets = Array.isArray(userData.tickets)
|
||||
? userData.tickets.filter((t: Ticket) => t.status !== 'closed')
|
||||
: [];
|
||||
const activeServers = Array.isArray(userData.servers)
|
||||
? userData.servers.filter((s: Server) => s.status === 'active')
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Сводка по аккаунту</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
||||
<p className="text-xl font-medium text-gray-700">Баланс:</p>
|
||||
<p className="text-4xl font-extrabold text-ospab-primary mt-2">₽ {userData.balance.toFixed(2)}</p>
|
||||
<p className="text-4xl font-extrabold text-ospab-primary mt-2">₽ {userData.balance?.toFixed ? userData.balance.toFixed(2) : Number(userData.balance).toFixed(2)}</p>
|
||||
<Link to="/dashboard/billing" className="text-sm text-gray-500 hover:underline mt-2">Пополнить баланс →</Link>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
||||
<p className="text-xl font-medium text-gray-700">Активные серверы:</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{userData.servers.length}</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{activeServers.length}</p>
|
||||
<Link to="/dashboard/servers" className="text-sm text-gray-500 hover:underline mt-2">Управлять →</Link>
|
||||
</div>
|
||||
<div className="bg-gray-100 p-6 rounded-2xl flex flex-col items-start">
|
||||
<p className="text-xl font-medium text-gray-700">Открытые тикеты:</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{userData.tickets.length}</p>
|
||||
<p className="text-4xl font-extrabold text-gray-800 mt-2">{openTickets.length}</p>
|
||||
<Link to="/dashboard/tickets" className="text-sm text-gray-500 hover:underline mt-2">Служба поддержки →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,155 @@
|
||||
const TicketResponse = () => {
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Response {
|
||||
id: number;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
operator?: { username: string };
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
responses: Response[];
|
||||
user?: { username: string };
|
||||
}
|
||||
|
||||
const TicketResponse: React.FC = () => {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [responseMsg, setResponseMsg] = useState<{ [key: number]: string }>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('http://localhost:5000/api/ticket', {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
setTickets(res.data);
|
||||
} else {
|
||||
setTickets([]);
|
||||
}
|
||||
} catch {
|
||||
setError('Ошибка загрузки тикетов');
|
||||
setTickets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const respondTicket = async (ticketId: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/respond', {
|
||||
ticketId,
|
||||
message: responseMsg[ticketId]
|
||||
}, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setResponseMsg(prev => ({ ...prev, [ticketId]: '' }));
|
||||
fetchTickets();
|
||||
} catch {
|
||||
setError('Ошибка отправки ответа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция закрытия тикета
|
||||
const closeTicket = async (ticketId: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
fetchTickets();
|
||||
} catch {
|
||||
setError('Ошибка закрытия тикета');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Ответы на тикеты</h2>
|
||||
<p className="text-lg text-gray-500">Здесь будут отображаться тикеты для ответов.</p>
|
||||
{error && <div className="text-red-500 mb-4">{error}</div>}
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">Нет тикетов для ответа.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{tickets.map(ticket => (
|
||||
<div key={ticket.id} className="border rounded-xl p-4 shadow flex flex-col">
|
||||
<div className="font-bold text-lg mb-1">{ticket.title}</div>
|
||||
<div className="text-gray-600 mb-2">{ticket.message}</div>
|
||||
<div className="text-sm text-gray-400 mb-2">Статус: {ticket.status} | Автор: {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>
|
||||
{(ticket.responses || []).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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Форма ответа и кнопка закрытия */}
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
|
||||
<input
|
||||
value={responseMsg[ticket.id] || ''}
|
||||
onChange={e => setResponseMsg(prev => ({ ...prev, [ticket.id]: e.target.value }))}
|
||||
placeholder="Ваш ответ..."
|
||||
className="border rounded p-2 flex-1"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respondTicket(ticket.id)}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition"
|
||||
disabled={loading || !(responseMsg[ticket.id] && responseMsg[ticket.id].trim())}
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Ответить'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => closeTicket(ticket.id)}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition"
|
||||
disabled={loading}
|
||||
>
|
||||
Закрыть тикет
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{ticket.status === 'closed' && (
|
||||
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,220 @@
|
||||
import React from 'react';
|
||||
import type { UserData, Ticket } from './types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useAuth from '../../context/useAuth';
|
||||
import axios from 'axios';
|
||||
|
||||
interface TicketsProps {
|
||||
tickets: unknown[];
|
||||
}
|
||||
// Глобальный логгер ошибок для 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);
|
||||
}
|
||||
);
|
||||
|
||||
type TicketsPageProps = {
|
||||
setUserData: (data: UserData) => void;
|
||||
};
|
||||
|
||||
const TicketsPage: React.FC<TicketsPageProps> = ({ setUserData }) => {
|
||||
const { user } = useAuth() as { user?: { username: string; operator?: number } };
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTickets();
|
||||
}, []);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('http://localhost:5000/api/ticket', {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
if (Array.isArray(res.data)) {
|
||||
setTickets(res.data);
|
||||
} else {
|
||||
setTickets([]);
|
||||
}
|
||||
} catch {
|
||||
setTickets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const userRes = await axios.get('http://localhost: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('token');
|
||||
await axios.post('http://localhost: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('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/respond', { ticketId, message: responseMsg }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
setResponseMsg('');
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
};
|
||||
|
||||
const closeTicket = async (ticketId: number) => {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('http://localhost:5000/api/ticket/close', { ticketId }, {
|
||||
withCredentials: true,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
});
|
||||
fetchTickets();
|
||||
await updateUserData();
|
||||
};
|
||||
|
||||
const Tickets: React.FC<TicketsProps> = ({ tickets }) => {
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Тикеты поддержки</h2>
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">У вас пока нет открытых тикетов.</p>
|
||||
) : (
|
||||
<p className="text-lg text-gray-500">Список ваших тикетов будет здесь...</p>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tickets;
|
||||
export default TicketsPage;
|
||||
|
||||
@@ -3,9 +3,26 @@ export interface User {
|
||||
operator: number;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
responses: unknown[];
|
||||
user?: { username: string };
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
// можно добавить другие поля по необходимости
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
user: User;
|
||||
balance: number;
|
||||
servers: unknown[];
|
||||
tickets: unknown[];
|
||||
servers: Server[];
|
||||
tickets: Ticket[];
|
||||
}
|
||||
Reference in New Issue
Block a user