497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import Link from 'next/link';
|
||
import { useRouter } from 'next/navigation';
|
||
import { Button } from '@/components/ui/button';
|
||
import { TextInput } from '@/components/ui/TextInput/TextInput';
|
||
import {
|
||
Plus,
|
||
Search,
|
||
Copy,
|
||
Trash2,
|
||
Edit,
|
||
Eye,
|
||
RefreshCw
|
||
} from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
interface FunnelListItem {
|
||
_id: string;
|
||
name: string;
|
||
description?: string;
|
||
status: 'draft' | 'published' | 'archived';
|
||
version: number;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
publishedAt?: string;
|
||
usage: {
|
||
totalViews: number;
|
||
totalCompletions: number;
|
||
lastUsed?: string;
|
||
};
|
||
funnelData?: {
|
||
meta?: {
|
||
id?: string;
|
||
title?: string;
|
||
description?: string;
|
||
};
|
||
};
|
||
}
|
||
|
||
interface PaginationInfo {
|
||
current: number;
|
||
total: number;
|
||
count: number;
|
||
totalItems: number;
|
||
}
|
||
|
||
export default function AdminCatalogPage() {
|
||
const [funnels, setFunnels] = useState<FunnelListItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const router = useRouter();
|
||
|
||
// Фильтры и поиск
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||
const [sortBy, setSortBy] = useState('updatedAt');
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||
|
||
// Пагинация
|
||
const [pagination, setPagination] = useState<PaginationInfo>({
|
||
current: 1,
|
||
total: 1,
|
||
count: 0,
|
||
totalItems: 0
|
||
});
|
||
|
||
// Выделенные элементы - TODO: реализовать в будущем
|
||
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(new Set());
|
||
|
||
// Загрузка данных
|
||
const loadFunnels = useCallback(async (page: number = 1) => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
const params = new URLSearchParams({
|
||
page: page.toString(),
|
||
limit: '20',
|
||
sortBy,
|
||
sortOrder,
|
||
...(searchQuery && { search: searchQuery }),
|
||
...(statusFilter !== 'all' && { status: statusFilter })
|
||
});
|
||
|
||
const response = await fetch(`/api/funnels?${params}`);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch funnels');
|
||
}
|
||
|
||
const data = await response.json();
|
||
setFunnels(data.funnels);
|
||
setPagination({
|
||
current: data.pagination.current,
|
||
total: data.pagination.total,
|
||
count: data.pagination.count,
|
||
totalItems: data.pagination.totalItems
|
||
});
|
||
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [searchQuery, statusFilter, sortBy, sortOrder]);
|
||
|
||
// Эффекты
|
||
useEffect(() => {
|
||
loadFunnels(1);
|
||
}, [loadFunnels]);
|
||
|
||
// Создание новой воронки
|
||
const handleCreateFunnel = async () => {
|
||
try {
|
||
const newFunnelData = {
|
||
name: 'Новая воронка',
|
||
description: 'Описание новой воронки',
|
||
funnelData: {
|
||
meta: {
|
||
id: `funnel-${Date.now()}`,
|
||
title: 'Новая воронка',
|
||
description: 'Описание новой воронки',
|
||
firstScreenId: 'screen-1'
|
||
},
|
||
defaultTexts: {
|
||
nextButton: 'Далее',
|
||
continueButton: 'Продолжить'
|
||
},
|
||
screens: [
|
||
{
|
||
id: 'screen-1',
|
||
template: 'info',
|
||
title: {
|
||
text: 'Добро пожаловать!',
|
||
font: 'manrope',
|
||
weight: 'bold'
|
||
},
|
||
description: {
|
||
text: 'Это ваша новая воронка. Начните редактирование.',
|
||
color: 'muted'
|
||
},
|
||
icon: {
|
||
type: 'emoji',
|
||
value: '🎯',
|
||
size: 'lg'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
};
|
||
|
||
const response = await fetch('/api/funnels', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(newFunnelData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to create funnel');
|
||
}
|
||
|
||
const createdFunnel = await response.json();
|
||
|
||
// Переходим к редактированию новой воронки
|
||
router.push(`/admin/builder/${createdFunnel._id}`);
|
||
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to create funnel');
|
||
}
|
||
};
|
||
|
||
// Дублирование воронки
|
||
const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => {
|
||
try {
|
||
const response = await fetch(`/api/funnels/${funnelId}/duplicate`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
name: `${funnelName} (копия)`
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to duplicate funnel');
|
||
}
|
||
|
||
// Обновляем список
|
||
loadFunnels(pagination.current);
|
||
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to duplicate funnel');
|
||
}
|
||
};
|
||
|
||
// Удаление воронки
|
||
const handleDeleteFunnel = async (funnelId: string, funnelName: string) => {
|
||
if (!confirm(`Вы уверены, что хотите удалить воронку "${funnelName}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/funnels/${funnelId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || 'Failed to delete funnel');
|
||
}
|
||
|
||
// Обновляем список
|
||
loadFunnels(pagination.current);
|
||
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to delete funnel');
|
||
}
|
||
};
|
||
|
||
// Статус badges
|
||
const getStatusBadge = (status: string) => {
|
||
const variants = {
|
||
draft: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||
published: 'bg-green-100 text-green-800 border-green-200',
|
||
archived: 'bg-gray-100 text-gray-800 border-gray-200'
|
||
};
|
||
|
||
const labels = {
|
||
draft: 'Черновик',
|
||
published: 'Опубликована',
|
||
archived: 'Архивирована'
|
||
};
|
||
|
||
return (
|
||
<span className={cn(
|
||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||
variants[status as keyof typeof variants]
|
||
)}>
|
||
{labels[status as keyof typeof labels]}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// Форматирование дат
|
||
const formatDate = (dateString: string) => {
|
||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900">Каталог воронок</h1>
|
||
<p className="mt-2 text-gray-600">
|
||
Управляйте своими воронками и создавайте новые
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleCreateFunnel} className="flex items-center gap-2">
|
||
<Plus className="h-4 w-4" />
|
||
Создать воронку
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Фильтры и поиск */}
|
||
<div className="mb-6 bg-white rounded-lg border border-gray-200 p-6">
|
||
<div className="flex flex-col sm:flex-row gap-4">
|
||
|
||
{/* Поиск */}
|
||
<div className="flex-1">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||
<TextInput
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Поиск по названию, описанию..."
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Фильтр статуса */}
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="all">Все статусы</option>
|
||
<option value="draft">Черновики</option>
|
||
<option value="published">Опубликованные</option>
|
||
<option value="archived">Архивированные</option>
|
||
</select>
|
||
|
||
{/* Сортировка */}
|
||
<select
|
||
value={`${sortBy}-${sortOrder}`}
|
||
onChange={(e) => {
|
||
const [field, order] = e.target.value.split('-');
|
||
setSortBy(field);
|
||
setSortOrder(order as 'asc' | 'desc');
|
||
}}
|
||
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="updatedAt-desc">Сначала новые</option>
|
||
<option value="updatedAt-asc">Сначала старые</option>
|
||
<option value="name-asc">По названию А-Я</option>
|
||
<option value="name-desc">По названию Я-А</option>
|
||
<option value="usage.totalViews-desc">По популярности</option>
|
||
</select>
|
||
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => loadFunnels(pagination.current)}
|
||
disabled={loading}
|
||
>
|
||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ошибка */}
|
||
{error && (
|
||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||
<div className="text-red-800">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Список воронок */}
|
||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||
|
||
{loading ? (
|
||
<div className="p-8 text-center text-gray-500">
|
||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
|
||
Загружается...
|
||
</div>
|
||
) : funnels.length === 0 ? (
|
||
<div className="p-8 text-center text-gray-500">
|
||
<div className="mb-4">Воронки не найдены</div>
|
||
<Button onClick={handleCreateFunnel} variant="outline">
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Создать первую воронку
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Название
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Статус
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Статистика
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Обновлена
|
||
</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
Действия
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{funnels.map((funnel) => (
|
||
<tr key={funnel._id} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4">
|
||
<div className="flex flex-col">
|
||
<div className="text-sm font-medium text-gray-900">
|
||
{funnel.name}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
ID: {funnel.funnelData?.meta?.id || 'N/A'}
|
||
</div>
|
||
{funnel.description && (
|
||
<div className="text-sm text-gray-500 mt-1">
|
||
{funnel.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
{getStatusBadge(funnel.status)}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="text-sm text-gray-900">
|
||
{funnel.usage.totalViews} просмотров
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
{funnel.usage.totalCompletions} завершений
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm text-gray-900">
|
||
{formatDate(funnel.updatedAt)}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
v{funnel.version}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||
<div className="flex items-center justify-end gap-2">
|
||
|
||
{/* Просмотр воронки */}
|
||
<Link href={`/${funnel.funnelData?.meta?.id || funnel._id}`}>
|
||
<Button variant="ghost" title="Просмотр" className="h-8 w-8 p-0">
|
||
<Eye className="h-4 w-4" />
|
||
</Button>
|
||
</Link>
|
||
|
||
{/* Редактирование */}
|
||
<Link href={`/admin/builder/${funnel._id}`}>
|
||
<Button variant="ghost" title="Редактировать" className="h-8 w-8 p-0">
|
||
<Edit className="h-4 w-4" />
|
||
</Button>
|
||
</Link>
|
||
|
||
{/* Дублировать */}
|
||
<Button
|
||
variant="ghost"
|
||
title="Дублировать"
|
||
onClick={() => handleDuplicateFunnel(funnel._id, funnel.name)}
|
||
className="h-8 w-8 p-0"
|
||
>
|
||
<Copy className="h-4 w-4" />
|
||
</Button>
|
||
|
||
{/* Удалить (только черновики) */}
|
||
{funnel.status === 'draft' && (
|
||
<Button
|
||
variant="ghost"
|
||
title="Удалить"
|
||
onClick={() => handleDeleteFunnel(funnel._id, funnel.name)}
|
||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Пагинация */}
|
||
{pagination.total > 1 && (
|
||
<div className="mt-6 flex items-center justify-between">
|
||
<div className="text-sm text-gray-700">
|
||
Показано {pagination.count} из {pagination.totalItems} воронок
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
disabled={pagination.current <= 1}
|
||
onClick={() => loadFunnels(pagination.current - 1)}
|
||
>
|
||
Предыдущая
|
||
</Button>
|
||
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
|
||
{pagination.current} / {pagination.total}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
disabled={pagination.current >= pagination.total}
|
||
onClick={() => loadFunnels(pagination.current + 1)}
|
||
>
|
||
Следующая
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|