feat: all project setup

This commit is contained in:
ivannoskov
2025-11-10 12:07:24 +03:00
parent fbfd7719bb
commit b0a9934220
99 changed files with 19597 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
"use client";
// ============================================================================
// External Channels Import Page
// ============================================================================
import React, { useState } 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 { Badge } from "@/components/ui/badge";
import { externalChannelsApi } from "@/lib/api";
import {
FileUp,
AlertCircle,
CheckCircle,
Loader2,
Download,
ArrowLeft,
X,
} from "lucide-react";
import type { ExternalChannelImportResponse } from "@/lib/types/api";
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 [result, setResult] = useState<ExternalChannelImportResponse | null>(
null
);
const [error, setError] = useState<string | null>(null);
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 = (selectedFile: File) => {
// Проверка типа файла
const validTypes = [
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
];
if (
!validTypes.includes(selectedFile.type) &&
!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)
) {
setError("Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV");
return;
}
setFile(selectedFile);
setError(null);
setResult(null);
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
handleFileSelect(selectedFile);
}
};
const handleUpload = async () => {
if (!file) return;
try {
setIsUploading(true);
setError(null);
// Для примера используем первый целевой канал (tc1)
// В реальном приложении нужно дать пользователю выбрать
const importResult = await externalChannelsApi.import(file, "tc1");
setResult(importResult);
setFile(null);
} catch (err: any) {
setError(err?.error?.message || "Ошибка импорта файла");
} finally {
setIsUploading(false);
}
};
const handleReset = () => {
setFile(null);
setResult(null);
setError(null);
};
const handleGoToChannels = () => {
router.push("/external-channels");
};
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 с данными из Tgstat
</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>
Требования к структуре Excel файла
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm">
<p>Файл должен содержать следующие колонки:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>
<strong>Название</strong> - название канала
</li>
<li>
<strong>Username</strong> - username канала (с @ или без)
</li>
<li>
<strong>Ссылка</strong> - ссылка на канал (t.me/...)
</li>
<li>
<strong>Подписчики</strong> - количество подписчиков (число)
</li>
<li>
<strong>Описание</strong> - краткое описание (опционально)
</li>
</ul>
<div className="pt-2">
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Скачать шаблон
</Button>
</div>
</div>
</CardContent>
</Card>
{!result ? (
<Card>
<CardHeader>
<CardTitle>Загрузка файла</CardTitle>
<CardDescription>
Перетащите файл или выберите его
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-12 text-center transition-colors
${
isDragging
? "border-primary bg-primary/5"
: "border-muted-foreground/25"
}
${file ? "bg-muted/50" : ""}
`}
>
{file ? (
<div className="space-y-4">
<div className="flex items-center justify-center gap-2">
<FileUp className="h-8 w-8 text-primary" />
</div>
<div>
<p className="font-medium">{file.name}</p>
<p className="text-sm text-muted-foreground">
{(file.size / 1024).toFixed(2)} KB
</p>
</div>
<Button variant="outline" size="sm" onClick={handleReset}>
<X className="mr-2 h-4 w-4" />
Выбрать другой файл
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-center">
<FileUp className="h-12 w-12 text-muted-foreground" />
</div>
<div>
<p className="text-lg font-medium">
Перетащите файл сюда
</p>
<p className="text-sm text-muted-foreground">
или нажмите кнопку ниже
</p>
</div>
<div>
<label htmlFor="file-upload">
<Button asChild variant="outline">
<span>
<FileUp className="mr-2 h-4 w-4" />
Выбрать файл
</span>
</Button>
</label>
<input
id="file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileInputChange}
className="hidden"
/>
</div>
</div>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{file && (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleReset}>
Отмена
</Button>
<Button onClick={handleUpload} disabled={isUploading}>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Импорт...
</>
) : (
<>
<FileUp className="mr-2 h-4 w-4" />
Импортировать
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<CheckCircle className="h-6 w-6 text-green-500" />
<CardTitle>Импорт завершен</CardTitle>
</div>
<CardDescription>Результаты обработки файла</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<div className="flex items-center justify-between p-3 rounded-lg bg-green-50 dark:bg-green-950/20">
<span className="text-sm font-medium">
Импортировано каналов
</span>
<Badge variant="default">{result.imported}</Badge>
</div>
{result.skipped > 0 && (
<div className="flex items-center justify-between p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20">
<span className="text-sm font-medium">Пропущено</span>
<Badge variant="secondary">{result.skipped}</Badge>
</div>
)}
</div>
{result.errors.length > 0 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<p className="font-medium mb-2">
Обнаружены ошибки при импорте:
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
{result.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleReset}>
Импортировать еще
</Button>
<Button onClick={handleGoToChannels}>Перейти к каналам</Button>
</div>
</CardContent>
</Card>
)}
</div>
</>
);
}