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

256 lines
9.6 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";
// ============================================================================
// Creatives 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 { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { creativesApi } from "@/lib/api";
import { formatNumber, formatMetric, truncate } from "@/lib/utils/format";
import {
Folder,
Loader2,
AlertCircle,
Plus,
Eye,
Pencil,
Archive,
Trash2,
} from "lucide-react";
import type { Creative } from "@/lib/types/api";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function CreativesPage() {
const [creatives, setCreatives] = useState<Creative[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<"all" | "active" | "archived">(
"active"
);
useEffect(() => {
loadCreatives();
}, []);
const loadCreatives = async () => {
try {
setLoading(true);
const response = await creativesApi.list();
setCreatives(response.data);
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки креативов");
} finally {
setLoading(false);
}
};
const handleArchive = async (id: string, isArchived: boolean) => {
try {
await creativesApi.update(id, { is_archived: !isArchived });
loadCreatives();
} catch (err: any) {
alert(err?.error?.message || "Ошибка обновления креатива");
}
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Удалить креатив "${name}"?`)) return;
try {
await creativesApi.delete(id);
loadCreatives();
} catch (err: any) {
alert(err?.error?.message || "Ошибка удаления креатива");
}
};
const filteredCreatives = creatives.filter((creative) => {
if (activeTab === "active") return !creative.is_archived;
if (activeTab === "archived") return creative.is_archived;
return true;
});
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>
<Button asChild>
<Link href="/creatives/new">
<Plus className="mr-2 h-4 w-4" />
Создать креатив
</Link>
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as any)}
className="w-full"
>
<TabsList>
<TabsTrigger value="active">
Активные ({creatives.filter((c) => !c.is_archived).length})
</TabsTrigger>
<TabsTrigger value="archived">
Архив ({creatives.filter((c) => c.is_archived).length})
</TabsTrigger>
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-4">
{loading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : filteredCreatives.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-lg font-medium text-muted-foreground">
{activeTab === "all" && "Нет креативов"}
{activeTab === "active" && "Нет активных креативов"}
{activeTab === "archived" && "Нет архивных креативов"}
</p>
<p className="text-sm text-muted-foreground mt-1">
Создайте первый креатив для рекламных сообщений
</p>
{activeTab === "active" && (
<Button asChild className="mt-4">
<Link href="/creatives/new">
<Plus className="mr-2 h-4 w-4" />
Создать креатив
</Link>
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCreatives.map((creative) => (
<Card
key={creative.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="truncate text-base">
{creative.name}
</CardTitle>
{creative.is_archived && (
<Badge variant="secondary">Архив</Badge>
)}
</div>
<CardDescription className="text-xs">
{creative.target_channel.title}
</CardDescription>
</div>
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-lg border bg-muted/50 p-3">
<p className="text-sm whitespace-pre-wrap line-clamp-4">
{creative.text}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border bg-muted/50 p-2">
<div className="text-sm font-medium">
{formatNumber(creative.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">
{formatNumber(creative.total_subscriptions)}
</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(creative.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={`/creatives/${creative.id}`}>
<Eye className="mr-2 h-4 w-4" />
Открыть
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleArchive(creative.id, creative.is_archived)
}
>
<Archive className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDelete(creative.id, creative.name)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
</div>
</>
);
}