Сделан баланс, проверка чеков, начата система создания серверов

This commit is contained in:
Georgiy Syralev
2025-09-18 16:26:11 +03:00
parent 515d31ee9e
commit cce9e7b996
54 changed files with 1914 additions and 316 deletions

View File

@@ -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>
)}

View 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;

View File

@@ -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>
);
};

View File

@@ -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 && (

View 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;

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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[];
}