feat: global platform v2 update
This commit is contained in:
302
app/dashboard/[workspaceId]/analytics/channels/page.tsx
Normal file
302
app/dashboard/[workspaceId]/analytics/channels/page.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Channels Analytics Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Loader2,
|
||||
Building2,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { ChannelAnalyticsItem } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SortField =
|
||||
| "placements"
|
||||
| "cost"
|
||||
| "subscriptions"
|
||||
| "views"
|
||||
| "cps"
|
||||
| "cpm";
|
||||
type SortOrder = "asc" | "desc" | null;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function ChannelsAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { getProjectFilterId } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField | null>("cost");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [workspaceId, projectFilterId]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await demoAwareAnalyticsApi.channels(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
size: 100,
|
||||
});
|
||||
setChannels(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load channels analytics:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort handler
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortOrder === "asc") setSortOrder("desc");
|
||||
else if (sortOrder === "desc") {
|
||||
setSortField(null);
|
||||
setSortOrder(null);
|
||||
} else setSortOrder("asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
};
|
||||
|
||||
// Sort data
|
||||
const sortedChannels = [...channels].sort((a, b) => {
|
||||
if (!sortField || !sortOrder) return 0;
|
||||
|
||||
let aVal = 0;
|
||||
let bVal = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case "placements":
|
||||
aVal = a.placements_count;
|
||||
bVal = b.placements_count;
|
||||
break;
|
||||
case "cost":
|
||||
aVal = a.total_cost;
|
||||
bVal = b.total_cost;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.total_subscriptions;
|
||||
bVal = b.total_subscriptions;
|
||||
break;
|
||||
case "views":
|
||||
aVal = a.total_views;
|
||||
bVal = b.total_views;
|
||||
break;
|
||||
case "cps":
|
||||
aVal = a.avg_cps ?? 0;
|
||||
bVal = b.avg_cps ?? 0;
|
||||
break;
|
||||
case "cpm":
|
||||
aVal = a.avg_cpm ?? 0;
|
||||
bVal = b.avg_cpm ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||
{ label: "Каналы" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Аналитика по каналам
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Эффективность каналов размещения
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sortedChannels.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 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">
|
||||
Создайте размещения для отображения статистики по каналам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Каналы размещения</CardTitle>
|
||||
<CardDescription>{sortedChannels.length} каналов</CardDescription>
|
||||
</CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Канал</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("placements")}
|
||||
>
|
||||
Размещений
|
||||
{getSortIcon("placements")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cost")}
|
||||
>
|
||||
Расходы
|
||||
{getSortIcon("cost")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("subscriptions")}
|
||||
>
|
||||
Подписчики
|
||||
{getSortIcon("subscriptions")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("views")}
|
||||
>
|
||||
Просмотры
|
||||
{getSortIcon("views")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cps")}
|
||||
>
|
||||
Ср. CPS
|
||||
{getSortIcon("cps")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cpm")}
|
||||
>
|
||||
Ср. CPM
|
||||
{getSortIcon("cpm")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedChannels.map((channel) => (
|
||||
<TableRow key={channel.channel_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{channel.channel_title}</div>
|
||||
{channel.channel_username && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@{channel.channel_username}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{channel.placements_count}</TableCell>
|
||||
<TableCell>{formatCurrency(channel.total_cost)}</TableCell>
|
||||
<TableCell>
|
||||
{formatNumber(channel.total_subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell>{formatNumber(channel.total_views)}</TableCell>
|
||||
<TableCell>{formatCurrency(channel.avg_cps)}</TableCell>
|
||||
<TableCell>{formatCurrency(channel.avg_cpm)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
305
app/dashboard/[workspaceId]/analytics/creatives/page.tsx
Normal file
305
app/dashboard/[workspaceId]/analytics/creatives/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Creatives Analytics Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Folder,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { CreativeAnalyticsItem } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type SortField =
|
||||
| "placements"
|
||||
| "cost"
|
||||
| "subscriptions"
|
||||
| "views"
|
||||
| "cps"
|
||||
| "cpm";
|
||||
type SortOrder = "asc" | "desc" | null;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function CreativesAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { getProjectFilterId } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
const [creatives, setCreatives] = useState<CreativeAnalyticsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField | null>("cost");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [workspaceId, projectFilterId]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
size: 100,
|
||||
});
|
||||
setCreatives(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load creatives analytics:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort handler
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortOrder === "asc") setSortOrder("desc");
|
||||
else if (sortOrder === "desc") {
|
||||
setSortField(null);
|
||||
setSortOrder(null);
|
||||
} else setSortOrder("asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
};
|
||||
|
||||
// Sort data
|
||||
const sortedCreatives = [...creatives].sort((a, b) => {
|
||||
if (!sortField || !sortOrder) return 0;
|
||||
|
||||
let aVal = 0;
|
||||
let bVal = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case "placements":
|
||||
aVal = a.placements_count;
|
||||
bVal = b.placements_count;
|
||||
break;
|
||||
case "cost":
|
||||
aVal = a.total_cost;
|
||||
bVal = b.total_cost;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.total_subscriptions;
|
||||
bVal = b.total_subscriptions;
|
||||
break;
|
||||
case "views":
|
||||
aVal = a.total_views;
|
||||
bVal = b.total_views;
|
||||
break;
|
||||
case "cps":
|
||||
aVal = a.avg_cpf ?? 0;
|
||||
bVal = b.avg_cpf ?? 0;
|
||||
break;
|
||||
case "cpm":
|
||||
aVal = a.avg_cpm ?? 0;
|
||||
bVal = b.avg_cpm ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||
{ label: "Креативы" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Аналитика по креативам
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Сравнение эффективности рекламных материалов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sortedCreatives.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">
|
||||
Создайте размещения для отображения статистики по креативам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Креативы</CardTitle>
|
||||
<CardDescription>
|
||||
{sortedCreatives.length} креативов с размещениями
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Креатив</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("placements")}
|
||||
>
|
||||
Размещений
|
||||
{getSortIcon("placements")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cost")}
|
||||
>
|
||||
Расходы
|
||||
{getSortIcon("cost")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("subscriptions")}
|
||||
>
|
||||
Подписчики
|
||||
{getSortIcon("subscriptions")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("views")}
|
||||
>
|
||||
Просмотры
|
||||
{getSortIcon("views")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cps")}
|
||||
>
|
||||
Ср. CPS
|
||||
{getSortIcon("cps")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cpm")}
|
||||
>
|
||||
Ср. CPM
|
||||
{getSortIcon("cpm")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedCreatives.map((creative) => (
|
||||
<TableRow key={creative.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{creative.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{creative.placements_count}</TableCell>
|
||||
<TableCell>{formatCurrency(creative.total_cost)}</TableCell>
|
||||
<TableCell>
|
||||
{formatNumber(creative.total_subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell>{formatNumber(creative.total_views)}</TableCell>
|
||||
<TableCell>{formatCurrency(creative.avg_cpf)}</TableCell>
|
||||
<TableCell>{formatCurrency(creative.avg_cpm)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
291
app/dashboard/[workspaceId]/analytics/page.tsx
Normal file
291
app/dashboard/[workspaceId]/analytics/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Analytics Overview Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Users,
|
||||
Eye,
|
||||
DollarSign,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const {
|
||||
selectedProjects,
|
||||
getProjectFilterId,
|
||||
allProjectsSelected,
|
||||
projects
|
||||
} = useWorkspace();
|
||||
|
||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalytics();
|
||||
}, [workspaceId, projectFilterId]);
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
grouping: "month",
|
||||
});
|
||||
setSpending(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load analytics:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate averages
|
||||
const avgCps =
|
||||
spending && spending.total_subscriptions > 0
|
||||
? spending.total_cost / spending.total_subscriptions
|
||||
: null;
|
||||
const avgCpm =
|
||||
spending && spending.total_views > 0
|
||||
? (spending.total_cost / spending.total_views) * 1000
|
||||
: null;
|
||||
|
||||
// Selection info text
|
||||
const selectionText = allProjectsSelected
|
||||
? "Все проекты"
|
||||
: selectedProjects.length === 1
|
||||
? selectedProjects[0].title
|
||||
: `${selectedProjects.length} проекта`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
Обзор эффективности рекламных размещений
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{selectionText}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{selectedProjects.length > 1 && !allProjectsSelected && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Выбрано {selectedProjects.length} проекта: {selectedProjects.map(p => p.title).join(", ")}.
|
||||
Показана суммарная аналитика по всем выбранным проектам.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего потрачено
|
||||
</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(spending?.total_cost ?? 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Подписчиков
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(spending?.total_subscriptions ?? 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Средний CPS: {avgCps ? formatCurrency(avgCps) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(spending?.total_views ?? 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Средний CPM: {avgCpm ? formatCurrency(avgCpm) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Периодов данных
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{spending?.chart_data?.length ?? 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">месяцев</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/spending`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Динамика расходов
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
График расходов по времени с группировкой
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/channels`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Аналитика по каналам
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Эффективность каналов размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/creatives`}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
Аналитика по креативам
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Сравнение эффективности креативов
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{spending && spending.chart_data && spending.chart_data.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Последние периоды</CardTitle>
|
||||
<CardDescription>Динамика по периодам</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{spending.chart_data.slice(0, 6).map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{item.period}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatNumber(item.subscriptions)} подписчиков •{" "}
|
||||
{formatNumber(item.views)} просмотров
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">
|
||||
{formatCurrency(item.cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
501
app/dashboard/[workspaceId]/analytics/spending/page.tsx
Normal file
501
app/dashboard/[workspaceId]/analytics/spending/page.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Spending Analytics Page - Enhanced with Charts
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info } from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
type ChartConfig,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { SpendingAnalytics, DateGrouping } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
const formatShortCurrency = (value: number) => {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`;
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
const formatDateLabel = (date: string, grouping: DateGrouping) => {
|
||||
const d = new Date(date);
|
||||
const g = grouping.toLowerCase();
|
||||
if (g === "day") {
|
||||
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" });
|
||||
} else if (g === "week") {
|
||||
return `Нед. ${d.toLocaleDateString("ru-RU", { day: "numeric", month: "short" })}`;
|
||||
} else {
|
||||
return d.toLocaleDateString("ru-RU", { month: "short", year: "2-digit" });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Chart Types
|
||||
// ============================================================================
|
||||
|
||||
type ChartType = "line" | "bar";
|
||||
type MetricKey = "cost" | "subscriptions" | "views";
|
||||
|
||||
// ============================================================================
|
||||
// Chart Configurations
|
||||
// ============================================================================
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
cost: {
|
||||
label: "Расходы",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
subscriptions: {
|
||||
label: "Подписчики",
|
||||
color: "hsl(var(--chart-2))",
|
||||
},
|
||||
views: {
|
||||
label: "Просмотры",
|
||||
color: "hsl(var(--chart-3))",
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function SpendingAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [grouping, setGrouping] = useState<DateGrouping>("month");
|
||||
|
||||
// Chart settings
|
||||
const [chartType, setChartType] = useState<ChartType>("line");
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [workspaceId, projectFilterId, grouping]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
grouping,
|
||||
});
|
||||
setSpending(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load spending:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format chart data
|
||||
const chartData = useMemo(() => {
|
||||
if (!spending?.chart_data) return [];
|
||||
return spending.chart_data.map((item) => ({
|
||||
period: item.period,
|
||||
dateLabel: formatDateLabel(item.period, grouping),
|
||||
cost: item.cost,
|
||||
subscriptions: item.subscriptions,
|
||||
views: item.views,
|
||||
}));
|
||||
}, [spending, grouping]);
|
||||
|
||||
// Toggle metric
|
||||
const handleToggleMetric = (metric: MetricKey) => {
|
||||
if (selectedMetrics.includes(metric)) {
|
||||
if (selectedMetrics.length > 1) {
|
||||
setSelectedMetrics(selectedMetrics.filter((m) => m !== metric));
|
||||
}
|
||||
} else {
|
||||
setSelectedMetrics([...selectedMetrics, metric]);
|
||||
}
|
||||
};
|
||||
|
||||
// Render chart
|
||||
const renderChart = () => {
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
const ChartComponent = chartType === "line" ? LineChart : BarChart;
|
||||
|
||||
return (
|
||||
<ChartContainer config={chartConfig} className="aspect-auto h-[300px] w-full">
|
||||
<ChartComponent
|
||||
data={chartData}
|
||||
margin={{ left: 12, right: 12, top: showLabels ? 20 : 12, bottom: 12 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="dateLabel"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={formatShortCurrency}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
return payload[0].payload.period;
|
||||
}
|
||||
return "";
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
|
||||
{selectedMetrics.map((metric) =>
|
||||
chartType === "line" ? (
|
||||
<Line
|
||||
key={metric}
|
||||
dataKey={metric}
|
||||
type="monotone"
|
||||
stroke={`var(--color-${metric})`}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
>
|
||||
{showLabels && (
|
||||
<LabelList
|
||||
dataKey={metric}
|
||||
position="top"
|
||||
formatter={(value: number) =>
|
||||
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
|
||||
}
|
||||
className="fill-foreground text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</Line>
|
||||
) : (
|
||||
<Bar
|
||||
key={metric}
|
||||
dataKey={metric}
|
||||
fill={`var(--color-${metric})`}
|
||||
radius={[4, 4, 0, 0]}
|
||||
>
|
||||
{showLabels && (
|
||||
<LabelList
|
||||
dataKey={metric}
|
||||
position="top"
|
||||
formatter={(value: number) =>
|
||||
metric === "cost" ? formatShortCurrency(value) : formatNumber(value)
|
||||
}
|
||||
className="fill-foreground text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</Bar>
|
||||
)
|
||||
)}
|
||||
</ChartComponent>
|
||||
</ChartContainer>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
|
||||
{ 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">Анализ расходов по периодам</p>
|
||||
</div>
|
||||
<Select value={grouping} onValueChange={(v) => setGrouping(v as DateGrouping)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">По дням</SelectItem>
|
||||
<SelectItem value="week">По неделям</SelectItem>
|
||||
<SelectItem value="month">По месяцам</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : spending ? (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(spending.total_subscriptions)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{formatNumber(spending.total_views)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Chart with Settings */}
|
||||
{chartData.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>График динамики</CardTitle>
|
||||
<CardDescription>
|
||||
Визуализация данных по выбранным метрикам
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* Chart Settings */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Chart Type Toggle */}
|
||||
<div className="flex items-center gap-1 border rounded-lg p-1">
|
||||
<Button
|
||||
variant={chartType === "line" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setChartType("line")}
|
||||
aria-label="Линейный график"
|
||||
>
|
||||
<LineChartIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={chartType === "bar" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setChartType("bar")}
|
||||
aria-label="Столбчатая диаграмма"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Labels Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="show-labels"
|
||||
checked={showLabels}
|
||||
onCheckedChange={(checked) => setShowLabels(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="show-labels" className="text-sm cursor-pointer">
|
||||
Подписи
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Показывать значения на точках графика
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Selection */}
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
|
||||
<span className="text-sm text-muted-foreground">Метрики:</span>
|
||||
{(["cost", "subscriptions", "views"] as MetricKey[]).map((metric) => (
|
||||
<div key={metric} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`metric-${metric}`}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onCheckedChange={() => handleToggleMetric(metric)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`metric-${metric}`}
|
||||
className="text-sm cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: `var(--color-${metric})` }}
|
||||
/>
|
||||
{chartConfig[metric].label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">{renderChart()}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Data Table */}
|
||||
{spending.chart_data && spending.chart_data.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Данные по периодам</CardTitle>
|
||||
<CardDescription>{spending.chart_data.length} периодов</CardDescription>
|
||||
</CardHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Период</TableHead>
|
||||
<TableHead className="text-right">Расходы</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">Просмотры</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 className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
CPM
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{spending.chart_data.map((item, index) => {
|
||||
const cps = item.subscriptions > 0 ? item.cost / item.subscriptions : null;
|
||||
const cpm = item.views > 0 ? (item.cost / item.views) * 1000 : null;
|
||||
|
||||
return (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium">{item.period}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.views)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{cps ? formatCurrency(cps) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{cpm ? formatCurrency(cpm) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<TrendingUp 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">
|
||||
Создайте размещения для отображения статистики
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
384
app/dashboard/[workspaceId]/channels/page.tsx
Normal file
384
app/dashboard/[workspaceId]/channels/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Channels Catalog Page - Enhanced with Sorting
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
Loader2,
|
||||
Search,
|
||||
Building2,
|
||||
Users,
|
||||
ExternalLink,
|
||||
Grid,
|
||||
List,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { channelsApi } from "@/lib/api";
|
||||
import { isDemoWorkspace, demoChannels } from "@/lib/demo";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { Channel } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatNumber = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sort Types
|
||||
// ============================================================================
|
||||
|
||||
type SortField = "title" | "username" | "subscribers";
|
||||
type SortOrder = "asc" | "desc" | null;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function ChannelsCatalogPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Demo mode - use mock data
|
||||
const isDemo = isDemoWorkspace(workspaceId);
|
||||
const response = isDemo
|
||||
? { items: demoChannels.filter(c => !search || c.username?.includes(search) || c.title.includes(search)), total: demoChannels.length, page: 1, size: 100, pages: 1 }
|
||||
: await channelsApi.list({
|
||||
username: search || undefined,
|
||||
size: 100,
|
||||
});
|
||||
setChannels(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load channels:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(loadChannels, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [search]);
|
||||
|
||||
// Handle sort
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortOrder === "asc") {
|
||||
setSortOrder("desc");
|
||||
} else if (sortOrder === "desc") {
|
||||
setSortField(null);
|
||||
setSortOrder(null);
|
||||
} else {
|
||||
setSortOrder("asc");
|
||||
}
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
};
|
||||
|
||||
// Filter and sort channels
|
||||
const filteredChannels = useMemo(() => {
|
||||
let result = [...channels];
|
||||
|
||||
// Sort
|
||||
if (sortField && sortOrder) {
|
||||
result.sort((a, b) => {
|
||||
let aVal: string | number = "";
|
||||
let bVal: string | number = "";
|
||||
|
||||
switch (sortField) {
|
||||
case "title":
|
||||
aVal = a.title.toLowerCase();
|
||||
bVal = b.title.toLowerCase();
|
||||
return sortOrder === "asc"
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
case "username":
|
||||
aVal = (a.username || "").toLowerCase();
|
||||
bVal = (b.username || "").toLowerCase();
|
||||
return sortOrder === "asc"
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
case "subscribers":
|
||||
aVal = a.subscribers_count || 0;
|
||||
bVal = b.subscribers_count || 0;
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [channels, sortField, sortOrder]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Каталог каналов" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{filteredChannels.length} каналов для размещения рекламы
|
||||
</p>
|
||||
</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="Поиск по username..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : channels.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 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">
|
||||
{search
|
||||
? "Попробуйте изменить поисковый запрос"
|
||||
: "В каталоге пока нет каналов"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === "table" ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("title")}
|
||||
>
|
||||
Канал
|
||||
{getSortIcon("title")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("username")}
|
||||
>
|
||||
Username
|
||||
{getSortIcon("username")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => handleSort("subscribers")}
|
||||
>
|
||||
Подписчики
|
||||
{getSortIcon("subscribers")}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help ml-1" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Количество подписчиков на момент последнего обновления
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredChannels.map((channel) => (
|
||||
<TableRow key={channel.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{channel.title}</div>
|
||||
{channel.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1 max-w-[300px]">
|
||||
{channel.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={`https://t.me/${channel.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
@{channel.username}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
{formatNumber(channel.subscribers_count)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
|
||||
Разместить
|
||||
</a>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredChannels.map((channel) => (
|
||||
<Card key={channel.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate">{channel.title}</CardTitle>
|
||||
{channel.username && (
|
||||
<CardDescription>
|
||||
<a
|
||||
href={`https://t.me/${channel.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
@{channel.username}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channel.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
{formatNumber(channel.subscribers_count)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={`/dashboard/${workspaceId}/placements/new?channel_id=${channel.id}`}>
|
||||
Разместить
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
366
app/dashboard/[workspaceId]/creatives/[creativeId]/page.tsx
Normal file
366
app/dashboard/[workspaceId]/creatives/[creativeId]/page.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Creative Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Pencil,
|
||||
Archive,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { creativesApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { Creative } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function CreativeDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const creativeId = params?.creativeId as string;
|
||||
const { hasPermission } = useWorkspace();
|
||||
|
||||
const [creative, setCreative] = useState<Creative | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(searchParams.get("edit") === "true");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [name, setName] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canWrite = hasPermission("placements_write");
|
||||
|
||||
useEffect(() => {
|
||||
loadCreative();
|
||||
}, [workspaceId, creativeId]);
|
||||
|
||||
const loadCreative = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await creativesApi.get(workspaceId, creativeId);
|
||||
setCreative(data);
|
||||
setName(data.name);
|
||||
setText(data.text);
|
||||
} catch (err) {
|
||||
console.error("Failed to load creative:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !text.trim()) {
|
||||
setError("Заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.includes("{invite_link}")) {
|
||||
setError("Текст должен содержать {invite_link}");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
const updated = await creativesApi.update(workspaceId, creativeId, {
|
||||
name: name.trim(),
|
||||
text: text.trim(),
|
||||
});
|
||||
|
||||
setCreative(updated);
|
||||
setIsEditing(false);
|
||||
} catch (err: any) {
|
||||
setError(err?.detail || "Не удалось сохранить");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!creative) return;
|
||||
|
||||
try {
|
||||
// Toggle archive status by updating via delete endpoint (soft delete)
|
||||
await creativesApi.delete(workspaceId, creativeId);
|
||||
router.push(`/dashboard/${workspaceId}/creatives`);
|
||||
} catch (err) {
|
||||
console.error("Failed to archive:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (creative) {
|
||||
navigator.clipboard.writeText(creative.text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (creative) {
|
||||
setName(creative.name);
|
||||
setText(creative.text);
|
||||
}
|
||||
setIsEditing(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!creative) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">Креатив не найден</h2>
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${workspaceId}/creatives`}>
|
||||
Вернуться к списку
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
|
||||
{ label: creative.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/dashboard/${workspaceId}/creatives`)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{creative.name}
|
||||
</h1>
|
||||
<Badge
|
||||
variant={
|
||||
creative.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{creative.status === "active" ? "Активен" : "Архив"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{creative.placements_count} размещений
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && !isEditing && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditing(true)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Редактировать
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
В архив
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Архивировать креатив?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Креатив будет перемещён в архив. Существующие размещения
|
||||
останутся без изменений.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleArchive}>
|
||||
Архивировать
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Редактирование</CardTitle>
|
||||
<CardDescription>
|
||||
Изменения не повлияют на существующие размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Название</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Текст креатива</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Не забудьте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Текст креатива</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Скопировано
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Копировать
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="whitespace-pre-wrap text-sm bg-muted p-4 rounded-lg">
|
||||
{creative.text.replace(
|
||||
"{invite_link}",
|
||||
"https://t.me/+AbCdEfGhIjK"
|
||||
)}
|
||||
</pre>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
|
||||
заменяется уникальной ссылкой при создании размещения
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
{canWrite && creative.status === "active" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Действия</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/placements/new?creative_id=${creative.id}`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Создать размещение с этим креативом
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
242
app/dashboard/[workspaceId]/creatives/new/page.tsx
Normal file
242
app/dashboard/[workspaceId]/creatives/new/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Create Creative Page
|
||||
// ============================================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Loader2, ArrowLeft, Info } from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { creativesApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function NewCreativePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { currentProject } = useWorkspace();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim() || !text.trim()) {
|
||||
setError("Заполните все обязательные поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.includes("{invite_link}")) {
|
||||
setError("Текст должен содержать {invite_link} для вставки ссылки");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentProject) {
|
||||
setError("Выберите проект в верхнем меню");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const creative = await creativesApi.create(
|
||||
workspaceId,
|
||||
currentProject.id,
|
||||
{
|
||||
name: name.trim(),
|
||||
text: text.trim(),
|
||||
}
|
||||
);
|
||||
|
||||
router.push(`/dashboard/${workspaceId}/creatives/${creative.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err?.detail || "Не удалось создать креатив");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const insertInviteLink = () => {
|
||||
if (!text.includes("{invite_link}")) {
|
||||
setText((prev) => prev + "{invite_link}");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Креативы", href: `/dashboard/${workspaceId}/creatives` },
|
||||
{ label: "Новый креатив" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Новый креатив</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Создайте рекламный материал для размещений
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!currentProject && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Выберите проект в верхнем меню для создания креатива
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Информация о креативе</CardTitle>
|
||||
<CardDescription>
|
||||
Креатив — это текст рекламного сообщения с пригласительной ссылкой
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Название <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Например: Летняя акция 2025"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Внутреннее название для удобства поиска
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="text">
|
||||
Текст креатива <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={insertInviteLink}
|
||||
disabled={text.includes("{invite_link}")}
|
||||
>
|
||||
Вставить {"{invite_link}"}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="text"
|
||||
placeholder={`Пример:
|
||||
|
||||
🔥 Подпишись на наш канал!
|
||||
|
||||
Здесь ты найдёшь:
|
||||
• Полезный контент
|
||||
• Эксклюзивные материалы
|
||||
• Актуальные новости
|
||||
|
||||
👉 {invite_link}`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
rows={10}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Используйте <code className="bg-muted px-1 rounded">{"{invite_link}"}</code>{" "}
|
||||
в том месте, где должна быть пригласительная ссылка
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{text.includes("{invite_link}") && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<span className="font-medium">Предпросмотр:</span>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-sm">
|
||||
{text.replace(
|
||||
"{invite_link}",
|
||||
"https://t.me/+AbCdEfGhIjK"
|
||||
)}
|
||||
</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!name.trim() ||
|
||||
!text.trim() ||
|
||||
!currentProject
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
"Создать креатив"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
569
app/dashboard/[workspaceId]/creatives/page.tsx
Normal file
569
app/dashboard/[workspaceId]/creatives/page.tsx
Normal file
@@ -0,0 +1,569 @@
|
||||
"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);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 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} креативов • {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>
|
||||
);
|
||||
}
|
||||
29
app/dashboard/[workspaceId]/layout.tsx
Normal file
29
app/dashboard/[workspaceId]/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// ============================================================================
|
||||
// Workspace Dashboard Layout
|
||||
// ============================================================================
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
import { WorkspaceProvider } from "@/components/providers/workspace-provider";
|
||||
import { DashboardLayoutContent } from "@/components/layout/dashboard-layout-content";
|
||||
|
||||
export default function WorkspaceDashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<WorkspaceProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<DashboardLayoutContent>{children}</DashboardLayoutContent>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</WorkspaceProvider>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
231
app/dashboard/[workspaceId]/page.tsx
Normal file
231
app/dashboard/[workspaceId]/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard Home Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, ShoppingCart, Users, TrendingUp, BarChart3 } from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { SpendingAnalytics } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatNumber = (value: number | null | undefined, showSign = false): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
const formatted = new Intl.NumberFormat("ru-RU").format(value);
|
||||
if (showSign && value > 0) return `+${formatted}`;
|
||||
return formatted;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { currentWorkspace, currentProject, isLoading: workspaceLoading, projects } = useWorkspace();
|
||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentWorkspace) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await demoAwareAnalyticsApi.spending(currentWorkspace.id, {
|
||||
project_id: currentProject?.id,
|
||||
});
|
||||
setSpending(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load spending:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [currentWorkspace?.id, currentProject?.id]);
|
||||
|
||||
if (workspaceLoading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-2">Нет доступных воркспейсов</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Создайте новый воркспейс для начала работы
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate totals and averages
|
||||
const totalCost = spending?.total_cost ?? 0;
|
||||
const totalSubscriptions = spending?.total_subscriptions ?? 0;
|
||||
const totalViews = spending?.total_views ?? 0;
|
||||
const avgCps = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null;
|
||||
const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Всего потрачено</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalCost)}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{formatNumber(totalSubscriptions)}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPS</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{formatCurrency(avgCps)}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{formatCurrency(avgCpm)}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Быстрый старт</CardTitle>
|
||||
<CardDescription>Начните с основных действий</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<a
|
||||
href={`/dashboard/${currentWorkspace.id}/placements/new`}
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Создать размещение
|
||||
</a>
|
||||
<a
|
||||
href={`/dashboard/${currentWorkspace.id}/creatives/new`}
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Создать креатив
|
||||
</a>
|
||||
<a
|
||||
href={`/dashboard/${currentWorkspace.id}/analytics`}
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Посмотреть аналитику
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Проекты</CardTitle>
|
||||
<CardDescription>
|
||||
{projects.length > 0
|
||||
? `${projects.length} активных проектов`
|
||||
: "Нет активных проектов"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{projects.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{projects.slice(0, 5).map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="font-medium">{project.title}</span>
|
||||
{project.username && (
|
||||
<span className="text-muted-foreground">@{project.username}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Добавьте бота в канал через Telegram для создания проекта
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
431
app/dashboard/[workspaceId]/placements/[placementId]/page.tsx
Normal file
431
app/dashboard/[workspaceId]/placements/[placementId]/page.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Placement Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
Archive,
|
||||
Eye,
|
||||
Users,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { placementsApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { Placement } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "—";
|
||||
return new Date(dateString).toLocaleString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null | undefined) => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function PlacementDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const placementId = params?.placementId as string;
|
||||
const { hasPermission } = useWorkspace();
|
||||
|
||||
const [placement, setPlacement] = useState<Placement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
|
||||
const canWrite = hasPermission("placements_write");
|
||||
|
||||
useEffect(() => {
|
||||
loadPlacement();
|
||||
}, [workspaceId, placementId]);
|
||||
|
||||
const loadPlacement = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await placementsApi.get(workspaceId, placementId);
|
||||
setPlacement(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load placement:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await placementsApi.delete(workspaceId, placementId);
|
||||
router.push(`/dashboard/${workspaceId}/placements`);
|
||||
} catch (err) {
|
||||
console.error("Failed to archive:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (placement?.invite_link) {
|
||||
navigator.clipboard.writeText(placement.invite_link);
|
||||
setCopiedLink(true);
|
||||
setTimeout(() => setCopiedLink(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!placement) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Ошибка" }]} />
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<h2 className="text-xl font-semibold mb-2">Размещение не найдено</h2>
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${workspaceId}/placements`}>
|
||||
Вернуться к списку
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
const cps =
|
||||
placement.cost && placement.subscriptions_count > 0
|
||||
? placement.cost / placement.subscriptions_count
|
||||
: null;
|
||||
const cpm =
|
||||
placement.cost && placement.views_count
|
||||
? (placement.cost / placement.views_count) * 1000
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
|
||||
{ label: placement.placement_channel_title },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/dashboard/${workspaceId}/placements`)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{placement.placement_channel_title}
|
||||
</h1>
|
||||
<Badge
|
||||
variant={
|
||||
placement.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{placement.status === "active" ? "Активно" : "Архив"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{formatDate(placement.placement_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && placement.status === "active" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
В архив
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Архивировать размещение?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Размещение будет перемещено в архив. Статистика сохранится.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleArchive}>
|
||||
Архивировать
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(placement.cost)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(placement.subscriptions_count)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CPS: {cps ? formatCurrency(cps) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(placement.views_count)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CPM: {cpm ? formatCurrency(cpm) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Тип ссылки
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{placement.invite_link_type === "approval"
|
||||
? "С заявками"
|
||||
: "Публичная"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* Invite Link */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Пригласительная ссылка</CardTitle>
|
||||
<CardDescription>
|
||||
{placement.invite_link_type === "approval"
|
||||
? "С одобрением — бот отслеживает подписчиков"
|
||||
: "Публичная ссылка"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
|
||||
{placement.invite_link}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" onClick={handleCopyLink}>
|
||||
{copiedLink ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href={placement.invite_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Creative */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Креатив</CardTitle>
|
||||
<CardDescription>{placement.creative_name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
|
||||
>
|
||||
Просмотреть креатив
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Детали размещения</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Проект
|
||||
</dt>
|
||||
<dd className="text-sm">{placement.project_channel_title}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Дата размещения
|
||||
</dt>
|
||||
<dd className="text-sm">{formatDate(placement.placement_date)}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Статус просмотров
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<Badge variant="outline">
|
||||
{placement.views_availability === "available"
|
||||
? "Автообновление"
|
||||
: placement.views_availability === "manual"
|
||||
? "Ручной ввод"
|
||||
: placement.views_availability === "unavailable"
|
||||
? "Недоступно"
|
||||
: "Не проверено"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Последнее обновление просмотров
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
{formatDateTime(placement.last_views_fetch_at)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{placement.ad_post_url && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Ссылка на пост
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<a
|
||||
href={placement.ad_post_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{placement.ad_post_url}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{placement.comment && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Комментарий
|
||||
</dt>
|
||||
<dd className="text-sm">{placement.comment}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
393
app/dashboard/[workspaceId]/placements/new/page.tsx
Normal file
393
app/dashboard/[workspaceId]/placements/new/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Create Placement Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader2, ArrowLeft, Info, Calendar as CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { ru } from "date-fns/locale";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { placementsApi, creativesApi, channelsApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Creative, Channel, InviteLinkType } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function NewPlacementPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { currentProject } = useWorkspace();
|
||||
|
||||
// Pre-filled from URL params
|
||||
const prefilledCreativeId = searchParams.get("creative_id");
|
||||
const prefilledChannelId = searchParams.get("channel_id");
|
||||
|
||||
// Form state
|
||||
const [channelId, setChannelId] = useState(prefilledChannelId || "");
|
||||
const [creativeId, setCreativeId] = useState(prefilledCreativeId || "");
|
||||
const [placementDate, setPlacementDate] = useState<Date>(new Date());
|
||||
const [cost, setCost] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [adPostUrl, setAdPostUrl] = useState("");
|
||||
const [inviteLinkType, setInviteLinkType] = useState<InviteLinkType>("approval");
|
||||
|
||||
// Data
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
|
||||
// Submit state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [workspaceId, currentProject?.id]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoadingData(true);
|
||||
|
||||
const [channelsRes, creativesRes] = await Promise.all([
|
||||
channelsApi.list({ size: 100 }),
|
||||
currentProject
|
||||
? creativesApi.list(workspaceId, {
|
||||
project_id: currentProject.id,
|
||||
include_archived: false,
|
||||
size: 100,
|
||||
})
|
||||
: Promise.resolve({ items: [] }),
|
||||
]);
|
||||
|
||||
setChannels(channelsRes.items);
|
||||
setCreatives(creativesRes.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentProject) {
|
||||
setError("Выберите проект в верхнем меню");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!channelId || !creativeId) {
|
||||
setError("Выберите канал и креатив");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const placement = await placementsApi.create(workspaceId, {
|
||||
project_id: currentProject.id,
|
||||
placement_channel_id: channelId,
|
||||
creative_id: creativeId,
|
||||
placement_date: placementDate.toISOString(),
|
||||
cost: cost ? parseFloat(cost) : undefined,
|
||||
comment: comment || undefined,
|
||||
ad_post_url: adPostUrl || undefined,
|
||||
invite_link_type: inviteLinkType,
|
||||
});
|
||||
|
||||
router.push(`/dashboard/${workspaceId}/placements/${placement.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err?.detail || "Не удалось создать размещение");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
|
||||
{ label: "Новое размещение" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Новое размещение</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Создайте размещение рекламы в Telegram канале
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!currentProject && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Выберите проект в верхнем меню для создания размещения
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loadingData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Параметры размещения</CardTitle>
|
||||
<CardDescription>
|
||||
Укажите канал, креатив и условия размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Channel */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Канал для размещения <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={channelId} onValueChange={setChannelId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{channel.title}</span>
|
||||
{channel.username && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@{channel.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Creative */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Креатив <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={creativeId} onValueChange={setCreativeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите креатив" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{creatives.length === 0 ? (
|
||||
<div className="p-2 text-sm text-muted-foreground text-center">
|
||||
Нет доступных креативов
|
||||
</div>
|
||||
) : (
|
||||
creatives.map((creative) => (
|
||||
<SelectItem key={creative.id} value={creative.id}>
|
||||
{creative.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{creatives.length === 0 && currentProject && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Сначала{" "}
|
||||
<a
|
||||
href={`/dashboard/${workspaceId}/creatives/new`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
создайте креатив
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placement Date */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Дата размещения <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!placementDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{placementDate
|
||||
? format(placementDate, "PPP", { locale: ru })
|
||||
: "Выберите дату"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={placementDate}
|
||||
onSelect={(date) => date && setPlacementDate(date)}
|
||||
locale={ru}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost">Стоимость (₽)</Label>
|
||||
<Input
|
||||
id="cost"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invite Link Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Тип пригласительной ссылки</Label>
|
||||
<Select
|
||||
value={inviteLinkType}
|
||||
onValueChange={(v) => setInviteLinkType(v as InviteLinkType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="approval">
|
||||
<div className="flex flex-col">
|
||||
<span>С одобрением (рекомендуется)</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Бот одобряет заявки и отслеживает подписчиков
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="public">
|
||||
<div className="flex flex-col">
|
||||
<span>Публичная ссылка</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Без отслеживания подписчиков
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Ad Post URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adPostUrl">Ссылка на рекламный пост</Label>
|
||||
<Input
|
||||
id="adPostUrl"
|
||||
placeholder="https://t.me/channel/123"
|
||||
value={adPostUrl}
|
||||
onChange={(e) => setAdPostUrl(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ссылка на пост после публикации (можно добавить позже)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">Комментарий</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
placeholder="Дополнительная информация..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!channelId ||
|
||||
!creativeId ||
|
||||
!currentProject
|
||||
}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
"Создать размещение"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
742
app/dashboard/[workspaceId]/placements/page.tsx
Normal file
742
app/dashboard/[workspaceId]/placements/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Placements List Page - Enhanced with Totals Row and Export
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Search,
|
||||
Plus,
|
||||
ShoppingCart,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwarePlacementsApi } from "@/lib/demo";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} 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 { Placement } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
const calculateCPS = (cost: number | null, subscriptions: number) => {
|
||||
if (!cost || subscriptions === 0) return null;
|
||||
return cost / subscriptions;
|
||||
};
|
||||
|
||||
const calculateCPM = (cost: number | null | undefined, views: number | null | undefined) => {
|
||||
if (!cost || !views || views === 0) return null;
|
||||
return (cost / views) * 1000;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Sort Types
|
||||
// ============================================================================
|
||||
|
||||
type SortField = "date" | "cost" | "subscriptions" | "views" | "cps" | "cpm" | "status";
|
||||
type SortOrder = "asc" | "desc" | null;
|
||||
|
||||
// ============================================================================
|
||||
// Aggregation Functions
|
||||
// ============================================================================
|
||||
|
||||
type AggFunc = "sum" | "avg" | "median" | "max" | "min";
|
||||
|
||||
const aggregationFunctions: Record<AggFunc, { label: string; fn: (values: number[]) => number | null }> = {
|
||||
sum: {
|
||||
label: "Сумма",
|
||||
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) : null),
|
||||
},
|
||||
avg: {
|
||||
label: "Среднее",
|
||||
fn: (values) => (values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : null),
|
||||
},
|
||||
median: {
|
||||
label: "Медиана",
|
||||
fn: (values) => {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
},
|
||||
},
|
||||
max: {
|
||||
label: "Макс.",
|
||||
fn: (values) => (values.length > 0 ? Math.max(...values) : null),
|
||||
},
|
||||
min: {
|
||||
label: "Мин.",
|
||||
fn: (values) => (values.length > 0 ? Math.min(...values) : null),
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Export Functions
|
||||
// ============================================================================
|
||||
|
||||
const exportToCSV = (placements: Placement[], filename: string) => {
|
||||
const headers = [
|
||||
"Канал",
|
||||
"Username",
|
||||
"Креатив",
|
||||
"Дата",
|
||||
"Стоимость",
|
||||
"Подписки",
|
||||
"Просмотры",
|
||||
"CPS",
|
||||
"CPM",
|
||||
"Статус",
|
||||
"Ссылка на пост",
|
||||
"Пригласительная ссылка",
|
||||
];
|
||||
|
||||
const rows = placements.map((p) => [
|
||||
p.placement_channel_title,
|
||||
"",
|
||||
p.creative_name,
|
||||
new Date(p.placement_date).toISOString().split("T")[0],
|
||||
p.cost?.toString() || "",
|
||||
p.subscriptions_count.toString(),
|
||||
p.views_count?.toString() || "",
|
||||
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
|
||||
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
|
||||
p.status,
|
||||
p.ad_post_url || "",
|
||||
p.invite_link,
|
||||
]);
|
||||
|
||||
const csv = [headers.join(";"), ...rows.map((r) => r.join(";"))].join("\n");
|
||||
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
|
||||
downloadBlob(blob, `${filename}.csv`);
|
||||
};
|
||||
|
||||
const exportToExcel = (placements: Placement[], filename: string) => {
|
||||
// Simple XLSX-like TSV format (opens in Excel)
|
||||
const headers = [
|
||||
"Канал",
|
||||
"Username",
|
||||
"Креатив",
|
||||
"Дата",
|
||||
"Стоимость",
|
||||
"Подписки",
|
||||
"Просмотры",
|
||||
"CPS",
|
||||
"CPM",
|
||||
"Статус",
|
||||
];
|
||||
|
||||
const rows = placements.map((p) => [
|
||||
p.placement_channel_title,
|
||||
"",
|
||||
p.creative_name,
|
||||
new Date(p.placement_date).toISOString().split("T")[0],
|
||||
p.cost?.toString() || "",
|
||||
p.subscriptions_count.toString(),
|
||||
p.views_count?.toString() || "",
|
||||
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
|
||||
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
|
||||
p.status,
|
||||
]);
|
||||
|
||||
const tsv = [headers.join("\t"), ...rows.map((r) => r.join("\t"))].join("\n");
|
||||
const blob = new Blob(["\uFEFF" + tsv], {
|
||||
type: "application/vnd.ms-excel;charset=utf-8;",
|
||||
});
|
||||
downloadBlob(blob, `${filename}.xls`);
|
||||
};
|
||||
|
||||
const exportToTxt = (placements: Placement[], filename: string) => {
|
||||
const lines = placements.map((p) => {
|
||||
const cps = calculateCPS(p.cost, p.subscriptions_count);
|
||||
const cpm = calculateCPM(p.cost, p.views_count);
|
||||
return [
|
||||
`Канал: ${p.placement_channel_title}`,
|
||||
`Креатив: ${p.creative_name}`,
|
||||
`Дата: ${formatDate(p.placement_date)}`,
|
||||
`Стоимость: ${formatCurrency(p.cost)}`,
|
||||
`Подписки: ${p.subscriptions_count}`,
|
||||
`Просмотры: ${p.views_count || "—"}`,
|
||||
`CPS: ${cps ? formatCurrency(cps) : "—"}`,
|
||||
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
|
||||
`Ссылка: ${p.invite_link}`,
|
||||
"---",
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
const text = lines.join("\n");
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8;" });
|
||||
downloadBlob(blob, `${filename}.txt`);
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function PlacementsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { selectedProjects, getProjectFilterId, allProjectsSelected, hasPermission } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
|
||||
const [placements, setPlacements] = useState<Placement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||
|
||||
// Aggregation settings
|
||||
const [aggCost, setAggCost] = useState<AggFunc>("sum");
|
||||
const [aggSubs, setAggSubs] = useState<AggFunc>("sum");
|
||||
const [aggViews, setAggViews] = useState<AggFunc>("sum");
|
||||
const [aggCps, setAggCps] = useState<AggFunc>("avg");
|
||||
const [aggCpm, setAggCpm] = useState<AggFunc>("avg");
|
||||
|
||||
const canWrite = hasPermission("placements_write");
|
||||
|
||||
useEffect(() => {
|
||||
loadPlacements();
|
||||
}, [workspaceId, projectFilterId, includeArchived]);
|
||||
|
||||
const loadPlacements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await demoAwarePlacementsApi.list(workspaceId, {
|
||||
project_id: projectFilterId,
|
||||
include_archived: includeArchived,
|
||||
size: 100,
|
||||
});
|
||||
setPlacements(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load placements:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sort
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortOrder === "asc") {
|
||||
setSortOrder("desc");
|
||||
} else if (sortOrder === "desc") {
|
||||
setSortField(null);
|
||||
setSortOrder(null);
|
||||
} else {
|
||||
setSortOrder("asc");
|
||||
}
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "asc") return <ArrowUp className="h-4 w-4 ml-1" />;
|
||||
if (sortOrder === "desc") return <ArrowDown className="h-4 w-4 ml-1" />;
|
||||
return <ArrowUpDown className="h-4 w-4 ml-1" />;
|
||||
};
|
||||
|
||||
// Filter and sort
|
||||
const filteredPlacements = useMemo(() => {
|
||||
return placements
|
||||
.filter(
|
||||
(p) =>
|
||||
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.creative_name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!sortField || !sortOrder) return 0;
|
||||
|
||||
let aVal: number | string = 0;
|
||||
let bVal: number | string = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case "date":
|
||||
return sortOrder === "asc"
|
||||
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime()
|
||||
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime();
|
||||
case "cost":
|
||||
aVal = a.cost ?? 0;
|
||||
bVal = b.cost ?? 0;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.subscriptions_count;
|
||||
bVal = b.subscriptions_count;
|
||||
break;
|
||||
case "views":
|
||||
aVal = a.views_count ?? 0;
|
||||
bVal = b.views_count ?? 0;
|
||||
break;
|
||||
case "cps":
|
||||
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0;
|
||||
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0;
|
||||
break;
|
||||
case "cpm":
|
||||
aVal = calculateCPM(a.cost, a.views_count) ?? 0;
|
||||
bVal = calculateCPM(b.cost, b.views_count) ?? 0;
|
||||
break;
|
||||
case "status":
|
||||
aVal = a.status;
|
||||
bVal = b.status;
|
||||
return sortOrder === "asc"
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
}
|
||||
|
||||
return sortOrder === "asc"
|
||||
? (aVal as number) - (bVal as number)
|
||||
: (bVal as number) - (aVal as number);
|
||||
});
|
||||
}, [placements, search, sortField, sortOrder]);
|
||||
|
||||
// Calculate totals with aggregation functions
|
||||
const totals = useMemo(() => {
|
||||
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null);
|
||||
const subs = filteredPlacements.map((p) => p.subscriptions_count);
|
||||
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null);
|
||||
const cpsValues = filteredPlacements
|
||||
.map((p) => calculateCPS(p.cost, p.subscriptions_count))
|
||||
.filter((c): c is number => c !== null);
|
||||
const cpmValues = filteredPlacements
|
||||
.map((p) => calculateCPM(p.cost, p.views_count))
|
||||
.filter((c): c is number => c !== null);
|
||||
|
||||
return {
|
||||
cost: aggregationFunctions[aggCost].fn(costs),
|
||||
subscriptions: aggregationFunctions[aggSubs].fn(subs),
|
||||
views: aggregationFunctions[aggViews].fn(views),
|
||||
cps: aggregationFunctions[aggCps].fn(cpsValues),
|
||||
cpm: aggregationFunctions[aggCpm].fn(cpmValues),
|
||||
};
|
||||
}, [filteredPlacements, aggCost, aggSubs, aggViews, aggCps, aggCpm]);
|
||||
|
||||
// Export handlers
|
||||
const handleExport = useCallback(
|
||||
(format: "csv" | "excel" | "txt") => {
|
||||
const filename = `placements_${new Date().toISOString().split("T")[0]}`;
|
||||
switch (format) {
|
||||
case "csv":
|
||||
exportToCSV(filteredPlacements, filename);
|
||||
break;
|
||||
case "excel":
|
||||
exportToExcel(filteredPlacements, filename);
|
||||
break;
|
||||
case "txt":
|
||||
exportToTxt(filteredPlacements, filename);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[filteredPlacements]
|
||||
);
|
||||
|
||||
// Aggregation selector component
|
||||
const AggSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AggFunc;
|
||||
onChange: (v: AggFunc) => void;
|
||||
}) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 ml-1 opacity-50 hover:opacity-100">
|
||||
<span className="text-[10px] font-mono">f</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Функция</DropdownMenuLabel>
|
||||
{(Object.keys(aggregationFunctions) as AggFunc[]).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => onChange(key)}
|
||||
className={value === key ? "bg-accent" : ""}
|
||||
>
|
||||
{aggregationFunctions[key].label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
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">
|
||||
{filteredPlacements.length} размещений
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export Button */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Экспорт
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleExport("excel")}>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-2" />
|
||||
Excel (.xls)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleExport("txt")}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Текст (.txt)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{canWrite && (
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${workspaceId}/placements/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>
|
||||
) : filteredPlacements.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<ShoppingCart 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}/placements/new`}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать размещение
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Канал</TableHead>
|
||||
<TableHead>Креатив</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("date")}
|
||||
>
|
||||
Дата
|
||||
{getSortIcon("date")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cost")}
|
||||
>
|
||||
Стоимость
|
||||
{getSortIcon("cost")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("subscriptions")}
|
||||
>
|
||||
Подписки
|
||||
{getSortIcon("subscriptions")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("views")}
|
||||
>
|
||||
Просмотры
|
||||
{getSortIcon("views")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cps")}
|
||||
>
|
||||
CPS
|
||||
{getSortIcon("cps")}
|
||||
</Button>
|
||||
<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>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cpm")}
|
||||
>
|
||||
CPM
|
||||
{getSortIcon("cpm")}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Cost Per Mille (1000 views)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Статус
|
||||
{getSortIcon("status")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPlacements.map((placement) => {
|
||||
const cps = calculateCPS(placement.cost, placement.subscriptions_count);
|
||||
const cpm = calculateCPM(placement.cost, placement.views_count);
|
||||
|
||||
return (
|
||||
<TableRow key={placement.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{placement.placement_channel_title}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{placement.creative_name}</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(placement.placement_date)}</TableCell>
|
||||
<TableCell>{formatCurrency(placement.cost)}</TableCell>
|
||||
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell>
|
||||
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell>
|
||||
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
|
||||
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={placement.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{placement.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}/placements/${placement.id}`}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Подробнее
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{placement.ad_post_url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={placement.ad_post_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Открыть пост
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
{/* Totals Row */}
|
||||
<TableFooter>
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell colSpan={3}>
|
||||
<span className="text-muted-foreground">Итого</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
{formatCurrency(totals.cost)}
|
||||
<AggSelector value={aggCost} onChange={setAggCost} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
{formatNumber(totals.subscriptions)}
|
||||
<AggSelector value={aggSubs} onChange={setAggSubs} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
{formatNumber(totals.views)}
|
||||
<AggSelector value={aggViews} onChange={setAggViews} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
{totals.cps ? formatCurrency(totals.cps) : "—"}
|
||||
<AggSelector value={aggCps} onChange={setAggCps} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center">
|
||||
{totals.cpm ? formatCurrency(totals.cpm) : "—"}
|
||||
<AggSelector value={aggCpm} onChange={setAggCpm} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
458
app/dashboard/[workspaceId]/projects/page.tsx
Normal file
458
app/dashboard/[workspaceId]/projects/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Projects Page (Target Channels) - Enhanced with Statistics
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Tv,
|
||||
ExternalLink,
|
||||
Grid,
|
||||
List,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Eye,
|
||||
ShoppingCart,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwareAnalyticsApi } from "@/lib/demo";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||
import type { Project, SpendingAnalytics } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface ProjectWithStats extends Project {
|
||||
stats?: {
|
||||
total_cost: number;
|
||||
total_subscriptions: number;
|
||||
total_views: number;
|
||||
avg_cps: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
|
||||
|
||||
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||
|
||||
// Load statistics for each project
|
||||
useEffect(() => {
|
||||
if (projects.length === 0) {
|
||||
setProjectsWithStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
const statsPromises = projects.map(async (project) => {
|
||||
try {
|
||||
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||
project_id: project.id,
|
||||
});
|
||||
return {
|
||||
...project,
|
||||
stats: {
|
||||
total_cost: spending.total_cost,
|
||||
total_subscriptions: spending.total_subscriptions,
|
||||
total_views: spending.total_views,
|
||||
avg_cps:
|
||||
spending.total_subscriptions > 0
|
||||
? spending.total_cost / spending.total_subscriptions
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { ...project, stats: undefined };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(statsPromises);
|
||||
setProjectsWithStats(results);
|
||||
} catch (err) {
|
||||
console.error("Failed to load project stats:", err);
|
||||
setProjectsWithStats(projects);
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
}, [projects, workspaceId]);
|
||||
|
||||
const isLoading = isLoadingProjects || loadingStats;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${currentWorkspace?.id}` },
|
||||
{ label: "Проекты" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
Telegram каналы, которые вы продвигаете
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Плиточный вид"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("table")}
|
||||
aria-label="Табличный вид"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Tv className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Чтобы добавить новый проект, добавьте бота{" "}
|
||||
<a
|
||||
href={`https://t.me/${BOT_USERNAME}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
@{BOT_USERNAME}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>{" "}
|
||||
администратором в ваш Telegram канал.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projectsWithStats.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Tv 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 max-w-md">
|
||||
Добавьте бота администратором в ваш Telegram канал, и он
|
||||
автоматически появится здесь как проект.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === "table" ? (
|
||||
// Table View
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Канал</TableHead>
|
||||
<TableHead>Статус</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">
|
||||
Просмотры
|
||||
<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 className="w-[140px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projectsWithStats.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Tv className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{project.title}</div>
|
||||
{project.username && (
|
||||
<a
|
||||
href={`https://t.me/${project.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
@{project.username}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{project.status === "active"
|
||||
? "Активен"
|
||||
: project.status === "inactive"
|
||||
? "Неактивен"
|
||||
: "Архив"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(project.stats?.total_cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(project.stats?.total_subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(project.stats?.total_views)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(project.stats?.avg_cps)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
|
||||
>
|
||||
План закупа
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
// Grid View
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projectsWithStats.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Tv className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||
{project.username && (
|
||||
<CardDescription>
|
||||
<a
|
||||
href={`https://t.me/${project.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
@{project.username}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{project.status === "active"
|
||||
? "Активен"
|
||||
: project.status === "inactive"
|
||||
? "Неактивен"
|
||||
: "Архив"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
{project.stats && (
|
||||
<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(project.stats.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(project.stats.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(project.stats.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(project.stats.avg_cps)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Сред. CPS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{project.username && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`https://t.me/${project.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
Канал
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
|
||||
>
|
||||
План закупа
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
742
app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
Normal file
742
app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
Normal file
@@ -0,0 +1,742 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Purchase Plan Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowUpDown,
|
||||
ShoppingCart,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { purchasePlanApi, channelsApi } from "@/lib/api";
|
||||
import { demoAwarePurchasePlanApi, isDemoWorkspace, demoChannels } from "@/lib/demo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { Project, PurchasePlanChannel, Channel } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "planned":
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Запланировано
|
||||
</Badge>
|
||||
);
|
||||
case "completed":
|
||||
return (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Завершено
|
||||
</Badge>
|
||||
);
|
||||
case "cancelled":
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Отменено
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
type SortField = "channel" | "status" | "cost";
|
||||
type SortOrder = "asc" | "desc" | null;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function PurchasePlanDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const projectId = params?.projectId as string;
|
||||
const { projects, hasPermission } = useWorkspace();
|
||||
|
||||
const [planChannels, setPlanChannels] = useState<PurchasePlanChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
// Add channel dialog
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [availableChannels, setAvailableChannels] = useState<Channel[]>([]);
|
||||
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState("");
|
||||
const [plannedCost, setPlannedCost] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
// Edit dialog
|
||||
const [editingChannel, setEditingChannel] = useState<PurchasePlanChannel | null>(null);
|
||||
const [editPlannedCost, setEditPlannedCost] = useState("");
|
||||
const [editComment, setEditComment] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Delete confirmation
|
||||
const [deletingChannel, setDeletingChannel] = useState<PurchasePlanChannel | null>(null);
|
||||
|
||||
// Sorting
|
||||
const [sortField, setSortField] = useState<SortField | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
|
||||
|
||||
const canWrite = hasPermission("placements_write");
|
||||
|
||||
useEffect(() => {
|
||||
const proj = projects.find((p) => p.id === projectId);
|
||||
if (proj) {
|
||||
setProject(proj);
|
||||
}
|
||||
}, [projects, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlanChannels();
|
||||
}, [workspaceId, projectId]);
|
||||
|
||||
const loadPlanChannels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await demoAwarePurchasePlanApi.list(workspaceId, projectId, { size: 100 });
|
||||
setPlanChannels(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load purchase plan:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailableChannels = async () => {
|
||||
try {
|
||||
setLoadingChannels(true);
|
||||
// In demo mode use mock channels
|
||||
const isDemo = isDemoWorkspace(workspaceId);
|
||||
const response = isDemo
|
||||
? { items: demoChannels, total: demoChannels.length, page: 1, size: 100, pages: 1 }
|
||||
: await channelsApi.list({ size: 100 });
|
||||
// Filter out channels already in plan
|
||||
const existingIds = new Set(planChannels.map((pc) => pc.channel.id));
|
||||
setAvailableChannels(response.items.filter((c) => !existingIds.has(c.id)));
|
||||
} catch (err) {
|
||||
console.error("Failed to load channels:", err);
|
||||
} finally {
|
||||
setLoadingChannels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
loadAvailableChannels();
|
||||
setSelectedChannelId("");
|
||||
setPlannedCost("");
|
||||
setComment("");
|
||||
setShowAddDialog(true);
|
||||
};
|
||||
|
||||
const handleAddChannel = async () => {
|
||||
if (!selectedChannelId) return;
|
||||
|
||||
try {
|
||||
setIsAdding(true);
|
||||
await purchasePlanApi.create(workspaceId, projectId, {
|
||||
channel_id: selectedChannelId,
|
||||
planned_cost: plannedCost ? parseFloat(plannedCost) : undefined,
|
||||
comment: comment || undefined,
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
loadPlanChannels();
|
||||
} catch (err) {
|
||||
console.error("Failed to add channel:", err);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditDialog = (channel: PurchasePlanChannel) => {
|
||||
setEditingChannel(channel);
|
||||
setEditPlannedCost(channel.planned_cost?.toString() || "");
|
||||
setEditComment(channel.comment || "");
|
||||
};
|
||||
|
||||
const handleUpdateChannel = async () => {
|
||||
if (!editingChannel) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await purchasePlanApi.update(workspaceId, projectId, editingChannel.id, {
|
||||
planned_cost: editPlannedCost ? parseFloat(editPlannedCost) : undefined,
|
||||
comment: editComment || undefined,
|
||||
});
|
||||
setEditingChannel(null);
|
||||
loadPlanChannels();
|
||||
} catch (err) {
|
||||
console.error("Failed to update channel:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async () => {
|
||||
if (!deletingChannel) return;
|
||||
|
||||
try {
|
||||
await purchasePlanApi.delete(workspaceId, projectId, deletingChannel.id);
|
||||
setDeletingChannel(null);
|
||||
loadPlanChannels();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete channel:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Sorting
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
if (sortOrder === "asc") setSortOrder("desc");
|
||||
else if (sortOrder === "desc") {
|
||||
setSortField(null);
|
||||
setSortOrder(null);
|
||||
}
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedChannels = useMemo(() => {
|
||||
if (!sortField || !sortOrder) return planChannels;
|
||||
|
||||
return [...planChannels].sort((a, b) => {
|
||||
let aVal: string | number = 0;
|
||||
let bVal: string | number = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case "channel":
|
||||
aVal = a.channel.title.toLowerCase();
|
||||
bVal = b.channel.title.toLowerCase();
|
||||
break;
|
||||
case "status":
|
||||
aVal = a.status;
|
||||
bVal = b.status;
|
||||
break;
|
||||
case "cost":
|
||||
aVal = a.planned_cost ?? 0;
|
||||
bVal = b.planned_cost ?? 0;
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof aVal === "string") {
|
||||
return sortOrder === "asc"
|
||||
? aVal.localeCompare(bVal as string)
|
||||
: (bVal as string).localeCompare(aVal);
|
||||
}
|
||||
return sortOrder === "asc" ? aVal - (bVal as number) : (bVal as number) - aVal;
|
||||
});
|
||||
}, [planChannels, sortField, sortOrder]);
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
const planned = planChannels.filter((c) => c.status === "planned");
|
||||
return {
|
||||
totalChannels: planChannels.length,
|
||||
plannedCount: planned.length,
|
||||
completedCount: planChannels.filter((c) => c.status === "completed").length,
|
||||
totalPlannedCost: planned.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
|
||||
};
|
||||
}, [planChannels]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} showProjectSelector={false} />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Планы закупок", href: `/dashboard/${workspaceId}/purchase-plans` },
|
||||
{ label: project?.title || "План закупок" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/dashboard/${workspaceId}/purchase-plans`)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
План закупок: {project?.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{stats.totalChannels} каналов • Планируемый бюджет:{" "}
|
||||
{formatCurrency(stats.totalPlannedCost)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={loadPlanChannels}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button onClick={handleOpenAddDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Добавить канал
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Всего каналов
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalChannels}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Запланировано
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.plannedCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Завершено
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.completedCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Планируемый бюджет
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.totalPlannedCost)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{planChannels.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<ShoppingCart 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">
|
||||
Добавьте каналы для размещения рекламы
|
||||
</p>
|
||||
{canWrite && (
|
||||
<Button onClick={handleOpenAddDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Добавить канал
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("channel")}
|
||||
>
|
||||
Канал
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("status")}
|
||||
>
|
||||
Статус
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => handleSort("cost")}
|
||||
>
|
||||
План. стоимость
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead>Комментарий</TableHead>
|
||||
<TableHead className="w-[100px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedChannels.map((planChannel) => (
|
||||
<TableRow key={planChannel.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{planChannel.channel.title}</div>
|
||||
{planChannel.channel.username && (
|
||||
<a
|
||||
href={`https://t.me/${planChannel.channel.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{planChannel.channel.username}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(planChannel.status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(planChannel.planned_cost)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{planChannel.comment || "—"}
|
||||
</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}/placements/new?channel_id=${planChannel.channel.id}`}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Создать размещение
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{canWrite && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleOpenEditDialog(planChannel)}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeletingChannel(planChannel)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="font-bold">
|
||||
Итого запланировано:
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold">
|
||||
{formatCurrency(stats.totalPlannedCost)}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Channel Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Добавить канал в план</DialogTitle>
|
||||
<DialogDescription>
|
||||
Выберите канал из каталога для добавления в план закупок
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Канал</Label>
|
||||
{loadingChannels ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Загрузка каналов...
|
||||
</div>
|
||||
) : (
|
||||
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableChannels.length === 0 ? (
|
||||
<div className="p-2 text-sm text-muted-foreground text-center">
|
||||
Все каналы уже добавлены
|
||||
</div>
|
||||
) : (
|
||||
availableChannels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{channel.title}</span>
|
||||
{channel.username && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@{channel.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="planned-cost">Планируемая стоимость (₽)</Label>
|
||||
<Input
|
||||
id="planned-cost"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={plannedCost}
|
||||
onChange={(e) => setPlannedCost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">Комментарий</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
placeholder="Дополнительная информация..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddChannel}
|
||||
disabled={!selectedChannelId || isAdding}
|
||||
>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Добавление...
|
||||
</>
|
||||
) : (
|
||||
"Добавить"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingChannel} onOpenChange={(open) => !open && setEditingChannel(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Редактировать канал</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingChannel?.channel.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-planned-cost">Планируемая стоимость (₽)</Label>
|
||||
<Input
|
||||
id="edit-planned-cost"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={editPlannedCost}
|
||||
onChange={(e) => setEditPlannedCost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-comment">Комментарий</Label>
|
||||
<Textarea
|
||||
id="edit-comment"
|
||||
placeholder="Дополнительная информация..."
|
||||
value={editComment}
|
||||
onChange={(e) => setEditComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingChannel(null)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleUpdateChannel} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog
|
||||
open={!!deletingChannel}
|
||||
onOpenChange={(open) => !open && setDeletingChannel(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить канал из плана?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Канал "{deletingChannel?.channel.title}" будет удалён из плана закупок.
|
||||
Это действие нельзя отменить.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteChannel}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
234
app/dashboard/[workspaceId]/purchase-plans/page.tsx
Normal file
234
app/dashboard/[workspaceId]/purchase-plans/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Purchase Plans Overview Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
ClipboardList,
|
||||
ChevronRight,
|
||||
Tv,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { demoAwarePurchasePlanApi } from "@/lib/demo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { Project, PurchasePlanChannel } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
interface ProjectPlanStats {
|
||||
totalChannels: number;
|
||||
plannedChannels: number;
|
||||
completedChannels: number;
|
||||
cancelledChannels: number;
|
||||
totalPlannedCost: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function PurchasePlansPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { projects, isLoadingProjects } = useWorkspace();
|
||||
|
||||
const [projectStats, setProjectStats] = useState<Record<string, ProjectPlanStats>>({});
|
||||
const [loadingStats, setLoadingStats] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length > 0) {
|
||||
loadAllStats();
|
||||
} else {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
}, [projects, workspaceId]);
|
||||
|
||||
const loadAllStats = async () => {
|
||||
setLoadingStats(true);
|
||||
const stats: Record<string, ProjectPlanStats> = {};
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const response = await demoAwarePurchasePlanApi.list(workspaceId, project.id, { size: 100 });
|
||||
const channels = response.items;
|
||||
|
||||
stats[project.id] = {
|
||||
totalChannels: channels.length,
|
||||
plannedChannels: channels.filter((c) => c.status === "planned").length,
|
||||
completedChannels: channels.filter((c) => c.status === "completed").length,
|
||||
cancelledChannels: channels.filter((c) => c.status === "cancelled").length,
|
||||
totalPlannedCost: channels
|
||||
.filter((c) => c.status === "planned")
|
||||
.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to load plan for project ${project.id}:`, err);
|
||||
stats[project.id] = {
|
||||
totalChannels: 0,
|
||||
plannedChannels: 0,
|
||||
completedChannels: 0,
|
||||
cancelledChannels: 0,
|
||||
totalPlannedCost: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
setProjectStats(stats);
|
||||
setLoadingStats(false);
|
||||
};
|
||||
|
||||
const isLoading = isLoadingProjects || loadingStats;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Планы закупок" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
Управление планами закупок по проектам
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<ClipboardList 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">
|
||||
Добавьте проект через Telegram бота для создания плана закупок
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${workspaceId}/projects`}>
|
||||
Перейти к проектам
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => {
|
||||
const stats = projectStats[project.id];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
|
||||
}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Tv className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
{project.username && (
|
||||
<CardDescription>@{project.username}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Всего каналов:</span>
|
||||
<span className="font-medium">{stats.totalChannels}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stats.plannedChannels > 0 && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{stats.plannedChannels} план.
|
||||
</Badge>
|
||||
)}
|
||||
{stats.completedChannels > 0 && (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
{stats.completedChannels} заверш.
|
||||
</Badge>
|
||||
)}
|
||||
{stats.cancelledChannels > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
{stats.cancelledChannels} отмен.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats.totalPlannedCost > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm pt-2 border-t">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">План. бюджет:</span>
|
||||
<span className="font-medium ml-auto">
|
||||
{formatCurrency(stats.totalPlannedCost)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Загрузка...</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
267
app/dashboard/[workspaceId]/settings/invites/page.tsx
Normal file
267
app/dashboard/[workspaceId]/settings/invites/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Invites Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, UserPlus, Mail, Check, X, Clock } from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { invitesApi } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { WorkspaceInvite } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function InvitesPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
|
||||
const [invites, setInvites] = useState<WorkspaceInvite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadInvites();
|
||||
}, [workspaceId]);
|
||||
|
||||
const loadInvites = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await invitesApi.list(workspaceId, { size: 100 });
|
||||
setInvites(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load invites:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
if (!username.trim()) return;
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
// Remove @ if present
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
});
|
||||
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowDialog(false);
|
||||
setUsername("");
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Не удалось отправить приглашение");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case "accepted":
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case "rejected":
|
||||
return <X className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: WorkspaceInvite["status"]) => {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "Ожидает";
|
||||
case "accepted":
|
||||
return "Принято";
|
||||
case "rejected":
|
||||
return "Отклонено";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
|
||||
{ label: "Приглашения" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
Приглашения в воркспейс через Telegram
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Пригласить
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Пригласить участника</DialogTitle>
|
||||
<DialogDescription>
|
||||
Введите username Telegram пользователя для отправки
|
||||
приглашения через бота
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Telegram username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="@username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateInvite();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvite}
|
||||
disabled={!username.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : invites.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Mail 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">
|
||||
Пригласите участников в воркспейс через Telegram
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.map((invite) => (
|
||||
<TableRow key={invite.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">@{invite.username}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
invite.status === "pending"
|
||||
? "outline"
|
||||
: invite.status === "accepted"
|
||||
? "default"
|
||||
: "destructive"
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
{getStatusIcon(invite.status)}
|
||||
{getStatusText(invite.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(invite.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
281
app/dashboard/[workspaceId]/settings/members/page.tsx
Normal file
281
app/dashboard/[workspaceId]/settings/members/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Members Page
|
||||
// ============================================================================
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, Users, Shield, ChevronDown } from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { membersApi } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { WorkspaceMember, PermissionKey } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||
admin_full: "Полный доступ",
|
||||
projects_read: "Просмотр проектов",
|
||||
projects_write: "Редактирование проектов",
|
||||
placements_read: "Просмотр размещений",
|
||||
placements_write: "Редактирование размещений",
|
||||
analytics_read: "Просмотр аналитики",
|
||||
};
|
||||
|
||||
const ALL_PERMISSIONS: PermissionKey[] = [
|
||||
"admin_full",
|
||||
"projects_read",
|
||||
"projects_write",
|
||||
"placements_read",
|
||||
"placements_write",
|
||||
"analytics_read",
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export default function MembersPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
|
||||
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMembers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await membersApi.list(workspaceId, { size: 100 });
|
||||
setMembers(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load members:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadMembers();
|
||||
}, [workspaceId]);
|
||||
|
||||
const handlePermissionChange = async (
|
||||
memberId: string,
|
||||
userId: string,
|
||||
permission: PermissionKey,
|
||||
enabled: boolean
|
||||
) => {
|
||||
try {
|
||||
setUpdating(memberId);
|
||||
|
||||
const member = members.find((m) => m.id === memberId);
|
||||
if (!member) return;
|
||||
|
||||
let newPermissions = [...member.permissions];
|
||||
|
||||
if (enabled) {
|
||||
// Add permission
|
||||
if (!newPermissions.some((p) => p.key === permission)) {
|
||||
newPermissions.push({ key: permission });
|
||||
}
|
||||
} else {
|
||||
// Remove permission
|
||||
newPermissions = newPermissions.filter((p) => p.key !== permission);
|
||||
}
|
||||
|
||||
await membersApi.updatePermissions(workspaceId, userId, {
|
||||
permissions: newPermissions,
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setMembers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === memberId ? { ...m, permissions: newPermissions } : m
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to update permissions:", err);
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
|
||||
return member.permissions.some((p) => p.key === key);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
|
||||
{ label: "Участники" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<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">
|
||||
Управление участниками и правами доступа
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={`/dashboard/${workspaceId}/settings/invites`}>
|
||||
Пригласить участника
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Users 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">
|
||||
Пригласите участников в воркспейс
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Права доступа</TableHead>
|
||||
<TableHead className="w-[150px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{member.user.username || `User ${member.user.telegram_id}`}
|
||||
</div>
|
||||
{member.user.username && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@{member.user.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
member.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{member.status === "active" ? "Активен" : "Приглашён"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{hasPermission(member, "admin_full") ? (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Админ
|
||||
</Badge>
|
||||
) : (
|
||||
member.permissions.map((p) => (
|
||||
<Badge key={p.key} variant="outline">
|
||||
{PERMISSION_LABELS[p.key]}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{member.permissions.length === 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Нет прав
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={updating === member.id}
|
||||
>
|
||||
{updating === member.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Права
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Права доступа</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{ALL_PERMISSIONS.map((perm) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={perm}
|
||||
checked={hasPermission(member, perm)}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePermissionChange(
|
||||
member.id,
|
||||
member.user.id,
|
||||
perm,
|
||||
checked
|
||||
)
|
||||
}
|
||||
>
|
||||
{PERMISSION_LABELS[perm]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
204
app/dashboard/[workspaceId]/settings/page.tsx
Normal file
204
app/dashboard/[workspaceId]/settings/page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Workspace Settings Page
|
||||
// ============================================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Loader2, Settings, Pencil, Trash2 } from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { workspacesApi } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
|
||||
|
||||
const [name, setName] = useState(currentWorkspace?.name || "");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!name.trim() || name === currentWorkspace?.name) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await workspacesApi.update(workspaceId, { name: name.trim() });
|
||||
await refreshWorkspaces();
|
||||
} catch (err) {
|
||||
console.error("Failed to update workspace:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await workspacesApi.delete(workspaceId);
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete workspace:", err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentWorkspace) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Настройки" }]} />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Настройки" },
|
||||
]}
|
||||
showProjectSelector={false}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Настройки воркспейса</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Управление настройками воркспейса
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* General Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Основные настройки</CardTitle>
|
||||
<CardDescription>
|
||||
Название и основные параметры воркспейса
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">Название</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Название воркспейса"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
name === currentWorkspace.name ||
|
||||
isUpdating
|
||||
}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Pencil className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Management Links */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Команда</CardTitle>
|
||||
<CardDescription>
|
||||
Управление участниками и приглашениями
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<a href={`/dashboard/${workspaceId}/settings/members`}>
|
||||
Участники воркспейса
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<a href={`/dashboard/${workspaceId}/settings/invites`}>
|
||||
Приглашения
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Опасная зона</CardTitle>
|
||||
<CardDescription>
|
||||
Необратимые действия с воркспейсом
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить воркспейс
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить воркспейс?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Это действие необратимо. Все данные воркспейса будут
|
||||
удалены, включая проекты, креативы, размещения и аналитику.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : null}
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user