Files
tgex-frontend/app/(dashboard)/external-channels/import/page.tsx
2025-11-19 12:56:04 +03:00

528 lines
18 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";
// ============================================================================
// External Channels Import Page
// ============================================================================
import React, { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { externalChannelsApi, channelsApi } from "@/lib/api";
import {
parseChannelsFile,
downloadTemplate,
type ParsedChannel,
} from "@/lib/utils/file-parser";
import {
FileUp,
AlertCircle,
CheckCircle,
Loader2,
Download,
ArrowLeft,
X,
FileSpreadsheet,
} from "lucide-react";
import type { TargetChannel } from "@/lib/types/api";
interface ImportResult {
total: number;
successful: number;
failed: number;
errors: string[];
}
export default function ImportExternalChannelsPage() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
const [progress, setProgress] = useState(0);
const [currentChannel, setCurrentChannel] = useState<string>("");
const [targetChannels, setTargetChannels] = useState<TargetChannel[]>([]);
const [selectedTargetChannel, setSelectedTargetChannel] =
useState<string>("");
useEffect(() => {
loadTargetChannels();
}, []);
const loadTargetChannels = async () => {
try {
const response = await channelsApi.list();
setTargetChannels(response.target_channels);
if (response.target_channels.length > 0) {
setSelectedTargetChannel(response.target_channels[0].id);
}
} catch (err) {
console.error("Error loading target channels:", err);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
handleFileSelect(droppedFile);
}
};
const handleFileSelect = async (selectedFile: File) => {
if (!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)) {
setParseErrors(["Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"]);
return;
}
setFile(selectedFile);
setParseErrors([]);
setParsedChannels([]);
setImportResult(null);
// Парсим файл
try {
const result = await parseChannelsFile(selectedFile);
setParsedChannels(result.channels);
setParseErrors(result.errors);
} catch (err) {
setParseErrors([
`Ошибка парсинга файла: ${
err instanceof Error ? err.message : "неизвестная ошибка"
}`,
]);
}
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
handleFileSelect(selectedFile);
}
};
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const handleImport = async () => {
if (!selectedTargetChannel || parsedChannels.length === 0) return;
setIsUploading(true);
setImportResult(null);
setProgress(0);
setCurrentChannel("");
const result: ImportResult = {
total: parsedChannels.length,
successful: 0,
failed: 0,
errors: [],
};
for (let i = 0; i < parsedChannels.length; i++) {
const channel = parsedChannels[i];
setCurrentChannel(channel.title);
setProgress(((i + 1) / parsedChannels.length) * 100);
try {
await externalChannelsApi.create({
telegram_id: channel.telegram_id || 0,
title: channel.title,
username: channel.username || null,
description: channel.description || null,
subscribers_count: channel.subscribers_count || null,
target_channel_ids: [selectedTargetChannel],
});
result.successful++;
} catch (err: any) {
result.failed++;
const errorMsg =
err?.detail || err?.error?.message || "Неизвестная ошибка";
result.errors.push(
`Строка ${channel.row} (${channel.title}): ${errorMsg}`
);
}
// Задержка между запросами (300ms)
if (i < parsedChannels.length - 1) {
await delay(300);
}
}
setImportResult(result);
setIsUploading(false);
setCurrentChannel("");
};
const handleReset = () => {
setFile(null);
setParsedChannels([]);
setParseErrors([]);
setImportResult(null);
setProgress(0);
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Внешние каналы", href: "/external-channels" },
{ label: "Импорт" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Импорт внешних каналов
</h1>
<p className="text-muted-foreground mt-1">
Загрузите файл Excel или CSV с данными каналов
</p>
</div>
<Button asChild variant="outline">
<Link href="/external-channels">
<ArrowLeft className="mr-2 h-4 w-4" />
Назад
</Link>
</Button>
</div>
{/* Выбор целевого канала */}
<Card>
<CardHeader>
<CardTitle>Целевой канал</CardTitle>
<CardDescription>
Выберите целевой канал для привязки импортируемых каналов
</CardDescription>
</CardHeader>
<CardContent>
<Select
value={selectedTargetChannel}
onValueChange={setSelectedTargetChannel}
disabled={isUploading || targetChannels.length === 0}
>
<SelectTrigger>
<SelectValue placeholder="Выберите целевой канал" />
</SelectTrigger>
<SelectContent>
{targetChannels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
{channel.username && ` (@${channel.username})`}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Шаблоны */}
<Card>
<CardHeader>
<CardTitle>Скачать шаблон</CardTitle>
<CardDescription>
Используйте шаблон для правильного заполнения данных
</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Button
variant="outline"
onClick={() => downloadTemplate("xlsx")}
disabled={isUploading}
>
<Download className="mr-2 h-4 w-4" />
Шаблон Excel
</Button>
<Button
variant="outline"
onClick={() => downloadTemplate("csv")}
disabled={isUploading}
>
<Download className="mr-2 h-4 w-4" />
Шаблон CSV
</Button>
</CardContent>
</Card>
{/* Загрузка файла */}
<Card>
<CardHeader>
<CardTitle>Загрузка файла</CardTitle>
<CardDescription>
Перетащите файл или нажмите для выбора
</CardDescription>
</CardHeader>
<CardContent>
<div
className={`relative border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25 hover:border-muted-foreground/50"
} ${isUploading ? "opacity-50 pointer-events-none" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
id="file-upload"
className="hidden"
accept=".xlsx,.xls,.csv"
onChange={handleFileInputChange}
disabled={isUploading}
/>
<label
htmlFor="file-upload"
className="cursor-pointer flex flex-col items-center"
>
<FileUp className="h-12 w-12 text-muted-foreground mb-4" />
{file ? (
<div className="space-y-2">
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(2)} KB
</p>
{!isUploading && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.preventDefault();
handleReset();
}}
>
<X className="h-4 w-4 mr-1" />
Удалить
</Button>
)}
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
Перетащите файл сюда или нажмите для выбора
</p>
<p className="text-xs text-muted-foreground mt-1">
Поддерживаются форматы: .xlsx, .xls, .csv
</p>
</>
)}
</label>
</div>
</CardContent>
</Card>
{/* Результаты парсинга */}
{parsedChannels.length > 0 && !importResult && (
<Card>
<CardHeader>
<CardTitle>Результаты парсинга</CardTitle>
<CardDescription>
Найдено каналов: {parsedChannels.length}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{parseErrors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Ошибки при парсинге:</strong>
<ul className="list-disc list-inside mt-2">
{parseErrors.slice(0, 5).map((error, i) => (
<li key={i} className="text-sm">
{error}
</li>
))}
{parseErrors.length > 5 && (
<li className="text-sm">
...и еще {parseErrors.length - 5}
</li>
)}
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
onClick={handleImport}
disabled={
isUploading ||
!selectedTargetChannel ||
parsedChannels.length === 0
}
className="flex-1"
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Импортировать {parsedChannels.length} каналов
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={isUploading}
>
Отмена
</Button>
</div>
</CardContent>
</Card>
)}
{/* Прогресс импорта */}
{isUploading && (
<Card>
<CardHeader>
<CardTitle>Импорт в процессе</CardTitle>
<CardDescription>
{currentChannel && `Импортируется: ${currentChannel}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={progress} className="w-full" />
<p className="text-sm text-center text-muted-foreground">
{Math.round(progress)}% завершено
</p>
</CardContent>
</Card>
)}
{/* Результаты импорта */}
{importResult && (
<Card>
<CardHeader>
<CardTitle>Результаты импорта</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center">
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{importResult.total}</div>
<div className="text-xs text-muted-foreground">Всего</div>
</div>
<div className="p-4 border rounded-lg border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{importResult.successful}
</div>
<div className="text-xs text-muted-foreground">Успешно</div>
</div>
<div className="p-4 border rounded-lg border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950">
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{importResult.failed}
</div>
<div className="text-xs text-muted-foreground">Ошибок</div>
</div>
</div>
{importResult.errors.length > 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Ошибки импорта:</strong>
<ul className="list-disc list-inside mt-2 max-h-60 overflow-y-auto">
{importResult.errors.map((error, i) => (
<li key={i} className="text-sm">
{error}
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{importResult.successful > 0 && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
Успешно импортировано каналов: {importResult.successful}
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
onClick={() => router.push("/external-channels")}
className="flex-1"
>
Перейти к списку каналов
</Button>
<Button variant="outline" onClick={handleReset}>
Импортировать еще
</Button>
</div>
</CardContent>
</Card>
)}
{/* Инструкция */}
<Card>
<CardHeader>
<CardTitle>Формат файла</CardTitle>
<CardDescription>Требования к структуре файла</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p>
<strong>Колонки (в порядке):</strong>
</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>
<strong>Название канала*</strong> - обязательное поле
</li>
<li>
<strong>Username</strong> - без символа @ (опционально)
</li>
<li>
<strong>Telegram ID</strong> - числовой ID канала
(опционально)
</li>
<li>
<strong>Подписчиков</strong> - количество подписчиков
(опционально)
</li>
<li>
<strong>Описание</strong> - описание канала (опционально)
</li>
</ol>
<p className="text-muted-foreground mt-4">
* Первая строка файла должна содержать заголовки и будет
пропущена при импорте
</p>
</div>
</CardContent>
</Card>
</div>
</>
);
}