update README
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,316 +1,496 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { FiAlertCircle, FiArrowLeft, FiDatabase, FiDollarSign, FiInfo, FiShoppingCart } from 'react-icons/fi';
|
||||
import {
|
||||
FiAlertCircle,
|
||||
FiArrowLeft,
|
||||
FiClock,
|
||||
FiDatabase,
|
||||
FiInfo,
|
||||
FiShoppingCart,
|
||||
FiShield,
|
||||
FiGlobe
|
||||
} from 'react-icons/fi';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { API_URL } from '../../config/api';
|
||||
import { DEFAULT_STORAGE_PLAN_ID, STORAGE_PLAN_IDS, STORAGE_PLAN_MAP, type StoragePlanId } from '../../constants/storagePlans';
|
||||
import type { StorageBucket } from './types';
|
||||
|
||||
// Упрощённый Checkout только для S3 Bucket
|
||||
interface CheckoutProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
type CheckoutPlan = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quotaGb: number;
|
||||
bandwidthGb: number;
|
||||
requestLimit: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const Checkout: React.FC<CheckoutProps> = ({ onSuccess }) => {
|
||||
type CartPayload = {
|
||||
cartId: string;
|
||||
plan: CheckoutPlan;
|
||||
price: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/;
|
||||
|
||||
type CreateBucketResponse = {
|
||||
bucket?: StorageBucket;
|
||||
consoleCredentials?: {
|
||||
login: string;
|
||||
password: string;
|
||||
url?: string | null;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StorageRegionOption = {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
endpoint: string | null;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const Checkout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [planName, setPlanName] = useState<StoragePlanId>(DEFAULT_STORAGE_PLAN_ID);
|
||||
const [planPrice, setPlanPrice] = useState<number>(STORAGE_PLAN_MAP[DEFAULT_STORAGE_PLAN_ID].price);
|
||||
const params = useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const cartId = params.get('cart') ?? '';
|
||||
|
||||
const [cart, setCart] = useState<CartPayload | null>(null);
|
||||
const [loadingCart, setLoadingCart] = useState<boolean>(true);
|
||||
const [balance, setBalance] = useState<number>(0);
|
||||
const [bucketName, setBucketName] = useState<string>('');
|
||||
const [region, setRegion] = useState<string>('ru-central-1');
|
||||
const [storageClass, setStorageClass] = useState<string>('standard');
|
||||
const [region, setRegion] = useState<string>('');
|
||||
const [isPublic, setIsPublic] = useState<boolean>(false);
|
||||
const [versioning, setVersioning] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [regions, setRegions] = useState<StorageRegionOption[]>([]);
|
||||
const [loadingRegions, setLoadingRegions] = useState<boolean>(false);
|
||||
|
||||
// Загружаем параметры из query (?plan=basic&price=199)
|
||||
const fetchBalance = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`${API_URL}/api/user/balance`);
|
||||
setBalance(res.data.balance || 0);
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки баланса', e);
|
||||
setBalance(Number(res.data?.balance) || 0);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки баланса', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCart = useCallback(async () => {
|
||||
if (!cartId) {
|
||||
setError('Не найден идентификатор корзины. Вернитесь к выбору тарифа.');
|
||||
setLoadingCart(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingCart(true);
|
||||
setError(null);
|
||||
const response = await apiClient.get(`${API_URL}/api/storage/cart/${cartId}`);
|
||||
setCart(response.data as CartPayload);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось загрузить корзину';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoadingCart(false);
|
||||
}
|
||||
}, [cartId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBalance();
|
||||
}, [fetchBalance]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCart();
|
||||
}, [fetchCart]);
|
||||
|
||||
const fetchRegions = useCallback(async () => {
|
||||
try {
|
||||
setLoadingRegions(true);
|
||||
const response = await apiClient.get(`${API_URL}/api/storage/regions`);
|
||||
const fetchedRegions = Array.isArray(response.data?.regions)
|
||||
? (response.data.regions as StorageRegionOption[])
|
||||
: [];
|
||||
const activeRegions = fetchedRegions.filter((item) => item?.isActive !== false);
|
||||
setRegions(activeRegions);
|
||||
|
||||
if (activeRegions.length > 0) {
|
||||
const preferred = activeRegions.find((item) => item.isDefault) ?? activeRegions[0];
|
||||
setRegion((current) => (current && activeRegions.some((item) => item.code === current) ? current : preferred.code));
|
||||
} else {
|
||||
setRegion('');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки регионов', err);
|
||||
setRegions([]);
|
||||
setRegion('');
|
||||
} finally {
|
||||
setLoadingRegions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const rawPlan = params.get('plan');
|
||||
const match = rawPlan ? rawPlan.toLowerCase() : '';
|
||||
const planId = STORAGE_PLAN_IDS.includes(match as StoragePlanId)
|
||||
? (match as StoragePlanId)
|
||||
: DEFAULT_STORAGE_PLAN_ID;
|
||||
setPlanName(planId);
|
||||
fetchRegions();
|
||||
}, [fetchRegions]);
|
||||
|
||||
const priceParam = params.get('price');
|
||||
if (priceParam) {
|
||||
const numeric = Number(priceParam);
|
||||
setPlanPrice(Number.isFinite(numeric) && numeric > 0 ? numeric : STORAGE_PLAN_MAP[planId].price);
|
||||
} else {
|
||||
setPlanPrice(STORAGE_PLAN_MAP[planId].price);
|
||||
}
|
||||
const plan = cart?.plan;
|
||||
const planPrice = cart?.price ?? plan?.price ?? 0;
|
||||
|
||||
fetchBalance();
|
||||
}, [location.search, fetchBalance]);
|
||||
const planHighlights = useMemo(() => {
|
||||
if (!plan?.description) return [] as string[];
|
||||
return plan.description
|
||||
.split(/\n|\|/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
}, [plan]);
|
||||
|
||||
const meta = STORAGE_PLAN_MAP[planName];
|
||||
const expiresAtText = useMemo(() => {
|
||||
if (!cart) return null;
|
||||
const expires = new Date(cart.expiresAt);
|
||||
return expires.toLocaleString('ru-RU');
|
||||
}, [cart]);
|
||||
|
||||
const canCreate = () => {
|
||||
if (!planPrice || !bucketName.trim() || !meta) return false;
|
||||
const canCreate = useMemo(() => {
|
||||
if (!cart || !plan) return false;
|
||||
if (!region) return false;
|
||||
if (!BUCKET_NAME_REGEX.test(bucketName.trim())) return false;
|
||||
if (balance < planPrice) return false;
|
||||
// Простая валидация имени (можно расширить): маленькие буквы, цифры, тире
|
||||
return /^[a-z0-9-]{3,40}$/.test(bucketName.trim());
|
||||
};
|
||||
return true;
|
||||
}, [balance, bucketName, cart, planPrice, region]);
|
||||
|
||||
const selectedRegion = useMemo(
|
||||
() => regions.find((item) => item.code === region),
|
||||
[regions, region]
|
||||
);
|
||||
|
||||
const regionLabel = useMemo(() => {
|
||||
if (selectedRegion?.name) return selectedRegion.name;
|
||||
if (selectedRegion?.code) return selectedRegion.code;
|
||||
if (region) return region;
|
||||
return '—';
|
||||
}, [selectedRegion, region]);
|
||||
|
||||
const balanceAfterPayment = useMemo(() => balance - planPrice, [balance, planPrice]);
|
||||
|
||||
const formatCurrency = useCallback((amount: number) => `₽${amount.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!canCreate()) {
|
||||
setError('Проверьте корректность данных и баланс');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
if (!canCreate || !cart) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// POST на будущий endpoint S3
|
||||
const res = await apiClient.post(`${API_URL}/api/storage/buckets`, {
|
||||
const response = await apiClient.post<CreateBucketResponse>(`${API_URL}/api/storage/buckets`, {
|
||||
name: bucketName.trim(),
|
||||
plan: planName,
|
||||
quotaGb: meta?.quotaGb || 0,
|
||||
cartId: cart.cartId,
|
||||
region,
|
||||
storageClass,
|
||||
storageClass: 'standard',
|
||||
public: isPublic,
|
||||
versioning
|
||||
versioning,
|
||||
});
|
||||
|
||||
if (res.data?.error) {
|
||||
setError(res.data.error);
|
||||
const { bucket: createdBucket, consoleCredentials, error: apiError } = response.data ?? {};
|
||||
if (apiError) {
|
||||
throw new Error(apiError);
|
||||
}
|
||||
if (!createdBucket) {
|
||||
throw new Error('Не удалось получить созданный бакет. Попробуйте ещё раз.');
|
||||
}
|
||||
|
||||
try {
|
||||
const userRes = await apiClient.get(`${API_URL}/api/auth/me`);
|
||||
window.dispatchEvent(new CustomEvent('userDataUpdate', {
|
||||
detail: { user: userRes.data?.user },
|
||||
}));
|
||||
} catch (refreshError) {
|
||||
console.error('Ошибка обновления данных пользователя', refreshError);
|
||||
}
|
||||
|
||||
if (consoleCredentials) {
|
||||
navigate(`/dashboard/storage/${createdBucket.id}`, {
|
||||
state: {
|
||||
consoleCredentials,
|
||||
bucketName: createdBucket.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Обновляем пользовательские данные и баланс (если списание произошло на сервере)
|
||||
try {
|
||||
const userRes = await apiClient.get(`${API_URL}/api/auth/me`);
|
||||
window.dispatchEvent(new CustomEvent('userDataUpdate', {
|
||||
detail: { user: userRes.data.user }
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Ошибка обновления userData', e);
|
||||
}
|
||||
if (onSuccess) onSuccess();
|
||||
navigate('/dashboard/storage');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
let message = 'Ошибка создания бакета';
|
||||
if (e && typeof e === 'object' && 'response' in e) {
|
||||
const resp = (e as { response?: { data?: { message?: string } } }).response;
|
||||
if (resp?.data?.message) message = resp.data.message;
|
||||
navigate(`/dashboard/storage/${createdBucket.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Ошибка создания бакета';
|
||||
setError(message);
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="max-w-6xl mx-auto pb-16">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/storage')}
|
||||
className="flex items-center gap-2 px-4 py-2 text-ospab-primary hover:bg-ospab-primary/5 rounded-lg transition-colors mb-4"
|
||||
className="flex items-center gap-2 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
<FiArrowLeft />
|
||||
<span>Назад к хранилищу</span>
|
||||
<span>Назад к списку бакетов</span>
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<FiDatabase className="text-ospab-primary" /> Создание S3 Bucket
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">План: {meta?.title}{planPrice ? ` • ₽${planPrice}/мес` : ''}</p>
|
||||
{expiresAtText && (
|
||||
<span className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
<FiClock /> Корзина действительна до {expiresAtText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3 mb-4">
|
||||
<FiDatabase className="text-blue-600" />
|
||||
Создание S3 бакета
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Проверяем ваш тариф, готовим бакет и резервируем средства на балансе.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 flex items-start gap-3">
|
||||
<FiAlertCircle className="text-red-500 text-xl flex-shrink-0 mt-0.5" />
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-xl p-4 mb-6 flex items-start gap-3">
|
||||
<FiAlertCircle className="text-xl" />
|
||||
<div>
|
||||
<p className="text-red-700 font-semibold">Ошибка</p>
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
<p className="font-semibold">Нужно внимание</p>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Bucket settings */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Параметры бакета</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Ваш тариф</h2>
|
||||
<p className="text-sm text-gray-500">Зафиксирован при создании корзины</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 text-sm text-gray-500">
|
||||
<FiShield /> {plan?.code ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingCart ? (
|
||||
<div className="animate-pulse h-32 bg-gray-100 rounded-lg" />
|
||||
) : plan ? (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900">{plan.name}</h3>
|
||||
<p className="text-sm text-gray-500">S3 Object Storage</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">₽ в месяц</p>
|
||||
<p className="text-4xl font-bold text-gray-900">{plan.price.toLocaleString('ru-RU')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3 mb-6 text-sm">
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-gray-500">Хранилище</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{plan.quotaGb.toLocaleString('ru-RU')} GB</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-gray-500">Исходящий трафик</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{plan.bandwidthGb.toLocaleString('ru-RU')} GB</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-3">
|
||||
<p className="text-gray-500">Запросы</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{plan.requestLimit}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{planHighlights.length > 0 && (
|
||||
<ul className="grid sm:grid-cols-2 gap-3 text-sm text-gray-600">
|
||||
{planHighlights.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Корзина не найдена. Вернитесь на страницу тарифов.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<FiInfo className="text-blue-600 text-xl" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Настройка бакета</h2>
|
||||
<p className="text-sm text-gray-500">Базовые параметры можно изменить позже</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Имя бакета</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bucketName}
|
||||
onChange={(e) => setBucketName(e.target.value)}
|
||||
onChange={(event) => setBucketName(event.target.value.toLowerCase())}
|
||||
placeholder="например: media-assets"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent transition-all"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Допустимы: a-z 0-9 - (3–40 символов)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">a-z, 0-9, дефис, 3–40 символов</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Регион</label>
|
||||
<select
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
>
|
||||
<option value="ru-central-1">ru-central-1</option>
|
||||
<option value="eu-east-1">eu-east-1</option>
|
||||
<option value="eu-west-1">eu-west-1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">Класс хранения</label>
|
||||
<select
|
||||
value={storageClass}
|
||||
onChange={(e) => setStorageClass(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
|
||||
>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="infrequent">Infrequent</option>
|
||||
<option value="archive">Archive</option>
|
||||
</select>
|
||||
<div className="relative">
|
||||
<FiGlobe className="absolute left-3 top-3 text-gray-400" />
|
||||
<select
|
||||
value={region}
|
||||
onChange={(event) => setRegion(event.target.value)}
|
||||
disabled={loadingRegions || regions.length === 0}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
|
||||
>
|
||||
{loadingRegions && <option value="">Загрузка...</option>}
|
||||
{!loadingRegions && regions.length === 0 && <option value="">Нет доступных регионов</option>}
|
||||
{regions.map((item) => (
|
||||
<option key={item.code} value={item.code}>
|
||||
{item.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-700">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
onChange={(event) => setIsPublic(event.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Публичный доступ</span>
|
||||
<span>Публичный доступ</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={versioning}
|
||||
onChange={(e) => setVersioning(e.target.checked)}
|
||||
onChange={(event) => setVersioning(event.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Версионирование</span>
|
||||
<span>Версионирование объектов</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan info */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FiInfo className="text-ospab-primary text-xl" />
|
||||
<h2 className="text-xl font-bold text-gray-800">Информация о плане</h2>
|
||||
</div>
|
||||
{meta ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-gray-700 text-sm">Включённый объём: <span className="font-semibold">{meta.quotaGb} GB</span></p>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
|
||||
{meta.included.slice(0, 4).map((d) => (
|
||||
<li key={d} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-ospab-primary rounded-full"></span>{d}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-gray-700">
|
||||
Оплата списывается помесячно при создании бакета. Использование сверх квоты будет тарифицироваться позже.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Параметры плана не найдены. Вернитесь на страницу тарифов.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-xl shadow-md p-6 sticky top-4">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<FiShoppingCart className="text-ospab-primary text-xl" />
|
||||
<h2 className="text-xl font-bold text-gray-800">Итого</h2>
|
||||
<aside className="space-y-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">К оплате сегодня</h2>
|
||||
<FiShoppingCart className="text-blue-600 text-xl" />
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-ospab-primary to-ospab-accent rounded-lg p-4 mb-6 text-white">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FiDollarSign className="text-lg" />
|
||||
<p className="text-white/80 text-sm">Баланс</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mb-3">₽{balance.toFixed(2)}</p>
|
||||
<div className="bg-blue-50 rounded-xl p-4 mb-4">
|
||||
<p className="text-sm text-blue-600">Баланс аккаунта</p>
|
||||
<p className="text-2xl font-bold text-blue-700">₽{balance.toFixed(2)}</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/billing')}
|
||||
className="w-full bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-colors text-sm font-semibold"
|
||||
>Пополнить баланс</button>
|
||||
className="mt-3 w-full text-sm font-semibold text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Пополнить баланс
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">План</p>
|
||||
{meta ? (
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="font-semibold text-gray-800 mb-1">{meta.title}</p>
|
||||
<p className="text-sm text-gray-600">₽{planPrice}/мес</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">Не выбран</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">Имя бакета</p>
|
||||
{bucketName ? (
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<p className="font-semibold text-gray-800">{bucketName}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">Не указано</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{planName && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<div className="space-y-2 mb-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Стоимость:</span>
|
||||
<span className="font-semibold">₽{planPrice}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Баланс:</span>
|
||||
<span className="font-semibold">₽{balance.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between pt-3 border-t border-gray-200">
|
||||
<span className="text-gray-800 font-semibold">Остаток:</span>
|
||||
<span className={`font-bold text-lg ${balance - planPrice >= 0 ? 'text-green-600' : 'text-red-600'}`}>₽{(balance - planPrice).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-gray-500">План</p>
|
||||
<p className="text-xs text-gray-400">S3 Object Storage</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">{plan?.name ?? '—'}</p>
|
||||
<p className="text-xs text-gray-500">{plan ? formatCurrency(planPrice) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-gray-500">Регион</p>
|
||||
<p className="text-xs text-gray-400">Endpoint</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">{regionLabel}</p>
|
||||
<p className="text-xs text-gray-500">{selectedRegion?.endpoint ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-gray-500">Баланс</p>
|
||||
<p className="font-semibold text-gray-900">{formatCurrency(balance)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-4 border-t border-gray-200">
|
||||
<div>
|
||||
<p className="text-gray-700 font-semibold">Итог к списанию</p>
|
||||
{plan && (
|
||||
<p className="text-xs text-gray-500">Ежемесячный платёж тарифа</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{plan ? formatCurrency(planPrice) : '—'}</p>
|
||||
</div>
|
||||
|
||||
{plan && (
|
||||
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
{balanceAfterPayment >= 0
|
||||
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
|
||||
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate() || loading}
|
||||
className={`w-full py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors ${
|
||||
!canCreate() || loading ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-ospab-primary text-white hover:bg-ospab-primary/90 shadow-lg hover:shadow-xl'
|
||||
disabled={!canCreate || submitting || loadingCart}
|
||||
className={`mt-6 w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition-colors ${
|
||||
!canCreate || submitting || loadingCart
|
||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
{loading ? (<><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div><span>Создание...</span></>) : (<><FiShoppingCart /><span>Создать бакет</span></>)}
|
||||
{submitting ? (
|
||||
<>
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Создаём бакет...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Оплатить и создать</span>
|
||||
<FiShoppingCart />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!canCreate() && (
|
||||
<p className="text-xs text-gray-500 text-center mt-3">Заполните имя бакета, выберите план и убедитесь в достаточном балансе</p>
|
||||
|
||||
{!canCreate && !loadingCart && (
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
Проверьте имя бакета, выбранный регион и достаточный баланс для оплаты тарифа.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,14 +7,14 @@ import AuthContext from '../../context/authcontext';
|
||||
|
||||
// Импортируем компоненты для вкладок
|
||||
import Summary from './summary';
|
||||
import TicketsPage from './tickets';
|
||||
import TicketsPage from './tickets/index';
|
||||
import Billing from './billing';
|
||||
import Settings from './settings';
|
||||
import NotificationsPage from './notifications';
|
||||
import CheckVerification from './checkverification';
|
||||
import TicketResponse from './ticketresponse';
|
||||
import Checkout from './checkout';
|
||||
import StoragePage from './storage';
|
||||
import StorageBucketPage from './storage-bucket';
|
||||
import AdminPanel from './admin';
|
||||
import BlogAdmin from './blogadmin';
|
||||
import BlogEditor from './blogeditor';
|
||||
@@ -115,7 +115,6 @@ const Dashboard = () => {
|
||||
];
|
||||
const adminTabs = [
|
||||
{ key: 'checkverification', label: 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||
{ key: 'ticketresponse', label: 'Ответы на тикеты', to: '/dashboard/ticketresponse' },
|
||||
];
|
||||
|
||||
const superAdminTabs = [
|
||||
@@ -257,10 +256,11 @@ const Dashboard = () => {
|
||||
<Routes>
|
||||
<Route path="/" element={<Summary userData={userData ?? { user: { username: '', operator: 0 }, balance: 0, tickets: [] }} />} />
|
||||
<Route path="storage" element={<StoragePage />} />
|
||||
<Route path="checkout" element={<Checkout onSuccess={() => navigate('/dashboard/storage')} />} />
|
||||
<Route path="storage/:bucketId" element={<StorageBucketPage />} />
|
||||
<Route path="checkout" element={<Checkout />} />
|
||||
{userData && (
|
||||
<>
|
||||
<Route path="tickets" element={<TicketsPage setUserData={setUserData} />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="tickets/new" element={<NewTicketPage />} />
|
||||
</>
|
||||
@@ -273,7 +273,6 @@ const Dashboard = () => {
|
||||
{isOperator && (
|
||||
<>
|
||||
<Route path="checkverification" element={<CheckVerification />} />
|
||||
<Route path="ticketresponse" element={<TicketResponse />} />
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
|
||||
1720
ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx
Normal file
1720
ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx
Normal file
File diff suppressed because it is too large
Load Diff
85
ospabhost/frontend/src/pages/dashboard/storage-utils.ts
Normal file
85
ospabhost/frontend/src/pages/dashboard/storage-utils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { StorageBucket } from './types';
|
||||
|
||||
export interface StatusBadge {
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / Math.pow(1024, index);
|
||||
const digits = value >= 10 || index === 0 ? 0 : 2;
|
||||
return `${value.toFixed(digits)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null, withTime = false): string {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
const options: Intl.DateTimeFormatOptions = withTime
|
||||
? { dateStyle: 'short', timeStyle: 'short' }
|
||||
: { dateStyle: 'short' };
|
||||
return date.toLocaleString('ru-RU', options);
|
||||
}
|
||||
|
||||
export function getUsagePercent(usedBytes: number, quotaGb: number): number {
|
||||
if (!Number.isFinite(quotaGb) || quotaGb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const quotaBytes = quotaGb * 1024 * 1024 * 1024;
|
||||
if (quotaBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min((usedBytes / quotaBytes) * 100, 100);
|
||||
}
|
||||
|
||||
export function getPlanTone(plan: string): string {
|
||||
if (!plan) {
|
||||
return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
const normalized = plan.toLowerCase();
|
||||
const variants: Record<string, string> = {
|
||||
basic: 'bg-blue-100 text-blue-700',
|
||||
standard: 'bg-green-100 text-green-700',
|
||||
plus: 'bg-purple-100 text-purple-700',
|
||||
pro: 'bg-orange-100 text-orange-700',
|
||||
enterprise: 'bg-red-100 text-red-700',
|
||||
};
|
||||
return variants[normalized] ?? 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
|
||||
export function getStatusBadge(status: StorageBucket['status']): StatusBadge {
|
||||
const normalized = (status ?? '').toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'active':
|
||||
return { label: 'Активен', className: 'bg-green-100 text-green-700' };
|
||||
case 'creating':
|
||||
return { label: 'Создаётся', className: 'bg-blue-100 text-blue-700' };
|
||||
case 'suspended':
|
||||
return { label: 'Приостановлен', className: 'bg-yellow-100 text-yellow-700' };
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return { label: 'Ошибка', className: 'bg-red-100 text-red-700' };
|
||||
default:
|
||||
return { label: status ?? 'Неизвестно', className: 'bg-gray-100 text-gray-600' };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '—';
|
||||
}
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
@@ -1,89 +1,238 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiDatabase, FiPlus, FiInfo, FiTrash2, FiSettings, FiExternalLink } from 'react-icons/fi';
|
||||
import {
|
||||
FiDatabase,
|
||||
FiPlus,
|
||||
FiTrash2,
|
||||
FiSettings,
|
||||
FiExternalLink,
|
||||
FiRefreshCw,
|
||||
FiCheckCircle,
|
||||
FiAlertTriangle,
|
||||
FiInfo
|
||||
} from 'react-icons/fi';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { API_URL } from '../../config/api';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import type { StorageBucket } from './types';
|
||||
import {
|
||||
formatBytes,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
getPlanTone,
|
||||
getStatusBadge,
|
||||
getUsagePercent,
|
||||
} from './storage-utils';
|
||||
|
||||
interface StorageBucket {
|
||||
id: number;
|
||||
name: string;
|
||||
plan: string;
|
||||
quotaGb: number;
|
||||
usedBytes: number;
|
||||
objectCount: number;
|
||||
storageClass: string;
|
||||
region: string;
|
||||
public: boolean;
|
||||
versioning: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
type StorageRegionInfo = NonNullable<StorageBucket['regionDetails']>;
|
||||
type StorageClassInfo = NonNullable<StorageBucket['storageClassDetails']>;
|
||||
type StoragePlanInfo = NonNullable<StorageBucket['planDetails']>;
|
||||
|
||||
interface StorageStatus {
|
||||
minio: {
|
||||
connected: boolean;
|
||||
endpoint: string;
|
||||
bucketPrefix: string;
|
||||
availableBuckets: number;
|
||||
error: string | null;
|
||||
};
|
||||
defaults: {
|
||||
region: StorageRegionInfo | null;
|
||||
storageClass: StorageClassInfo | null;
|
||||
};
|
||||
plans: StoragePlanInfo[];
|
||||
regions: StorageRegionInfo[];
|
||||
classes: StorageClassInfo[];
|
||||
}
|
||||
|
||||
const StoragePage: React.FC = () => {
|
||||
const [buckets, setBuckets] = useState<StorageBucket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [status, setStatus] = useState<StorageStatus | null>(null);
|
||||
const [loadingBuckets, setLoadingBuckets] = useState(true);
|
||||
const [loadingStatus, setLoadingStatus] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { addToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [bucketActions, setBucketActions] = useState<Record<number, boolean>>({});
|
||||
|
||||
const fetchBuckets = useCallback(async (notify = false) => {
|
||||
try {
|
||||
setLoadingBuckets(true);
|
||||
const response = await apiClient.get<{ buckets: StorageBucket[] }>('/api/storage/buckets');
|
||||
setBuckets(response.data?.buckets ?? []);
|
||||
setError(null);
|
||||
if (notify) {
|
||||
addToast('Список бакетов обновлён', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Storage] Не удалось загрузить бакеты', err);
|
||||
setError('Не удалось загрузить список хранилищ');
|
||||
addToast('Не удалось получить список бакетов', 'error');
|
||||
} finally {
|
||||
setLoadingBuckets(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const fetchStatus = useCallback(async (notify = false) => {
|
||||
try {
|
||||
setLoadingStatus(true);
|
||||
const response = await apiClient.get<StorageStatus>('/api/storage/status');
|
||||
setStatus(response.data);
|
||||
if (notify && response.data.minio.connected) {
|
||||
addToast('Подключение к MinIO активно', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Storage] Не удалось получить статус', err);
|
||||
if (notify) {
|
||||
addToast('Не удалось обновить статус MinIO', 'warning');
|
||||
}
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
const setBucketBusy = useCallback((id: number, busy: boolean) => {
|
||||
setBucketActions((prev) => {
|
||||
if (busy) {
|
||||
return { ...prev, [id]: true };
|
||||
}
|
||||
if (!(id in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isBucketBusy = useCallback((id: number) => bucketActions[id] === true, [bucketActions]);
|
||||
|
||||
const handleDeleteBucket = useCallback(async (bucket: StorageBucket) => {
|
||||
if (!window.confirm(`Удалить бакет «${bucket.name}»?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteRequest = (force: boolean) => apiClient.delete(`/api/storage/buckets/${bucket.id}`, {
|
||||
params: force ? { force: true } : undefined,
|
||||
});
|
||||
|
||||
setBucketBusy(bucket.id, true);
|
||||
try {
|
||||
await deleteRequest(false);
|
||||
setBuckets((prev) => prev.filter((item) => item.id !== bucket.id));
|
||||
addToast(`Бакет «${bucket.name}» удалён`, 'success');
|
||||
fetchStatus();
|
||||
return;
|
||||
} catch (error) {
|
||||
let message = 'Не удалось удалить бакет';
|
||||
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
|
||||
message = error.response.data.error;
|
||||
}
|
||||
|
||||
const lower = message.toLowerCase();
|
||||
const requiresForce = lower.includes('непуст');
|
||||
|
||||
if (requiresForce) {
|
||||
const confirmForce = window.confirm(`${message}. Удалить принудительно? Все объекты будут удалены без восстановления.`);
|
||||
if (confirmForce) {
|
||||
try {
|
||||
await deleteRequest(true);
|
||||
setBuckets((prev) => prev.filter((item) => item.id !== bucket.id));
|
||||
addToast(`Бакет «${bucket.name}» удалён принудительно`, 'warning');
|
||||
fetchStatus();
|
||||
return;
|
||||
} catch (forceError) {
|
||||
let forceMessage = 'Не удалось удалить бакет принудительно';
|
||||
if (isAxiosError(forceError) && typeof forceError.response?.data?.error === 'string') {
|
||||
forceMessage = forceError.response.data.error;
|
||||
}
|
||||
addToast(forceMessage, 'error');
|
||||
}
|
||||
} else {
|
||||
addToast(message, 'warning');
|
||||
}
|
||||
} else {
|
||||
addToast(message, 'error');
|
||||
}
|
||||
} finally {
|
||||
setBucketBusy(bucket.id, false);
|
||||
}
|
||||
}, [addToast, fetchStatus, setBucketBusy]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBuckets();
|
||||
}, []);
|
||||
fetchStatus();
|
||||
}, [fetchBuckets, fetchStatus]);
|
||||
|
||||
const fetchBuckets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiClient.get(`${API_URL}/api/storage/buckets`);
|
||||
setBuckets(res.data.buckets || []);
|
||||
} catch (e) {
|
||||
console.error('Ошибка загрузки бакетов', e);
|
||||
setError('Не удалось загрузить список хранилищ');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const getUsagePercent = (usedBytes: number, quotaGb: number): number => {
|
||||
const quotaBytes = quotaGb * 1024 * 1024 * 1024;
|
||||
return quotaBytes > 0 ? Math.min((usedBytes / quotaBytes) * 100, 100) : 0;
|
||||
};
|
||||
|
||||
const getPlanColor = (plan: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
basic: 'text-blue-600 bg-blue-50',
|
||||
standard: 'text-green-600 bg-green-50',
|
||||
plus: 'text-purple-600 bg-purple-50',
|
||||
pro: 'text-orange-600 bg-orange-50',
|
||||
enterprise: 'text-red-600 bg-red-50'
|
||||
useEffect(() => {
|
||||
const handleBucketsRefresh = () => {
|
||||
fetchBuckets();
|
||||
fetchStatus();
|
||||
};
|
||||
return colors[plan] || 'text-gray-600 bg-gray-50';
|
||||
};
|
||||
|
||||
window.addEventListener('storageBucketsRefresh', handleBucketsRefresh);
|
||||
return () => {
|
||||
window.removeEventListener('storageBucketsRefresh', handleBucketsRefresh);
|
||||
};
|
||||
}, [fetchBuckets, fetchStatus]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalBuckets = buckets.length;
|
||||
const totalUsedBytes = buckets.reduce((acc, bucket) => acc + bucket.usedBytes, 0);
|
||||
const totalQuotaGb = buckets.reduce((acc, bucket) => acc + bucket.quotaGb, 0);
|
||||
const autoRenewCount = buckets.reduce((acc, bucket) => acc + (bucket.autoRenew ? 1 : 0), 0);
|
||||
const quotaBytes = totalQuotaGb * 1024 * 1024 * 1024;
|
||||
const globalUsagePercent = quotaBytes > 0 ? Math.min((totalUsedBytes / quotaBytes) * 100, 100) : 0;
|
||||
const minMonthlyPrice = buckets.reduce((min, bucket) => {
|
||||
const price = bucket.planDetails?.price ?? bucket.monthlyPrice;
|
||||
if (!Number.isFinite(price)) {
|
||||
return min;
|
||||
}
|
||||
return Math.min(min, Number(price));
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
|
||||
return {
|
||||
totalBuckets,
|
||||
totalUsedBytes,
|
||||
totalQuotaGb,
|
||||
autoRenewCount,
|
||||
globalUsagePercent,
|
||||
lowestPrice: Number.isFinite(minMonthlyPrice) ? minMonthlyPrice : null,
|
||||
};
|
||||
}, [buckets]);
|
||||
|
||||
const handleRefreshBuckets = useCallback(() => {
|
||||
fetchBuckets(true);
|
||||
}, [fetchBuckets]);
|
||||
|
||||
const handleRefreshStatus = useCallback(() => {
|
||||
fetchStatus(true);
|
||||
}, [fetchStatus]);
|
||||
|
||||
const handleOpenBucket = useCallback((id: number) => {
|
||||
navigate(`/dashboard/storage/${id}`);
|
||||
}, [navigate]);
|
||||
|
||||
const minioStatus = status?.minio;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
|
||||
<FiDatabase className="text-ospab-primary" />
|
||||
S3 Хранилище
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">Управление вашими объектными хранилищами</p>
|
||||
<p className="text-gray-600 mt-1">Управление объектными бакетами и статус облачного хранилища</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
className="px-5 py-2.5 bg-white border-2 border-ospab-primary text-ospab-primary rounded-lg font-semibold hover:bg-ospab-primary hover:text-white transition-all flex items-center gap-2"
|
||||
onClick={handleRefreshBuckets}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
|
||||
>
|
||||
<FiInfo />
|
||||
Тарифы
|
||||
<FiRefreshCw className={loadingBuckets ? 'animate-spin' : ''} />
|
||||
Обновить список
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
@@ -96,20 +245,116 @@ const StoragePage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-red-700">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-red-700 flex items-center gap-2">
|
||||
<FiAlertTriangle className="text-red-500" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
{minioStatus?.connected ? (
|
||||
<FiCheckCircle className="text-green-500 text-2xl" />
|
||||
) : (
|
||||
<FiAlertTriangle className="text-red-500 text-2xl" />
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800">Статус подключения MinIO</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{minioStatus?.connected ? 'Подключение установлено' : 'Нет связи с хранилищем. Попробуйте обновить статус.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefreshStatus}
|
||||
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
|
||||
>
|
||||
<FiRefreshCw className={loadingStatus ? 'animate-spin' : ''} />
|
||||
Проверить статус
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingStatus ? (
|
||||
<div className="px-6 py-8 text-sm text-gray-500">Проверяем подключение к MinIO...</div>
|
||||
) : status ? (
|
||||
<div className="px-6 py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-3 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<FiDatabase className="text-ospab-primary" />
|
||||
<span>Endpoint: <span className="font-semibold text-gray-800">{minioStatus?.endpoint || '—'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FiInfo className="text-ospab-primary" />
|
||||
<span>Префикс бакетов: <span className="font-semibold text-gray-800">{minioStatus?.bucketPrefix || '—'}</span></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FiInfo className="text-ospab-primary" />
|
||||
<span>Всего бакетов на сервере: <span className="font-semibold text-gray-800">{minioStatus?.availableBuckets ?? '—'}</span></span>
|
||||
</div>
|
||||
{minioStatus?.error && !minioStatus.connected && (
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<FiAlertTriangle />
|
||||
<span className="font-medium">{minioStatus.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 space-y-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Регион по умолчанию</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.region?.name ?? 'Не выбран'}</p>
|
||||
<p className="text-xs text-gray-500">{status.defaults.region?.endpoint ?? status.defaults.region?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения по умолчанию</p>
|
||||
<p className="text-gray-800 font-semibold">{status.defaults.storageClass?.name ?? 'Не выбран'}</p>
|
||||
<p className="text-xs text-gray-500">{status.defaults.storageClass?.description ?? status.defaults.storageClass?.code ?? '—'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<span>Активных тарифов: <span className="font-semibold text-gray-800">{status.plans.length}</span></span>
|
||||
<span>Регионов: <span className="font-semibold text-gray-800">{status.regions.length}</span></span>
|
||||
<span>Классов хранения: <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-8 text-sm text-gray-500 flex items-center gap-2">
|
||||
<FiInfo />
|
||||
Нет данных о статусе хранилища. Попробуйте обновить.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Всего бакетов</p>
|
||||
<p className="text-3xl font-bold text-gray-800">{summary.totalBuckets}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Автопродление активировано: {summary.autoRenewCount}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Использовано данных</p>
|
||||
<p className="text-2xl font-semibold text-gray-800">{formatBytes(summary.totalUsedBytes)}</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Глобальная загрузка: {summary.globalUsagePercent.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-md p-5">
|
||||
<p className="text-xs uppercase text-gray-500 mb-2">Суммарная квота</p>
|
||||
<p className="text-2xl font-semibold text-gray-800">{summary.totalQuotaGb} GB</p>
|
||||
<p className="text-xs text-gray-500 mt-2">Мин. ежемесячный тариф: {summary.lowestPrice !== null ? formatCurrency(summary.lowestPrice) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingBuckets ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary"></div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary" />
|
||||
</div>
|
||||
) : buckets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<FiDatabase className="text-6xl text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">Нет активных хранилищ</h3>
|
||||
<p className="text-gray-600 mb-6">Создайте ваш первый S3 бакет для хранения файлов, резервных копий и медиа-контента</p>
|
||||
<p className="text-gray-600 mb-6">Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.</p>
|
||||
<button
|
||||
onClick={() => navigate('/tariffs')}
|
||||
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 shadow-lg transition-all inline-flex items-center gap-2"
|
||||
@@ -122,85 +367,127 @@ const StoragePage: React.FC = () => {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{buckets.map((bucket) => {
|
||||
const usagePercent = getUsagePercent(bucket.usedBytes, bucket.quotaGb);
|
||||
const statusBadge = getStatusBadge(bucket.status);
|
||||
const planName = bucket.planDetails?.name ?? bucket.plan;
|
||||
const planTone = getPlanTone(bucket.planDetails?.code ?? bucket.plan);
|
||||
const rawPrice = bucket.planDetails?.price ?? bucket.monthlyPrice;
|
||||
const price = Number.isFinite(rawPrice) ? Number(rawPrice) : null;
|
||||
const busy = isBucketBusy(bucket.id);
|
||||
|
||||
return (
|
||||
<div key={bucket.id} className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow">
|
||||
<div key={bucket.id} className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-ospab-primary/10 p-3 rounded-lg">
|
||||
<FiDatabase className="text-ospab-primary text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-800">{bucket.name}</h3>
|
||||
<span className={`inline-block px-2 py-1 text-xs font-semibold rounded-full ${getPlanColor(bucket.plan)}`}>
|
||||
{bucket.plan}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-gray-800">{bucket.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${planTone}`}>
|
||||
{planName}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${statusBadge.className}`}>
|
||||
{statusBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">ID бакета: {bucket.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<FiSettings />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenBucket(bucket.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${busy ? 'cursor-not-allowed text-gray-400' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
title="Управление бакетом"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <FiRefreshCw className="animate-spin" /> : <FiSettings />}
|
||||
</button>
|
||||
<button className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
<FiTrash2 />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteBucket(bucket)}
|
||||
className={`p-2 rounded-lg transition-colors ${busy ? 'cursor-not-allowed text-red-300' : 'text-red-600 hover:bg-red-50'}`}
|
||||
title="Удалить бакет"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <FiRefreshCw className="animate-spin" /> : <FiTrash2 />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Использовано: {formatBytes(bucket.usedBytes)}</span>
|
||||
<span>Квота: {bucket.quotaGb} GB</span>
|
||||
<div className="mt-5 space-y-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<div className="flex justify-between mb-1 text-xs uppercase text-gray-500">
|
||||
<span>Использовано</span>
|
||||
<span>
|
||||
{formatBytes(bucket.usedBytes)} из {bucket.quotaGb} GB
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
usagePercent > 90 ? 'bg-red-500' : usagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{usagePercent.toFixed(1)}% от квоты</p>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
usagePercent > 90 ? 'bg-red-500' : usagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{usagePercent.toFixed(1)}% использовано</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-ospab-primary">{bucket.objectCount}</p>
|
||||
<p className="text-xs text-gray-500">Объектов</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-gray-700">{bucket.region}</p>
|
||||
<p className="text-xs text-gray-500">Регион</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-semibold text-gray-700">{bucket.storageClass}</p>
|
||||
<p className="text-xs text-gray-500">Класс</p>
|
||||
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Объектов: <span className="font-semibold text-gray-700">{bucket.objectCount}</span>
|
||||
</span>
|
||||
<span>
|
||||
Тариф: <span className="font-semibold text-gray-700">{planName}</span>
|
||||
</span>
|
||||
{price !== null ? (
|
||||
<span>
|
||||
Ежемесячно: <span className="font-semibold text-gray-700">{formatCurrency(price)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
Синхронизация: <span className="font-semibold text-gray-700">{formatDate(bucket.usageSyncedAt, true)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap mt-4 text-xs">
|
||||
{bucket.public && (
|
||||
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 text-xs font-semibold rounded-full">
|
||||
Публичный
|
||||
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 font-semibold rounded-full">
|
||||
Публичный доступ
|
||||
</span>
|
||||
)}
|
||||
{bucket.versioning && (
|
||||
<span className="inline-flex items-center px-2 py-1 bg-purple-100 text-purple-700 text-xs font-semibold rounded-full">
|
||||
<span className="inline-flex items-center px-2 py-1 bg-purple-100 text-purple-700 font-semibold rounded-full">
|
||||
Версионирование
|
||||
</span>
|
||||
)}
|
||||
{bucket.autoRenew && (
|
||||
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 font-semibold rounded-full">
|
||||
Автопродление
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-between items-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Создан: {new Date(bucket.createdAt).toLocaleDateString('ru-RU')}
|
||||
</p>
|
||||
<button className="text-ospab-primary hover:text-ospab-primary/80 font-semibold text-sm flex items-center gap-1">
|
||||
Открыть <FiExternalLink />
|
||||
<div className="px-6 py-4 bg-gray-50 flex flex-col gap-2 text-xs text-gray-500 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<span>
|
||||
Создан: <span className="font-semibold text-gray-700">{formatDate(bucket.createdAt)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Следующее списание: <span className="font-semibold text-gray-700">{formatDate(bucket.nextBillingDate)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleOpenBucket(bucket.id)}
|
||||
className="text-ospab-primary hover:text-ospab-primary/80 font-semibold text-sm flex items-center gap-1"
|
||||
>
|
||||
Открыть
|
||||
<FiExternalLink />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
|
||||
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 res = await apiClient.get('/api/ticket');
|
||||
const data = Array.isArray(res.data) ? res.data : res.data?.tickets;
|
||||
setTickets(data || []);
|
||||
} catch {
|
||||
setError('Ошибка загрузки тикетов');
|
||||
setTickets([]);
|
||||
}
|
||||
};
|
||||
|
||||
const respondTicket = async (ticketId: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post('/api/ticket/respond', {
|
||||
ticketId,
|
||||
message: responseMsg[ticketId]
|
||||
});
|
||||
setResponseMsg(prev => ({ ...prev, [ticketId]: '' }));
|
||||
fetchTickets();
|
||||
} catch {
|
||||
setError('Ошибка отправки ответа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция закрытия тикета
|
||||
const closeTicket = async (ticketId: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post('/api/ticket/close', { ticketId });
|
||||
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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default TicketResponse;
|
||||
@@ -68,38 +68,36 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт' }
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.open;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно', emoji: '🔴' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий', emoji: '🟠' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный', emoji: '⚪' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий', emoji: '🟢' }
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий' }
|
||||
};
|
||||
|
||||
const badge = badges[priority] || badges.normal;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -216,7 +214,6 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
{/* Tickets Grid */}
|
||||
{tickets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<div className="text-6xl mb-4">📭</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
|
||||
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
|
||||
<button
|
||||
@@ -253,19 +250,10 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🕒</span>
|
||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>💬</span>
|
||||
<span>{ticket.responses?.length || 0} ответов</span>
|
||||
</span>
|
||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||
<span>{ticket.responses?.length || 0} ответов</span>
|
||||
{ticket.closedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🔒</span>
|
||||
<span>Закрыт</span>
|
||||
</span>
|
||||
<span>Закрыт</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
||||
|
||||
@@ -1,138 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
|
||||
interface Ticket {
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
username: string;
|
||||
operator: boolean;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
interface TicketAttachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TicketResponse {
|
||||
id: number;
|
||||
message: string;
|
||||
isInternal: boolean;
|
||||
createdAt: string;
|
||||
author: TicketAuthor | null;
|
||||
attachments: TicketAttachment[];
|
||||
}
|
||||
|
||||
interface TicketDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
user: TicketAuthor | null;
|
||||
assignedTo: number | null;
|
||||
assignedOperator: TicketAuthor | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
closedAt?: string;
|
||||
assignedTo?: number;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
closedAt: string | null;
|
||||
responseCount: number;
|
||||
lastResponseAt: string | null;
|
||||
attachments: TicketAttachment[];
|
||||
responses: TicketResponse[];
|
||||
}
|
||||
|
||||
interface Response {
|
||||
id: number;
|
||||
message: string;
|
||||
isInternal: boolean;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
operator: boolean;
|
||||
};
|
||||
}
|
||||
const STATUS_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
open: { text: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { text: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { text: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { text: 'Решён', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { text: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const TicketDetailPage: React.FC = () => {
|
||||
const PRIORITY_LABELS: Record<string, { text: string; badge: string }> = {
|
||||
urgent: { text: 'Срочно', badge: 'bg-red-100 text-red-800' },
|
||||
high: { text: 'Высокий', badge: 'bg-orange-100 text-orange-800' },
|
||||
normal: { text: 'Обычный', badge: 'bg-gray-100 text-gray-800' },
|
||||
low: { text: 'Низкий', badge: 'bg-green-100 text-green-800' },
|
||||
};
|
||||
|
||||
const TicketDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
fetchTicket();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
const currentUserId = userData?.user?.id ?? null;
|
||||
|
||||
const [ticket, setTicket] = useState<TicketDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [reply, setReply] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [statusProcessing, setStatusProcessing] = useState(false);
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [isInternalNote, setIsInternalNote] = useState(false);
|
||||
|
||||
const ticketId = Number(id);
|
||||
|
||||
const fetchTicket = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/ticket/${id}`);
|
||||
|
||||
setTicket(response.data.ticket);
|
||||
setResponses(response.data.ticket.responses || []);
|
||||
if (!ticketId) {
|
||||
setError('Некорректный идентификатор тикета');
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки тикета:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/api/ticket/${ticketId}`);
|
||||
const payload: TicketDetail | null = response.data?.ticket ?? null;
|
||||
setTicket(payload);
|
||||
} catch (err) {
|
||||
console.error('Ошибка загрузки тикета:', err);
|
||||
setError('Не удалось загрузить тикет');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendResponse = async () => {
|
||||
if (!newMessage.trim()) return;
|
||||
useEffect(() => {
|
||||
fetchTicket();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticketId]);
|
||||
|
||||
const formatDateTime = (value: string | null) => {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
try {
|
||||
return new Date(value).toLocaleString('ru-RU');
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendReply = async () => {
|
||||
if (!ticketId || !reply.trim()) {
|
||||
setReply((prev) => prev.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/respond', {
|
||||
ticketId: id,
|
||||
message: newMessage
|
||||
ticketId,
|
||||
message: reply.trim(),
|
||||
...(isOperator ? { isInternal: isInternalNote } : {}),
|
||||
});
|
||||
|
||||
setNewMessage('');
|
||||
|
||||
setReply('');
|
||||
setIsInternalNote(false);
|
||||
addToast('Ответ отправлен', 'success');
|
||||
fetchTicket();
|
||||
} catch (error) {
|
||||
console.error('Ошибка отправки ответа:', error);
|
||||
alert('Не удалось отправить ответ');
|
||||
} catch (err) {
|
||||
console.error('Ошибка отправки ответа:', err);
|
||||
addToast('Не удалось отправить ответ', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTicket = async () => {
|
||||
if (!confirm('Вы уверены, что хотите закрыть этот тикет?')) return;
|
||||
const handleCloseTicket = async () => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const confirmation = window.confirm('Вы уверены, что хотите закрыть тикет?');
|
||||
if (!confirmation) return;
|
||||
|
||||
setStatusProcessing(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/close', { ticketId: id });
|
||||
|
||||
await apiClient.post('/api/ticket/close', { ticketId });
|
||||
addToast('Тикет закрыт', 'success');
|
||||
fetchTicket();
|
||||
alert('Тикет успешно закрыт');
|
||||
} catch (error) {
|
||||
console.error('Ошибка закрытия тикета:', error);
|
||||
alert('Не удалось закрыть тикет');
|
||||
} catch (err) {
|
||||
console.error('Ошибка закрытия тикета:', err);
|
||||
addToast('Не удалось закрыть тикет', 'error');
|
||||
} finally {
|
||||
setStatusProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
|
||||
};
|
||||
const handleUpdateStatus = async (status: string) => {
|
||||
if (!ticketId) return;
|
||||
|
||||
const badge = badges[status] || badges.open;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
</span>
|
||||
);
|
||||
setStatusProcessing(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/status', { ticketId, status });
|
||||
addToast('Статус обновлён', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка изменения статуса:', err);
|
||||
addToast('Не удалось изменить статус', 'error');
|
||||
} finally {
|
||||
setStatusProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const badges: Record<string, { color: string; text: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800', text: 'Срочно 🔴' },
|
||||
high: { color: 'bg-orange-100 text-orange-800', text: 'Высокий 🟠' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800', text: 'Обычный ⚪' },
|
||||
low: { color: 'bg-green-100 text-green-800', text: 'Низкий 🟢' }
|
||||
};
|
||||
const handleAssignToMe = async () => {
|
||||
if (!ticketId || !currentUserId) return;
|
||||
|
||||
const badge = badges[priority] || badges.normal;
|
||||
|
||||
setAssigning(true);
|
||||
try {
|
||||
await apiClient.post('/api/ticket/assign', { ticketId, operatorId: currentUserId });
|
||||
addToast('Тикет назначен на вас', 'success');
|
||||
fetchTicket();
|
||||
} catch (err) {
|
||||
console.error('Ошибка назначения тикета:', err);
|
||||
addToast('Не удалось назначить тикет', 'error');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusChip = useMemo(() => {
|
||||
if (!ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = STATUS_LABELS[ticket.status] ?? STATUS_LABELS.open;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${badge.color}`}>
|
||||
{badge.text}
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${meta.badge}`}>
|
||||
<span>{meta.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}, [ticket]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка тикета...</p>
|
||||
<div className="mx-auto h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="mt-4 text-sm text-gray-600">Загрузка тикета...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-red-600">
|
||||
<h2 className="text-lg font-semibold">Ошибка</h2>
|
||||
<p className="mt-2 text-sm">{error}</p>
|
||||
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-red-700">
|
||||
← Вернуться к тикетам
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -140,130 +239,212 @@ const TicketDetailPage: React.FC = () => {
|
||||
|
||||
if (!ticket) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Тикет не найден</h2>
|
||||
<Link
|
||||
to="/dashboard/tickets"
|
||||
className="text-blue-500 hover:text-blue-600 font-medium"
|
||||
>
|
||||
← Вернуться к списку тикетов
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-md rounded-2xl border border-gray-200 bg-white p-6 text-center shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Тикет не найден</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">Возможно, он был удалён или у вас нет доступа.</p>
|
||||
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-blue-600">
|
||||
← Вернуться к списку
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const priorityMeta = PRIORITY_LABELS[ticket.priority] ?? PRIORITY_LABELS.normal;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
to="/dashboard/tickets"
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition hover:text-gray-800"
|
||||
>
|
||||
<span>←</span>
|
||||
<span>Назад к тикетам</span>
|
||||
</Link>
|
||||
← Назад
|
||||
</button>
|
||||
|
||||
{/* Ticket Header */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">{ticket.title}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(ticket.status)}
|
||||
{getPriorityBadge(ticket.priority)}
|
||||
<span className="text-sm text-gray-600">
|
||||
Категория: {ticket.category}
|
||||
<h1 className="text-2xl font-bold text-gray-900">{ticket.title}</h1>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
{statusChip}
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${priorityMeta.badge}`}>
|
||||
{priorityMeta.text}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">Категория: {ticket.category}</span>
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
|
||||
{ticket.assignedOperator.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="flex flex-col gap-2 text-sm text-gray-600">
|
||||
<span>Создан: {formatDateTime(ticket.createdAt)}</span>
|
||||
<span>Обновлён: {formatDateTime(ticket.updatedAt)}</span>
|
||||
{ticket.closedAt && <span>Закрыт: {formatDateTime(ticket.closedAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-xl border border-gray-100 bg-gray-50 p-5 text-gray-700">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{ticket.message}</p>
|
||||
</div>
|
||||
|
||||
{ticket.attachments.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Вложенные файлы</h3>
|
||||
<ul className="mt-2 flex flex-wrap gap-3 text-sm text-blue-600">
|
||||
{ticket.attachments.map((attachment) => (
|
||||
<li key={attachment.id}>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
|
||||
📎 {attachment.filename}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||
{isOperator && ticket.status !== 'closed' && ticket.assignedTo !== currentUserId && (
|
||||
<button
|
||||
onClick={closeTicket}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200"
|
||||
type="button"
|
||||
onClick={handleAssignToMe}
|
||||
disabled={assigning}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Закрыть тикет
|
||||
{assigning ? 'Назначаю...' : 'Взять в работу'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{ticket.status !== 'closed' && (
|
||||
<>
|
||||
{isOperator && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateStatus('resolved')}
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-green-200 px-4 py-2 text-sm font-semibold text-green-600 transition hover:border-green-300 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{statusProcessing ? 'Сохранение...' : 'Отметить как решён'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCloseTicket}
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Закрыть тикет
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOperator && ticket.status === 'closed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateStatus('in_progress')}
|
||||
disabled={statusProcessing}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Возобновить работу
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{ticket.message}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">История общения</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
{ticket.responses.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
|
||||
Ответов пока нет. Напишите первое сообщение, чтобы ускорить решение.
|
||||
</p>
|
||||
) : (
|
||||
ticket.responses.map((response) => {
|
||||
const isCurrentUser = response.author?.id === currentUserId;
|
||||
const isResponseOperator = Boolean(response.author?.operator);
|
||||
|
||||
<div className="flex items-center gap-4 mt-4 text-sm text-gray-600">
|
||||
<span>Создан: {new Date(ticket.createdAt).toLocaleString('ru-RU')}</span>
|
||||
{ticket.closedAt && (
|
||||
<span>Закрыт: {new Date(ticket.closedAt).toLocaleString('ru-RU')}</span>
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className={`rounded-xl border border-gray-100 p-5 ${
|
||||
response.isInternal ? 'bg-yellow-50 border-yellow-200' : isCurrentUser ? 'bg-blue-50 border-blue-100' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-900">{response.author?.username ?? 'Неизвестно'}</span>
|
||||
{isResponseOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
|
||||
Оператор
|
||||
</span>
|
||||
)}
|
||||
{response.isInternal && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-700">
|
||||
Внутренний комментарий
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{formatDateTime(response.createdAt)}</span>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-800">{response.message}</p>
|
||||
|
||||
{response.attachments.length > 0 && (
|
||||
<ul className="mt-3 flex flex-wrap gap-2 text-sm text-blue-600">
|
||||
{response.attachments.map((attachment) => (
|
||||
<li key={attachment.id}>
|
||||
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
|
||||
📎 {attachment.filename}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Responses */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{responses.map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className={`bg-white rounded-xl shadow-md p-6 ${
|
||||
response.isInternal ? 'bg-yellow-50 border-2 border-yellow-200' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
|
||||
{response.user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{response.user.username}
|
||||
</span>
|
||||
{response.user.operator && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
⭐ Оператор
|
||||
</span>
|
||||
)}
|
||||
{response.isInternal && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
🔒 Внутренний комментарий
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(response.createdAt).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{response.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New Response Form */}
|
||||
{ticket.status !== 'closed' && (
|
||||
<div className="bg-white rounded-xl shadow-md p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Добавить ответ</h3>
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Новый ответ</h2>
|
||||
<textarea
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
placeholder="Введите ваш ответ..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={5}
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
placeholder="Опишите детали, приложите решение или уточнение..."
|
||||
className="mt-3 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
rows={6}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-3 mt-4">
|
||||
|
||||
{isOperator && (
|
||||
<label className="mt-3 inline-flex items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isInternalNote}
|
||||
onChange={(event) => setIsInternalNote(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Внутренний комментарий (видно только операторам)
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setNewMessage('')}
|
||||
className="px-6 py-2 text-gray-700 hover:text-gray-900 font-medium transition-colors"
|
||||
disabled={sending}
|
||||
type="button"
|
||||
onClick={() => setReply('')}
|
||||
disabled={sending || reply.length === 0}
|
||||
className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
<button
|
||||
onClick={sendResponse}
|
||||
disabled={sending || !newMessage.trim()}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
onClick={handleSendReply}
|
||||
disabled={sending || !reply.trim()}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить'}
|
||||
</button>
|
||||
|
||||
@@ -1,167 +1,250 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import AuthContext from '../../../context/authcontext';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
|
||||
interface Ticket {
|
||||
interface TicketAuthor {
|
||||
id: number;
|
||||
username: string;
|
||||
operator: boolean;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
interface TicketAttachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TicketResponse {
|
||||
id: number;
|
||||
message: string;
|
||||
isInternal: boolean;
|
||||
createdAt: string;
|
||||
author: TicketAuthor | null;
|
||||
attachments: TicketAttachment[];
|
||||
}
|
||||
|
||||
interface TicketItem {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
user: TicketAuthor | null;
|
||||
assignedTo: number | null;
|
||||
assignedOperator: TicketAuthor | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
responses: Response[];
|
||||
assignedTo?: number;
|
||||
closedAt?: string;
|
||||
closedAt: string | null;
|
||||
responseCount: number;
|
||||
lastResponseAt: string | null;
|
||||
attachments: TicketAttachment[];
|
||||
responses: TicketResponse[];
|
||||
}
|
||||
|
||||
interface Response {
|
||||
id: number;
|
||||
message: string;
|
||||
isInternal: boolean;
|
||||
createdAt: string;
|
||||
userId: number;
|
||||
user: {
|
||||
username: string;
|
||||
operator: boolean;
|
||||
};
|
||||
interface TicketListMeta {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
const TicketsPage: React.FC = () => {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
interface TicketStats {
|
||||
open: number;
|
||||
inProgress: number;
|
||||
awaitingReply: number;
|
||||
resolved: number;
|
||||
closed: number;
|
||||
assignedToMe?: number;
|
||||
unassigned?: number;
|
||||
}
|
||||
|
||||
const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
open: { label: 'Открыт', badge: 'bg-green-100 text-green-800' },
|
||||
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
|
||||
awaiting_reply: { label: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
|
||||
resolved: { label: 'Решён', badge: 'bg-purple-100 text-purple-800' },
|
||||
closed: { label: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
|
||||
};
|
||||
|
||||
const PRIORITY_DICTIONARY: Record<string, { label: string; badge: string }> = {
|
||||
urgent: { label: 'Срочно', badge: 'bg-red-50 text-red-700 border border-red-200' },
|
||||
high: { label: 'Высокий', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
|
||||
normal: { label: 'Обычный', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
low: { label: 'Низкий', badge: 'bg-green-50 text-green-700 border border-green-200' },
|
||||
};
|
||||
|
||||
|
||||
|
||||
const TicketsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { userData } = useContext(AuthContext);
|
||||
const { addToast } = useToast();
|
||||
const isOperator = Boolean(userData?.user?.operator);
|
||||
|
||||
const [tickets, setTickets] = useState<TicketItem[]>([]);
|
||||
const [meta, setMeta] = useState<TicketListMeta>({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false });
|
||||
const [stats, setStats] = useState<TicketStats>({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
status: 'all',
|
||||
category: 'all',
|
||||
priority: 'all'
|
||||
priority: 'all',
|
||||
assigned: 'all',
|
||||
});
|
||||
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Debounce search input to avoid flooding the API while typing
|
||||
const timer = window.setTimeout(() => {
|
||||
setDebouncedSearch(searchInput.trim());
|
||||
}, 350);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setMeta((prev) => (prev.page === 1 ? prev : { ...prev, page: 1 }));
|
||||
}, [filters.status, filters.category, filters.priority, filters.assigned, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchTickets = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
page: meta.page,
|
||||
pageSize: meta.pageSize,
|
||||
};
|
||||
|
||||
if (filters.status !== 'all') params.status = filters.status;
|
||||
if (filters.category !== 'all') params.category = filters.category;
|
||||
if (filters.priority !== 'all') params.priority = filters.priority;
|
||||
if (debouncedSearch) params.search = debouncedSearch;
|
||||
if (isOperator && filters.assigned !== 'all') params.assigned = filters.assigned;
|
||||
|
||||
const response = await apiClient.get('/api/ticket', { params });
|
||||
if (!isMounted) return;
|
||||
|
||||
const payload = response.data ?? {};
|
||||
setTickets(Array.isArray(payload.tickets) ? payload.tickets : []);
|
||||
setMeta((prev) => ({
|
||||
...prev,
|
||||
...(payload.meta ?? {}),
|
||||
}));
|
||||
setStats(payload.stats ?? { open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
console.error('Ошибка загрузки тикетов:', err);
|
||||
setError('Не удалось загрузить тикеты');
|
||||
addToast('Не удалось загрузить тикеты. Попробуйте позже.', 'error');
|
||||
setTickets([]);
|
||||
setMeta((prev) => ({ ...prev, page: 1, total: 0, totalPages: 1, hasMore: false }));
|
||||
setStats({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchTickets();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters]);
|
||||
}, [meta.page, meta.pageSize, filters.status, filters.category, filters.priority, filters.assigned, debouncedSearch, isOperator]);
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
if (filters.status !== 'all') params.status = filters.status;
|
||||
if (filters.category !== 'all') params.category = filters.category;
|
||||
if (filters.priority !== 'all') params.priority = filters.priority;
|
||||
|
||||
const response = await apiClient.get('/api/ticket', { params });
|
||||
|
||||
setTickets(response.data.tickets || []);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки тикетов:', error);
|
||||
setLoading(false);
|
||||
const formatRelativeTime = (dateString: string | null) => {
|
||||
if (!dateString) {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
|
||||
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
|
||||
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
|
||||
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
|
||||
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.open;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getPriorityBadge = (priority: string) => {
|
||||
const badges: Record<string, { color: string; text: string; emoji: string }> = {
|
||||
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно', emoji: '🔴' },
|
||||
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий', emoji: '🟠' },
|
||||
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный', emoji: '⚪' },
|
||||
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий', emoji: '🟢' }
|
||||
};
|
||||
|
||||
const badge = badges[priority] || badges.normal;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
|
||||
<span>{badge.emoji}</span>
|
||||
<span>{badge.text}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
general: '💬',
|
||||
technical: '⚙️',
|
||||
billing: '💰',
|
||||
other: '📝'
|
||||
};
|
||||
|
||||
return icons[category] || icons.general;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
if (diffMinutes < 1) return 'только что';
|
||||
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн назад`;
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка тикетов...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const statusCards = useMemo(() => {
|
||||
if (isOperator) {
|
||||
return [
|
||||
{ title: 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ title: 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
|
||||
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
|
||||
{ title: 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
|
||||
];
|
||||
}, [isOperator, stats]);
|
||||
|
||||
const handleChangePage = (nextPage: number) => {
|
||||
setMeta((prev) => ({ ...prev, page: nextPage }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Управляйте вашими обращениями в службу поддержки</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Тикеты поддержки</h1>
|
||||
<p className="text-gray-600">Создавайте обращения и следите за их обработкой в режиме реального времени.</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/dashboard/tickets/new"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
|
||||
<button
|
||||
onClick={() => navigate('/dashboard/tickets/new')}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
<span>➕</span>
|
||||
Создать тикет
|
||||
</Link>
|
||||
Новый тикет
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Статус</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{statusCards.map((card) => (
|
||||
<div key={card.title} className={`rounded-xl p-4 shadow-sm ${card.accent}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-semibold">{card.title}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-3xl font-bold">{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white p-6 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Статус</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="open">Открыт</option>
|
||||
@@ -171,14 +254,12 @@ const TicketsPage: React.FC = () => {
|
||||
<option value="closed">Закрыт</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Категория</label>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Категория</label>
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, category: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все категории</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
@@ -187,14 +268,12 @@ const TicketsPage: React.FC = () => {
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Приоритет</label>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Приоритет</label>
|
||||
<select
|
||||
value={filters.priority}
|
||||
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все приоритеты</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
@@ -203,71 +282,147 @@ const TicketsPage: React.FC = () => {
|
||||
<option value="low">Низкий</option>
|
||||
</select>
|
||||
</div>
|
||||
{isOperator && (
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Назначение</label>
|
||||
<select
|
||||
value={filters.assigned}
|
||||
onChange={(event) => setFilters((prev) => ({ ...prev, assigned: event.target.value }))}
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="me">Мои тикеты</option>
|
||||
<option value="unassigned">Без оператора</option>
|
||||
<option value="others">Назначены другим</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className={isOperator ? 'lg:col-span-4' : 'lg:col-span-6'}>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Поиск</label>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="Поиск по теме или описанию..."
|
||||
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tickets Grid */}
|
||||
{tickets.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<div className="text-6xl mb-4">📭</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
|
||||
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
|
||||
<Link
|
||||
to="/dashboard/tickets/new"
|
||||
className="inline-block bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
|
||||
>
|
||||
Создать первый тикет
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{tickets.map((ticket) => (
|
||||
<div className="rounded-2xl bg-white shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
<p className="text-sm text-gray-500">Загрузка тикетов...</p>
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Тикетов пока нет</h3>
|
||||
<p className="max-w-md text-sm text-gray-500">
|
||||
Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.
|
||||
</p>
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to={`/dashboard/tickets/${ticket.id}`}
|
||||
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-all duration-200 overflow-hidden"
|
||||
to="/dashboard/tickets/new"
|
||||
className="rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">{getCategoryIcon(ticket.category)}</span>
|
||||
<h3 className="text-xl font-semibold text-gray-900">{ticket.title}</h3>
|
||||
{getPriorityBadge(ticket.priority)}
|
||||
</div>
|
||||
<p className="text-gray-600 line-clamp-2">{ticket.message.substring(0, 150)}...</p>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
{getStatusBadge(ticket.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🕒</span>
|
||||
<span>{formatRelativeTime(ticket.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span>💬</span>
|
||||
<span>{ticket.responses?.length || 0} ответов</span>
|
||||
</span>
|
||||
{ticket.closedAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>🔒</span>
|
||||
<span>Закрыт</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-blue-500 hover:text-blue-600 font-medium">
|
||||
Открыть →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
Создать первый тикет
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden w-full grid-cols-[100px_1fr_160px_160px_160px] gap-4 border-b border-gray-100 px-6 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 lg:grid">
|
||||
<span>ID</span>
|
||||
<span>Тема</span>
|
||||
<span>Статус</span>
|
||||
<span>Приоритет</span>
|
||||
<span>Обновлён</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{tickets.map((ticket) => {
|
||||
const statusMeta = STATUS_DICTIONARY[ticket.status] ?? STATUS_DICTIONARY.open;
|
||||
const priorityMeta = PRIORITY_DICTIONARY[ticket.priority] ?? PRIORITY_DICTIONARY.normal;
|
||||
|
||||
return (
|
||||
<li key={ticket.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/dashboard/tickets/${ticket.id}`)}
|
||||
className="w-full px-6 py-4 text-left transition hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[100px_1fr_160px_160px_160px] lg:items-center lg:gap-4">
|
||||
<span className="text-sm font-semibold text-gray-500">#{ticket.id}</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-base font-semibold text-gray-900">
|
||||
<span className="line-clamp-1">{ticket.title}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{ticket.message}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
{ticket.assignedOperator && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">
|
||||
{ticket.assignedOperator.username}
|
||||
</span>
|
||||
)}
|
||||
{ticket.responseCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-600">
|
||||
{ticket.responseCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500">
|
||||
{ticket.user?.username ?? 'Неизвестно'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center justify-start rounded-full px-3 py-1 text-xs font-semibold ${statusMeta.badge}`}>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
<span className={`inline-flex items-center justify-start rounded-full px-3 py-1 text-xs font-semibold ${priorityMeta.badge}`}>
|
||||
{priorityMeta.label}
|
||||
</span>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>{formatRelativeTime(ticket.updatedAt)}</div>
|
||||
{ticket.lastResponseAt && (
|
||||
<div className="text-xs text-gray-400">Ответ: {formatRelativeTime(ticket.lastResponseAt)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-gray-100 px-6 py-4 text-sm text-gray-600 md:flex-row">
|
||||
<span>
|
||||
Показано {(meta.page - 1) * meta.pageSize + 1}–
|
||||
{Math.min(meta.page * meta.pageSize, meta.total)} из {meta.total}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangePage(Math.max(1, meta.page - 1))}
|
||||
disabled={meta.page === 1}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1 font-medium text-gray-600 transition disabled:cursor-not-allowed disabled:opacity-40 hover:bg-gray-100"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="px-2 text-sm">Стр. {meta.page} / {meta.totalPages}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleChangePage(meta.page + 1)}
|
||||
disabled={!meta.hasMore}
|
||||
className="rounded-lg border border-gray-200 px-3 py-1 font-medium text-gray-600 transition disabled:cursor-not-allowed disabled:opacity-40 hover:bg-gray-100"
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import apiClient from '../../../utils/apiClient';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
|
||||
const NewTicketPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { addToast } = useToast();
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
message: '',
|
||||
@@ -28,10 +30,13 @@ const NewTicketPage: React.FC = () => {
|
||||
const response = await apiClient.post('/api/ticket/create', formData);
|
||||
|
||||
// Перенаправляем на созданный тикет
|
||||
addToast('Тикет создан и отправлен в поддержку', 'success');
|
||||
navigate(`/dashboard/tickets/${response.data.ticket.id}`);
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания тикета:', err);
|
||||
setError('Не удалось создать тикет. Попробуйте ещё раз.');
|
||||
addToast('Не удалось создать тикет', 'error');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
@@ -86,10 +91,10 @@ const NewTicketPage: React.FC = () => {
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="general">💬 Общие вопросы</option>
|
||||
<option value="technical">⚙️ Технические</option>
|
||||
<option value="billing">💰 Биллинг</option>
|
||||
<option value="other">📝 Другое</option>
|
||||
<option value="general">Общие вопросы</option>
|
||||
<option value="technical">Технические</option>
|
||||
<option value="billing">Биллинг</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -103,10 +108,10 @@ const NewTicketPage: React.FC = () => {
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="low">🟢 Низкий</option>
|
||||
<option value="normal">⚪ Обычный</option>
|
||||
<option value="high">🟠 Высокий</option>
|
||||
<option value="urgent">🔴 Срочно</option>
|
||||
<option value="low">Низкий</option>
|
||||
<option value="normal">Обычный</option>
|
||||
<option value="high">Высокий</option>
|
||||
<option value="urgent">Срочно</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,8 +30,61 @@ export interface StorageBucket {
|
||||
region: string;
|
||||
public: boolean;
|
||||
versioning: boolean;
|
||||
status: string;
|
||||
monthlyPrice: number;
|
||||
autoRenew: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
nextBillingDate?: string | null;
|
||||
lastBilledAt?: string | null;
|
||||
usageSyncedAt?: string | null;
|
||||
consoleLogin?: string | null;
|
||||
planDetails?: {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quotaGb: number;
|
||||
bandwidthGb: number;
|
||||
requestLimit: string;
|
||||
description: string | null;
|
||||
order: number;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
regionDetails?: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
endpoint: string | null;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
storageClassDetails?: {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
redundancy: string | null;
|
||||
performance: string | null;
|
||||
retrievalFee: string | null;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
consoleUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface StorageObject {
|
||||
key: string;
|
||||
size: number;
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
export interface StorageAccessKey {
|
||||
id: number;
|
||||
accessKey: string;
|
||||
label?: string | null;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
|
||||
Reference in New Issue
Block a user