w-funnel/src/app/admin/page.tsx
2025-09-27 23:05:02 +02:00

497 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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