Files
tgex-frontend/app/dashboard/[workspaceId]/creatives/page.tsx
2026-01-07 15:46:30 +03:00

589 lines
23 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 - Enhanced with Statistics
// ============================================================================
import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Search,
Plus,
Folder,
MoreHorizontal,
Archive,
Pencil,
Trash2,
Eye,
Copy,
TrendingUp,
Users,
ShoppingCart,
BarChart3,
Info,
Grid,
List,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { creativesApi } from "@/lib/api";
import { demoAwareCreativesApi, demoAwareAnalyticsApi } from "@/lib/demo";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
// ============================================================================
// Types
// ============================================================================
interface CreativeWithStats extends Creative {
analytics?: CreativeAnalyticsItem;
}
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const getCreativeWord = (count: number): string => {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod100 >= 11 && mod100 <= 14) {
return "креативов";
}
if (mod10 === 1) {
return "креатив";
}
if (mod10 >= 2 && mod10 <= 4) {
return "креатива";
}
return "креативов";
};
// ============================================================================
// Component
// ============================================================================
export default function CreativesPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
const projectFilterId = getProjectFilterId();
const [creatives, setCreatives] = useState<Creative[]>([]);
const [analyticsData, setAnalyticsData] = useState<CreativeAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true);
const [loadingAnalytics, setLoadingAnalytics] = useState(false);
const [search, setSearch] = useState("");
const [includeArchived, setIncludeArchived] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const canWrite = hasPermission("placements_write");
useEffect(() => {
loadCreatives();
}, [workspaceId, projectFilterId, includeArchived]);
useEffect(() => {
if (creatives.length > 0) {
loadAnalytics();
}
}, [creatives, workspaceId, projectFilterId]);
const loadCreatives = async () => {
try {
setLoading(true);
const response = await demoAwareCreativesApi.list(workspaceId, {
project_id: projectFilterId,
include_archived: includeArchived,
size: 100,
});
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives:", err);
} finally {
setLoading(false);
}
};
const loadAnalytics = async () => {
try {
setLoadingAnalytics(true);
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
project_id: projectFilterId,
size: 100,
});
setAnalyticsData(response.items);
} catch (err) {
console.error("Failed to load analytics:", err);
} finally {
setLoadingAnalytics(false);
}
};
// Merge creatives with analytics
const creativesWithStats: CreativeWithStats[] = useMemo(() => {
return creatives.map((creative) => ({
...creative,
analytics: analyticsData.find((a) => a.id === creative.id),
}));
}, [creatives, analyticsData]);
const handleArchive = async (creative: Creative) => {
try {
await creativesApi.update(workspaceId, creative.id, {});
loadCreatives();
} catch (err) {
console.error("Failed to archive creative:", err);
}
};
const handleDelete = async (creativeId: string) => {
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
try {
await creativesApi.delete(workspaceId, creativeId);
loadCreatives();
} catch (err) {
console.error("Failed to delete creative:", err);
}
};
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text);
};
// Filter by search
const filteredCreatives = creativesWithStats.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.text.toLowerCase().includes(search.toLowerCase())
);
// Totals
const totals = useMemo(() => {
const data = filteredCreatives.filter((c) => c.analytics);
return {
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0),
subscriptions: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0),
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
};
}, [filteredCreatives]);
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ 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-2xl font-bold tracking-tight">Креативы</h1>
<p className="text-muted-foreground">
{filteredCreatives.length} {getCreativeWord(filteredCreatives.length)} {formatCurrency(totals.cost)} {" "}
{formatNumber(totals.subscriptions)} подписчиков
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 border rounded-lg p-1">
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
</div>
{canWrite && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Link>
</Button>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Поиск по названию или тексту..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-archived"
checked={includeArchived}
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
/>
<Label htmlFor="include-archived" className="text-sm cursor-pointer">
Показать архивные
</Label>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-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 py-12">
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет креативов</h3>
<p className="text-muted-foreground text-center mb-4">
{search
? "Ничего не найдено по вашему запросу"
: "Создайте первый креатив для начала работы"}
</p>
{canWrite && !search && (
<Button asChild>
<Link href={`/dashboard/${workspaceId}/creatives/new`}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
</Link>
</Button>
)}
</CardContent>
</Card>
) : viewMode === "table" ? (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Название</TableHead>
<TableHead>Текст</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center gap-1">
Размещений
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Количество размещений с этим креативом
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Потрачено
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Сумма всех размещений с этим креативом
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Сред. CPS
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Subscription средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead>Статус</TableHead>
<TableHead className="w-[70px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredCreatives.map((creative) => (
<TableRow key={creative.id}>
<TableCell>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="font-medium hover:underline"
>
{creative.name}
</Link>
</TableCell>
<TableCell>
<div className="max-w-[300px] truncate text-sm text-muted-foreground">
{creative.text.replace("{invite_link}", "[ссылка]")}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{creative.analytics?.placements_count ?? creative.placements_count}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.total_cost)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatNumber(creative.analytics?.total_subscriptions)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
)}
</TableCell>
<TableCell>
<Badge
variant={creative.status === "active" ? "default" : "secondary"}
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
>
<Eye className="h-4 w-4 mr-2" />
Просмотр
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4 mr-2" />
Аналитика
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
<Copy className="h-4 w-4 mr-2" />
Копировать текст
</DropdownMenuItem>
{canWrite && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleArchive(creative)}>
<Archive className="h-4 w-4 mr-2" />
{creative.status === "active" ? "В архив" : "Восстановить"}
</DropdownMenuItem>
{creative.placements_count === 0 && (
<DropdownMenuItem
onClick={() => handleDelete(creative.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredCreatives.map((creative) => (
<Card key={creative.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
className="hover:underline"
>
{creative.name}
</Link>
</CardTitle>
<CardDescription className="line-clamp-2 mt-1">
{creative.text.replace("{invite_link}", "[ссылка]")}
</CardDescription>
</div>
<Badge
variant={creative.status === "active" ? "default" : "secondary"}
className="ml-2 shrink-0"
>
{creative.status === "active" ? "Активен" : "Архив"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats */}
{creative.analytics && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(creative.analytics.total_cost)}
</div>
<div className="text-xs text-muted-foreground">Потрачено</div>
</div>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_views)}
</div>
<div className="text-xs text-muted-foreground">Просмотров</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(creative.analytics.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">Сред. CPS</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
<Eye className="h-4 w-4 mr-1" />
Подробнее
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/creatives?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</TooltipProvider>
);
}