Files
tgex-frontend/app/(dashboard)/external-channels/page.tsx
2025-11-10 12:07:24 +03:00

407 lines
14 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 List Page
// ============================================================================
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { externalChannelsApi } from "@/lib/api";
import {
formatNumber,
formatMetric,
formatUsername,
formatCompactNumber,
} from "@/lib/utils/format";
import {
ExternalLink as ExternalLinkIcon,
Loader2,
AlertCircle,
Plus,
Search,
FileUp,
Eye,
Pencil,
Trash2,
Users,
} from "lucide-react";
import type { ExternalChannel } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export default function ExternalChannelsPage() {
const [channels, setChannels] = useState<ExternalChannel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
// Form state
const [formData, setFormData] = useState({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
useEffect(() => {
loadChannels();
}, []);
const loadChannels = async () => {
try {
setLoading(true);
const response = await externalChannelsApi.list();
setChannels(response.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
setIsCreating(true);
await externalChannelsApi.create({
title: formData.title,
username: formData.username || undefined,
link: formData.link,
subscribers_count: formData.subscribers_count
? parseInt(formData.subscribers_count)
: undefined,
description: formData.description || undefined,
target_channel_ids: [], // По умолчанию не привязываем
});
setIsCreateDialogOpen(false);
setFormData({
title: "",
username: "",
link: "",
subscribers_count: "",
description: "",
});
loadChannels();
} catch (err: any) {
alert(err?.error?.message || "Ошибка создания канала");
} finally {
setIsCreating(false);
}
};
const handleDelete = async (id: string, title: string) => {
if (!confirm(`Удалить канал "${title}"?`)) return;
try {
await externalChannelsApi.delete(id);
loadChannels();
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления канала");
}
};
const filteredChannels = channels.filter(
(channel) =>
channel.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
channel.username?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: "/" },
{ label: "Внешние каналы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Внешние каналы
</h1>
<p className="text-muted-foreground mt-1">
Каналы, в которых покупаете рекламу
</p>
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/external-channels/import">
<FileUp className="mr-2 h-4 w-4" />
Импорт из Excel
</Link>
</Button>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Добавить канал
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Добавить внешний канал</DialogTitle>
<DialogDescription>
Добавьте канал, в котором планируете покупать рекламу
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="title">Название *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
placeholder="Название канала"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="link">Ссылка *</Label>
<Input
id="link"
value={formData.link}
onChange={(e) =>
setFormData({ ...formData, link: e.target.value })
}
placeholder="https://t.me/channel_name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
placeholder="@channel_name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="subscribers">Количество подписчиков</Label>
<Input
id="subscribers"
type="number"
value={formData.subscribers_count}
onChange={(e) =>
setFormData({
...formData,
subscribers_count: e.target.value,
})
}
placeholder="50000"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Описание</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
placeholder="Краткое описание канала"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Отмена
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Создание...
</>
) : (
"Создать"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию или username..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Badge variant="secondary">
Всего: {formatNumber(filteredChannels.length)}
</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredChannels.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<ExternalLinkIcon className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{searchQuery ? "Каналы не найдены" : "Нет внешних каналов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
{searchQuery
? "Попробуйте изменить поисковый запрос"
: "Добавьте каналы вручную или импортируйте из Excel"}
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredChannels.map((channel) => (
<Card
key={channel.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate flex items-center gap-2">
<ExternalLinkIcon className="h-4 w-4 shrink-0" />
{channel.title}
</CardTitle>
<CardDescription className="truncate mt-1">
{channel.username ? (
<a
href={channel.link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{formatUsername(channel.username)}
</a>
) : (
<a
href={channel.link}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-xs"
>
{channel.link}
</a>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{channel.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{channel.description}
</p>
)}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border bg-muted/50 p-2">
<div className="flex items-center justify-center gap-1">
<Users className="h-3 w-3 text-muted-foreground" />
<div className="text-sm font-medium">
{channel.subscribers_count
? formatCompactNumber(channel.subscribers_count)
: "—"}
</div>
</div>
<div className="text-xs text-muted-foreground">
Подписчики
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(channel.total_purchases)}
</div>
<div className="text-xs text-muted-foreground">
Закупов
</div>
</div>
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatMetric(channel.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">CPF</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
asChild
variant="default"
size="sm"
className="flex-1"
>
<Link href={`/external-channels/${channel.id}`}>
<Eye className="mr-2 h-4 w-4" />
Подробнее
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(channel.id, channel.title)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</>
);
}