feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -0,0 +1,517 @@
"use client";
// ============================================================================
// All Placements Analytics Page
// ============================================================================
import { useEffect, useState, useMemo, useCallback } from "react";
import { useParams } from "next/navigation";
import { Loader2, ArrowUpDown, ArrowUpRight, Calendar, Filter, X } 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 { analyticsApi, channelsApi } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { PlacementAnalyticsItem, Channel } from "@/lib/types/api";
// ============================================================================
// Types
// ============================================================================
type SortField = "placement_date" | "cost" | "subscriptions_count" | "views_count" | "cpf" | "cpm";
type SortDirection = "asc" | "desc";
interface SortConfig {
field: SortField;
direction: SortDirection;
}
interface DateRange {
from: Date | undefined;
to?: Date | undefined;
}
// ============================================================================
// Helpers
// ============================================================================
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);
};
const formatDate = (dateStr: string | undefined) => {
if (!dateStr) return "—";
try {
return format(new Date(dateStr), "d MMM yyyy, HH:mm", { locale: ru });
} catch {
return "—";
}
};
// ============================================================================
// Main Component
// ============================================================================
export default function PlacementsAnalyticsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects } = useWorkspace();
const [placements, setPlacements] = useState<PlacementAnalyticsItem[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [loading, setLoading] = useState(true);
// Filters
const [selectedProjectId, setSelectedProjectId] = useState<string>("all");
const [selectedChannelId, setSelectedChannelId] = useState<string>("all");
const [selectedCreativeId, setSelectedCreativeId] = useState<string>("all");
const [dateRange, setDateRange] = useState<DateRange>({ from: undefined, to: undefined });
// Sorting
const [sortConfig, setSortConfig] = useState<SortConfig>({
field: "placement_date",
direction: "desc",
});
const loadChannels = useCallback(async () => {
try {
const response = await channelsApi.list({ size: 1000 });
setChannels(response.items);
} catch (err) {
console.error("Failed to load channels:", err);
setChannels([]);
}
}, []);
const loadPlacements = useCallback(async () => {
try {
setLoading(true);
const response = await analyticsApi.placements(workspaceId, {
project_id: selectedProjectId !== "all" ? selectedProjectId : undefined,
placement_channel_id: selectedChannelId !== "all" ? selectedChannelId : undefined,
creative_id: selectedCreativeId !== "all" ? selectedCreativeId : undefined,
date_from: dateRange.from ? dateRange.from.toISOString() : undefined,
date_to: dateRange.to ? dateRange.to.toISOString() : undefined,
});
setPlacements(response.items);
} catch (err) {
console.error("Failed to load placements:", err);
setPlacements([]);
} finally {
setLoading(false);
}
}, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange]);
// Load data
useEffect(() => {
loadChannels();
loadPlacements();
}, [workspaceId, selectedProjectId, selectedChannelId, selectedCreativeId, dateRange, loadPlacements, loadChannels]);
// Get unique creatives from placements
const creatives = useMemo(() => {
const creativeMap = new Map<string, string>();
placements.forEach((p) => {
if (p.creative_id && p.creative_name) {
creativeMap.set(p.creative_id, p.creative_name);
}
});
return Array.from(creativeMap.entries()).map(([id, name]) => ({ id, name }));
}, [placements]);
// Sort placements
const sortedPlacements = useMemo(() => {
const sorted = [...placements].sort((a, b) => {
let aVal: number | string | null | undefined = a[sortConfig.field];
let bVal: number | string | null | undefined = b[sortConfig.field];
if (sortConfig.field === "placement_date") {
aVal = aVal ? new Date(aVal as string).getTime() : 0;
bVal = bVal ? new Date(bVal as string).getTime() : 0;
}
if (aVal === null || aVal === undefined) aVal = sortConfig.direction === "asc" ? Infinity : -Infinity;
if (bVal === null || bVal === undefined) bVal = sortConfig.direction === "asc" ? Infinity : -Infinity;
if (sortConfig.direction === "asc") {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
return sorted;
}, [placements, sortConfig]);
// Calculate totals
const totals = useMemo(() => {
return {
cost: placements.reduce((sum, p) => sum + (p.cost || 0), 0),
subscriptions_count: placements.reduce((sum, p) => sum + p.subscriptions_count, 0),
views_count: placements.reduce((sum, p) => sum + (p.views_count || 0), 0),
cpf: placements.length > 0
? placements.reduce((sum, p) => sum + (p.cpf || 0), 0) / placements.length
: null,
cpm: placements.length > 0
? placements.reduce((sum, p) => sum + (p.cpm || 0), 0) / placements.length
: null,
};
}, [placements]);
// Handle sort click
const handleSort = (field: SortField) => {
setSortConfig((prev) => ({
field,
direction: prev.field === field && prev.direction === "desc" ? "asc" : "desc",
}));
};
// Reset filters
const resetFilters = () => {
setSelectedProjectId("all");
setSelectedChannelId("all");
setSelectedCreativeId("all");
setDateRange({ from: undefined, to: undefined });
};
// Has active filters
const hasActiveFilters = selectedProjectId !== "all" || selectedChannelId !== "all" || selectedCreativeId !== "all" || dateRange.from || dateRange.to;
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>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Всего размещений</CardDescription>
<CardTitle className="text-2xl">{formatNumber(placements.length)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Сумма расходов</CardDescription>
<CardTitle className="text-2xl">{formatCurrency(totals.cost)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Подписчиков</CardDescription>
<CardTitle className="text-2xl">{formatNumber(totals.subscriptions_count)}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Просмотров</CardDescription>
<CardTitle className="text-2xl">{formatNumber(totals.views_count)}</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Фильтры</CardTitle>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={resetFilters}>
<X className="h-4 w-4 mr-2" />
Сбросить
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-5">
<div className="space-y-2">
<label className="text-sm font-medium">Проект</label>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger>
<SelectValue placeholder="Все проекты" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все проекты</SelectItem>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Канал</label>
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
<SelectTrigger>
<SelectValue placeholder="Все каналы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все каналы</SelectItem>
{channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Креатив</label>
<Select value={selectedCreativeId} onValueChange={setSelectedCreativeId}>
<SelectTrigger>
<SelectValue placeholder="Все креативы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все креативы</SelectItem>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Период</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateRange.from && !dateRange.to && "text-muted-foreground"
)}
>
<Calendar className="mr-2 h-4 w-4" />
{dateRange.from && dateRange.to
? `${format(dateRange.from, "d MMM", { locale: ru })} - ${format(dateRange.to, "d MMM yyyy", { locale: ru })}`
: dateRange.from
? `${format(dateRange.from, "d MMM yyyy", { locale: ru })} - ...`
: "Выберите период"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="range"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selected={dateRange as any}
onSelect={(range: DateRange | undefined) => setDateRange({ from: range?.from, to: range?.to })}
initialFocus
numberOfMonths={2}
locale={ru}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-end">
<Button
variant="outline"
className="w-full"
onClick={loadPlacements}
>
<Filter className="h-4 w-4 mr-2" />
Применить
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardContent className="pt-6">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : placements.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground">Нет завершённых размещений</p>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("placement_date")}
>
<div className="flex items-center gap-1">
Дата
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort("cost")}
>
<div className="flex items-center gap-1">
Стоимость
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead>Канал размещения</TableHead>
<TableHead>Проект</TableHead>
<TableHead>Креатив</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("subscriptions_count")}
>
<div className="flex items-center justify-end gap-1">
Подписчики
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("views_count")}
>
<div className="flex items-center justify-end gap-1">
Просмотры
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("cpf")}
>
<div className="flex items-center justify-end gap-1">
CPF
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 text-right"
onClick={() => handleSort("cpm")}
>
<div className="flex items-center justify-end gap-1">
CPM
<ArrowUpDown className="h-4 w-4" />
</div>
</TableHead>
<TableHead className="text-center">Пост</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedPlacements.map((placement) => (
<TableRow key={placement.id}>
<TableCell className="whitespace-nowrap">
{formatDate(placement.placement_date)}
</TableCell>
<TableCell className="whitespace-nowrap font-medium">
{formatCurrency(placement.cost)}
</TableCell>
<TableCell className="whitespace-nowrap">
{placement.placement_channel_title}
</TableCell>
<TableCell className="whitespace-nowrap">
{placement.project_channel_title}
</TableCell>
<TableCell className="whitespace-nowrap">
{placement.creative_name}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatNumber(placement.subscriptions_count)}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatNumber(placement.views_count)}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatCurrency(placement.cpf)}
</TableCell>
<TableCell className="text-right whitespace-nowrap">
{formatCurrency(placement.cpm)}
</TableCell>
<TableCell className="text-center">
{placement.post_url ? (
<a
href={placement.post_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center p-1 rounded hover:bg-muted"
>
<ArrowUpRight className="h-4 w-4 text-muted-foreground" />
</a>
) : (
<span className="text-muted-foreground/50"></span>
)}
</TableCell>
</TableRow>
))}
{/* Totals Row */}
<TableRow className="bg-muted/50 font-medium">
<TableCell>ИТОГО</TableCell>
<TableCell>{formatCurrency(totals.cost)}</TableCell>
<TableCell colSpan={2}></TableCell>
<TableCell className="text-right">{formatNumber(totals.subscriptions_count)}</TableCell>
<TableCell className="text-right">{formatNumber(totals.views_count)}</TableCell>
<TableCell className="text-right">{formatCurrency(totals.cpf)}</TableCell>
<TableCell className="text-right">{formatCurrency(totals.cpm)}</TableCell>
<TableCell></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}

View File

@@ -13,7 +13,6 @@ import {
ExternalLink, ExternalLink,
Copy, Copy,
Check, Check,
Archive,
Eye, Eye,
Users, Users,
TrendingUp, TrendingUp,
@@ -30,17 +29,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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"; import type { Placement } from "@/lib/types/api";
// ============================================================================ // ============================================================================
@@ -114,15 +102,6 @@ export default function PlacementDetailPage() {
} }
}; };
const handleArchive = async () => {
try {
await placementsApi.delete(workspaceId, placementId);
router.push(`/dashboard/${workspaceId}/placements`);
} catch (err) {
console.error("Failed to archive:", err);
}
};
const handleCopyLink = () => { const handleCopyLink = () => {
if (placement?.invite_link) { if (placement?.invite_link) {
navigator.clipboard.writeText(placement.invite_link); navigator.clipboard.writeText(placement.invite_link);
@@ -159,14 +138,11 @@ export default function PlacementDetailPage() {
} }
// Calculate metrics // Calculate metrics
const cps = const cost = placement.details?.cost?.value;
placement.cost && placement.subscriptions_count > 0 const subscriptionsCount = placement.placement_post?.subscriptions_count ?? 0;
? placement.cost / placement.subscriptions_count const viewsCount = placement.placement_post?.views_count ?? 0;
: null; const cps = cost && subscriptionsCount > 0 ? cost / subscriptionsCount : null;
const cpm = const cpm = cost && viewsCount > 0 ? (cost / viewsCount) * 1000 : null;
placement.cost && placement.views_count
? (placement.cost / placement.views_count) * 1000
: null;
return ( return (
<> <>
@@ -174,7 +150,7 @@ export default function PlacementDetailPage() {
breadcrumbs={[ breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` }, { label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` }, { label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
{ label: placement.placement_channel_title }, { label: placement.channel.title },
]} ]}
/> />
@@ -192,46 +168,21 @@ export default function PlacementDetailPage() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
{placement.placement_channel_title} {placement.channel.title}
</h1> </h1>
<Badge <Badge
variant={ variant={
placement.status === "active" ? "default" : "secondary" placement.status === "Оплачено" ? "default" : "secondary"
} }
> >
{placement.status === "active" ? "Активно" : "Архив"} {placement.status === "Оплачено" ? "Активно" : placement.status}
</Badge> </Badge>
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{formatDate(placement.placement_date)} {placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}
</p> </p>
</div> </div>
</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> </div>
{/* Stats Cards */} {/* Stats Cards */}
@@ -241,11 +192,11 @@ export default function PlacementDetailPage() {
<CardTitle className="text-sm font-medium">Стоимость</CardTitle> <CardTitle className="text-sm font-medium">Стоимость</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatCurrency(placement.cost)} {formatCurrency(cost)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -253,14 +204,14 @@ export default function PlacementDetailPage() {
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle> <CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatNumber(placement.subscriptions_count)} {formatNumber(subscriptionsCount)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
CPS: {cps ? formatCurrency(cps) : "—"} CPS: {cps ? formatCurrency(cps) : "—"}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -268,14 +219,14 @@ export default function PlacementDetailPage() {
<CardTitle className="text-sm font-medium">Просмотров</CardTitle> <CardTitle className="text-sm font-medium">Просмотров</CardTitle>
<Eye className="h-4 w-4 text-muted-foreground" /> <Eye className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{formatNumber(placement.views_count)} {formatNumber(viewsCount)}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
CPM: {cpm ? formatCurrency(cpm) : "—"} CPM: {cpm ? formatCurrency(cpm) : "—"}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -308,24 +259,31 @@ export default function PlacementDetailPage() {
<CardContent> <CardContent>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate"> <code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
{placement.invite_link} {placement.invite_link || "—"}
</code> </code>
<Button variant="outline" size="icon" onClick={handleCopyLink}> <Button
variant="outline"
size="icon"
onClick={handleCopyLink}
disabled={!placement.invite_link}
>
{copiedLink ? ( {copiedLink ? (
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
) : ( ) : (
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
)} )}
</Button> </Button>
<Button variant="outline" size="icon" asChild> {placement.invite_link && (
<a <Button variant="outline" size="icon" asChild>
href={placement.invite_link} <a
target="_blank" href={placement.invite_link}
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
<ExternalLink className="h-4 w-4" /> >
</a> <ExternalLink className="h-4 w-4" />
</Button> </a>
</Button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -334,16 +292,18 @@ export default function PlacementDetailPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Креатив</CardTitle> <CardTitle>Креатив</CardTitle>
<CardDescription>{placement.creative_name}</CardDescription> <CardDescription>ID: {placement.creative_id}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button variant="outline" asChild> {placement.creative_id && (
<Link <Button variant="outline" asChild>
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`} <Link
> href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
Просмотреть креатив >
</Link> Просмотреть креатив
</Button> </Link>
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -357,57 +317,45 @@ export default function PlacementDetailPage() {
<dl className="grid gap-4 sm:grid-cols-2"> <dl className="grid gap-4 sm:grid-cols-2">
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
Проект Канал размещения
</dt> </dt>
<dd className="text-sm">{placement.project_channel_title}</dd> <dd className="text-sm">{placement.channel.title}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
Дата размещения Дата размещения
</dt> </dt>
<dd className="text-sm">{formatDate(placement.placement_date)}</dd> <dd className="text-sm">{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
Статус просмотров Дата оплаты
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">{placement.details?.payment_at ? formatDate(placement.details.payment_at) : "—"}</dd>
<Badge variant="outline">
{placement.views_availability === "available"
? "Автообновление"
: placement.views_availability === "manual"
? "Ручной ввод"
: placement.views_availability === "unavailable"
? "Недоступно"
: "Не проверено"}
</Badge>
</dd>
</div> </div>
<div> <div>
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
Последнее обновление просмотров Формат
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">{placement.details?.format || "—"}</dd>
{formatDateTime(placement.last_views_fetch_at)}
</dd>
</div> </div>
{placement.ad_post_url && ( {placement.placement_post?.post?.url && (
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<dt className="text-sm font-medium text-muted-foreground"> <dt className="text-sm font-medium text-muted-foreground">
Ссылка на пост Ссылка на пост
</dt> </dt>
<dd className="text-sm"> <dd className="text-sm">
<a <a
href={placement.ad_post_url} href={placement.placement_post.post.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
> >
{placement.ad_post_url} {placement.placement_post.post.url}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </a>
</dd> </dd>

View File

@@ -157,17 +157,17 @@ const exportToCSV = (placements: Placement[], filename: string) => {
]; ];
const rows = placements.map((p) => [ const rows = placements.map((p) => [
p.placement_channel_title, p.channel.title,
"", "",
p.creative_name, "—", // creative_name not available in new structure
new Date(p.placement_date).toISOString().split("T")[0], p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "",
p.cost?.toString() || "", p.details?.cost?.value?.toString() || "",
p.subscriptions_count.toString(), p.placement_post?.subscriptions_count.toString() || "0",
p.views_count?.toString() || "", p.placement_post?.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "", calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "", calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "",
p.status, p.status,
p.ad_post_url || "", p.placement_post?.post?.url || "",
p.invite_link, p.invite_link,
]); ]);
@@ -192,15 +192,15 @@ const exportToExcel = (placements: Placement[], filename: string) => {
]; ];
const rows = placements.map((p) => [ const rows = placements.map((p) => [
p.placement_channel_title, p.channel.title,
"", "",
p.creative_name, "—",
new Date(p.placement_date).toISOString().split("T")[0], p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "",
p.cost?.toString() || "", p.details?.cost?.value?.toString() || "",
p.subscriptions_count.toString(), p.placement_post?.subscriptions_count.toString() || "0",
p.views_count?.toString() || "", p.placement_post?.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "", calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "", calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "",
p.status, p.status,
]); ]);
@@ -213,18 +213,21 @@ const exportToExcel = (placements: Placement[], filename: string) => {
const exportToTxt = (placements: Placement[], filename: string) => { const exportToTxt = (placements: Placement[], filename: string) => {
const lines = placements.map((p) => { const lines = placements.map((p) => {
const cps = calculateCPS(p.cost, p.subscriptions_count); const cost = p.details?.cost?.value ?? null;
const cpm = calculateCPM(p.cost, p.views_count); const subscriptions = p.placement_post?.subscriptions_count ?? 0;
const views = p.placement_post?.views_count ?? null;
const cps = calculateCPS(cost, subscriptions);
const cpm = calculateCPM(cost, views);
return [ return [
`Канал: ${p.placement_channel_title}`, `Канал: ${p.channel.title}`,
`Креатив: ${p.creative_name}`, `Креатив: `,
`Дата: ${formatDate(p.placement_date)}`, `Дата: ${p.details?.placement_at ? formatDate(p.details.placement_at) : "—"}`,
`Стоимость: ${formatCurrency(p.cost)}`, `Стоимость: ${formatCurrency(cost)}`,
`Подписки: ${p.subscriptions_count}`, `Подписки: ${p.placement_post?.subscriptions_count ?? "—"}`,
`Просмотры: ${p.views_count || "—"}`, `Просмотры: ${p.placement_post?.views_count || "—"}`,
`CPS: ${cps ? formatCurrency(cps) : "—"}`, `CPS: ${cps ? formatCurrency(cps) : "—"}`,
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`, `CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
`Ссылка: ${p.invite_link}`, `Ссылка: ${p.invite_link || "—"}`,
"---", "---",
].join("\n"); ].join("\n");
}); });
@@ -320,8 +323,8 @@ export default function PlacementsPage() {
return placements return placements
.filter( .filter(
(p) => (p) =>
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) || p.channel.title.toLowerCase().includes(search.toLowerCase()) ||
p.creative_name.toLowerCase().includes(search.toLowerCase()) (p.creative_id && p.creative_id.toString().toLowerCase().includes(search.toLowerCase()))
) )
.sort((a, b) => { .sort((a, b) => {
if (!sortField || !sortOrder) return 0; if (!sortField || !sortOrder) return 0;
@@ -329,30 +332,39 @@ export default function PlacementsPage() {
let aVal: number | string = 0; let aVal: number | string = 0;
let bVal: number | string = 0; let bVal: number | string = 0;
const aCost = a.details?.cost?.value ?? null;
const bCost = b.details?.cost?.value ?? null;
const aSubs = a.placement_post?.subscriptions_count ?? 0;
const bSubs = b.placement_post?.subscriptions_count ?? 0;
const aViews = a.placement_post?.views_count ?? null;
const bViews = b.placement_post?.views_count ?? null;
switch (sortField) { switch (sortField) {
case "date": case "date":
const aDate = a.details?.placement_at || "";
const bDate = b.details?.placement_at || "";
return sortOrder === "asc" return sortOrder === "asc"
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime() ? aDate.localeCompare(bDate)
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime(); : bDate.localeCompare(aDate);
case "cost": case "cost":
aVal = a.cost ?? 0; aVal = aCost ?? 0;
bVal = b.cost ?? 0; bVal = bCost ?? 0;
break; break;
case "subscriptions": case "subscriptions":
aVal = a.subscriptions_count; aVal = aSubs;
bVal = b.subscriptions_count; bVal = bSubs;
break; break;
case "views": case "views":
aVal = a.views_count ?? 0; aVal = aViews ?? 0;
bVal = b.views_count ?? 0; bVal = bViews ?? 0;
break; break;
case "cps": case "cps":
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0; aVal = calculateCPS(aCost, aSubs) ?? 0;
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0; bVal = calculateCPS(bCost, bSubs) ?? 0;
break; break;
case "cpm": case "cpm":
aVal = calculateCPM(a.cost, a.views_count) ?? 0; aVal = calculateCPM(aCost, aViews) ?? 0;
bVal = calculateCPM(b.cost, b.views_count) ?? 0; bVal = calculateCPM(bCost, bViews) ?? 0;
break; break;
case "status": case "status":
aVal = a.status; aVal = a.status;
@@ -370,14 +382,14 @@ export default function PlacementsPage() {
// Calculate totals with aggregation functions // Calculate totals with aggregation functions
const totals = useMemo(() => { const totals = useMemo(() => {
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null); const costs = filteredPlacements.map((p) => p.details?.cost?.value).filter((c): c is number => c !== null);
const subs = filteredPlacements.map((p) => p.subscriptions_count); const subs = filteredPlacements.map((p) => p.placement_post?.subscriptions_count ?? 0);
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null); const views = filteredPlacements.map((p) => p.placement_post?.views_count).filter((v): v is number => v !== null);
const cpsValues = filteredPlacements const cpsValues = filteredPlacements
.map((p) => calculateCPS(p.cost, p.subscriptions_count)) .map((p) => calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0))
.filter((c): c is number => c !== null); .filter((c): c is number => c !== null);
const cpmValues = filteredPlacements const cpmValues = filteredPlacements
.map((p) => calculateCPM(p.cost, p.views_count)) .map((p) => calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null))
.filter((c): c is number => c !== null); .filter((c): c is number => c !== null);
return { return {
@@ -639,28 +651,31 @@ export default function PlacementsPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredPlacements.map((placement) => { {filteredPlacements.map((placement) => {
const cps = calculateCPS(placement.cost, placement.subscriptions_count); const cost = placement.details?.cost?.value ?? null;
const cpm = calculateCPM(placement.cost, placement.views_count); const subscriptions = placement.placement_post?.subscriptions_count ?? 0;
const views = placement.placement_post?.views_count ?? null;
const cps = calculateCPS(cost, subscriptions);
const cpm = calculateCPM(cost, views);
return ( return (
<TableRow key={placement.id}> <TableRow key={placement.id}>
<TableCell> <TableCell>
<div className="font-medium">{placement.placement_channel_title}</div> <div className="font-medium">{placement.channel.title}</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm">{placement.creative_name}</div> <div className="text-sm"></div>
</TableCell> </TableCell>
<TableCell>{formatDate(placement.placement_date)}</TableCell> <TableCell>{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}</TableCell>
<TableCell>{formatCurrency(placement.cost)}</TableCell> <TableCell>{formatCurrency(cost)}</TableCell>
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell> <TableCell>{formatNumber(subscriptions)}</TableCell>
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell> <TableCell>{formatNumber(views)}</TableCell>
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell> <TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell> <TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={placement.status === "active" ? "default" : "secondary"} variant={placement.status === "Оплачено" ? "default" : "secondary"}
> >
{placement.status === "active" ? "Активен" : "Архив"} {placement.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -677,10 +692,10 @@ export default function PlacementsPage() {
Подробнее Подробнее
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{placement.ad_post_url && ( {placement.placement_post?.post?.url && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<a <a
href={placement.ad_post_url} href={placement.placement_post.post.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >

View File

@@ -16,13 +16,18 @@ import {
TrendingUp, TrendingUp,
Users, Users,
Eye, Eye,
ShoppingCart,
Info, Info,
Bot, Bot,
Settings, Settings,
Folder, FolderKanban,
Archive, Archive,
RotateCcw,
Trash2, Trash2,
Zap,
Target,
CircleDollarSign,
CircleQuestionMark,
ArrowRightFromLine,
} from "lucide-react"; } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
@@ -80,6 +85,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { BOT_USERNAME } from "@/lib/utils/constants"; import { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api"; import type { Project, SpendingAnalytics } from "@/lib/types/api";
@@ -93,6 +99,8 @@ interface ProjectWithStats extends Project {
total_subscriptions: number; total_subscriptions: number;
total_views: number; total_views: number;
avg_cpf: number | null; avg_cpf: number | null;
avg_cpm: number | null;
placements_count: number;
}; };
} }
@@ -115,6 +123,19 @@ const formatCurrency = (value: number | null | undefined): string => {
}).format(value); }).format(value);
}; };
const formatStatus = (status: string): string => {
switch (status) {
case "active":
return "Активен";
case "inactive":
return "Неактивен";
case "archived":
return "В архиве";
default:
return status;
}
};
// ============================================================================ // ============================================================================
// Component // Component
// ============================================================================ // ============================================================================
@@ -122,7 +143,7 @@ const formatCurrency = (value: number | null | undefined): string => {
export default function ProjectsPage() { export default function ProjectsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace(); const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]); const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false); const [loadingStats, setLoadingStats] = useState(false);
@@ -131,9 +152,18 @@ export default function ProjectsPage() {
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public"); const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false); const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false); const [isArchiving, setIsArchiving] = useState(false);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState(""); const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [isMoving, setIsMoving] = useState(false);
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
const { isDemoMode } = useWorkspace(); const { isDemoMode } = useWorkspace();
const handleOpenSettings = (project: Project) => { const handleOpenSettings = (project: Project) => {
@@ -175,6 +205,22 @@ export default function ProjectsPage() {
} }
}; };
const handleUnarchive = async () => {
if (!settingsProject) return;
try {
setIsUnarchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to unarchive project:", err);
} finally {
setIsUnarchiving(false);
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!settingsProject) return; if (!settingsProject) return;
@@ -202,8 +248,67 @@ export default function ProjectsPage() {
setDeleteConfirmationText(""); setDeleteConfirmationText("");
}; };
const handleOpenMoveDialog = async () => {
setShowMoveDialog(true);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
// Check permissions for all other workspaces
setIsCheckingTargetPermissions(true);
const adminWorkspaces: string[] = [];
for (const ws of workspaces) {
if (ws.id === workspaceId) continue;
const isAdmin = await isWorkspaceAdmin(ws.id);
if (isAdmin) {
adminWorkspaces.push(ws.id);
}
}
// Set first admin workspace as default if available
if (adminWorkspaces.length > 0) {
setTargetWorkspaceId(adminWorkspaces[0]);
setTargetWorkspaceAdmin(true);
}
setIsCheckingTargetPermissions(false);
};
const handleCloseMoveDialog = () => {
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
};
const handleTargetWorkspaceChange = async (newWorkspaceId: string) => {
setTargetWorkspaceId(newWorkspaceId);
setTargetWorkspaceAdmin(false);
// Check if user is admin in target workspace
const isAdmin = await isWorkspaceAdmin(newWorkspaceId);
setTargetWorkspaceAdmin(isAdmin);
};
const handleMoveProject = async () => {
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
try {
setIsMoving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await refreshProjects();
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
} catch (err) {
console.error("Failed to move project:", err);
} finally {
setIsMoving(false);
}
};
const getChannelAvatar = (project: Project) => { const getChannelAvatar = (project: Project) => {
// Telegram channel avatar URL format
if (project.username) { if (project.username) {
return `https://t.me/i/userpic/320/${project.username}.jpg`; return `https://t.me/i/userpic/320/${project.username}.jpg`;
} }
@@ -235,6 +340,11 @@ export default function ProjectsPage() {
spending.total_subscriptions > 0 spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions ? spending.total_cost / spending.total_subscriptions
: null, : null,
avg_cpm:
spending.total_views > 0
? (spending.total_cost / spending.total_views) * 1000
: null,
placements_count: spending.placements_count || 0,
}, },
}; };
} catch { } catch {
@@ -275,27 +385,37 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2 border rounded-lg p-1"> <div className="flex items-center gap-2 border rounded-lg p-1">
<Button <Tooltip>
variant={viewMode === "grid" ? "secondary" : "ghost"} <TooltipTrigger asChild>
size="sm" <Button
onClick={() => setViewMode("grid")} variant={viewMode === "grid" ? "secondary" : "ghost"}
aria-label="Плиточный вид" size="sm"
> onClick={() => setViewMode("grid")}
<Grid className="h-4 w-4" /> aria-label="Плиточный вид"
</Button> >
<Button <Grid className="h-4 w-4" />
variant={viewMode === "table" ? "secondary" : "ghost"} </Button>
size="sm" </TooltipTrigger>
onClick={() => setViewMode("table")} <TooltipContent>Плиточный вид</TooltipContent>
aria-label="Табличный вид" </Tooltip>
> <Tooltip>
<List className="h-4 w-4" /> <TooltipTrigger asChild>
</Button> <Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Табличный вид</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
<Alert> <Alert>
<Tv className="h-4 w-4" /> <CircleQuestionMark className="h-4 w-4 mt-1.5" />
<AlertDescription className="flex items-center justify-between flex-wrap gap-3"> <AlertDescription className="flex items-center justify-between flex-wrap gap-3">
<span> <span>
Чтобы добавить новый проект, добавьте бота{" "} Чтобы добавить новый проект, добавьте бота{" "}
@@ -334,7 +454,7 @@ export default function ProjectsPage() {
) : projectsWithStats.length === 0 ? ( ) : projectsWithStats.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Tv className="h-12 w-12 text-muted-foreground mb-4" /> <FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3> <h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center max-w-md"> <p className="text-muted-foreground text-center max-w-md">
Добавьте бота администратором в ваш Telegram канал, и он Добавьте бота администратором в ваш Telegram канал, и он
@@ -343,7 +463,6 @@ export default function ProjectsPage() {
</CardContent> </CardContent>
</Card> </Card>
) : viewMode === "table" ? ( ) : viewMode === "table" ? (
// Table View
<Card> <Card>
<Table> <Table>
<TableHeader> <TableHeader>
@@ -365,7 +484,7 @@ export default function ProjectsPage() {
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Привлечено подписчиков Привлечено
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
@@ -378,7 +497,7 @@ export default function ProjectsPage() {
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Суммарный охват Охват
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
@@ -389,19 +508,7 @@ export default function ProjectsPage() {
</Tooltip> </Tooltip>
</div> </div>
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">CPF</TableHead>
<div className="flex items-center justify-end gap-1">
CPF
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Follower средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="w-[200px]"></TableHead> <TableHead className="w-[200px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -413,7 +520,7 @@ export default function ProjectsPage() {
<Avatar className="h-9 w-9"> <Avatar className="h-9 w-9">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} /> <AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10"> <AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-4 w-4 text-primary" /> <FolderKanban className="h-4 w-4 text-primary" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div>
@@ -435,25 +542,38 @@ export default function ProjectsPage() {
<TableCell> <TableCell>
<Badge <Badge
variant={project.status === "active" ? "default" : "secondary"} variant={project.status === "active" ? "default" : "secondary"}
className={cn(
project.status === "archived" && "bg-muted text-muted-foreground"
)}
> >
{project.status === "active" {formatStatus(project.status)}
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right font-medium">
{formatNumber(project.subscribers_count)} {formatNumber(project.subscribers_count)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatNumber(project.stats?.total_subscriptions)} <div className="flex items-center justify-end gap-1">
<span className="font-medium">
{formatNumber(project.stats?.total_subscriptions)}
</span>
{project.stats?.placements_count ? (
<span className="text-xs text-muted-foreground">
({project.stats.placements_count} размещ.)
</span>
) : null}
</div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatNumber(project.stats?.total_views)} {formatNumber(project.stats?.total_views)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatCurrency(project.stats?.avg_cpf)} <div className="flex items-center justify-end gap-1">
<CircleDollarSign className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">
{formatCurrency(project.stats?.avg_cpf)}
</span>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
@@ -461,14 +581,15 @@ export default function ProjectsPage() {
<Link <Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`} href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
> >
План закупов <Zap className="h-3 w-3 mr-1" />
Размещения
</Link> </Link>
</Button> </Button>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link <Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`} href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
> >
<Folder className="h-3 w-3 mr-1" /> <Target className="h-3 w-3 mr-1" />
Креативы Креативы
</Link> </Link>
</Button> </Button>
@@ -477,8 +598,7 @@ export default function ProjectsPage() {
size="sm" size="sm"
onClick={() => handleOpenSettings(project)} onClick={() => handleOpenSettings(project)}
> >
<Settings className="h-3 w-3 mr-1" /> <Settings className="h-3 w-3" />
Настройки
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -488,31 +608,30 @@ export default function ProjectsPage() {
</Table> </Table>
</Card> </Card>
) : ( ) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => ( {projectsWithStats.map((project) => (
<Card key={project.id}> <Card key={project.id} className="overflow-hidden">
<CardHeader> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10 shrink-0">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} /> <AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10"> <AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" /> <FolderKanban className="h-5 w-5 text-primary" />
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div> <div className="min-w-0">
<CardTitle className="text-base">{project.title}</CardTitle> <CardTitle className="text-base truncate">{project.title}</CardTitle>
{project.username && ( {project.username && (
<CardDescription> <CardDescription>
<a <a
href={`https://t.me/${project.username}`} href={`https://t.me/${project.username}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-0.5" className="hover:underline inline-flex items-center gap-0.5 truncate"
> >
@{project.username} @{project.username}
<ExternalLink className="h-2.5 w-2.5" /> <ExternalLink className="h-2.5 w-2.5 shrink-0" />
</a> </a>
</CardDescription> </CardDescription>
)} )}
@@ -520,85 +639,88 @@ export default function ProjectsPage() {
</div> </div>
<Badge <Badge
variant={project.status === "active" ? "default" : "secondary"} variant={project.status === "active" ? "default" : "secondary"}
className={cn(
"shrink-0",
project.status === "archived" && "bg-muted text-muted-foreground"
)}
> >
{project.status === "active" {formatStatus(project.status)}
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Stats Grid */} <div className="flex items-center justify-between text-sm">
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="flex items-center gap-2 text-muted-foreground">
{project.subscribers_count !== undefined && ( <Users className="h-4 w-4" />
<div className="flex items-center gap-2"> <span>Подписчиков</span>
<Users className="h-4 w-4 text-muted-foreground" /> </div>
<div> <span className="font-semibold">
<div className="font-medium"> {formatNumber(project.subscribers_count)}
{formatNumber(project.subscribers_count)} </span>
</div>
{project.stats && (
<>
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Target className="h-4 w-4 text-primary" />
</div> </div>
<div className="text-xs text-muted-foreground"> <div>
Подписчиков <div className="text-xs text-muted-foreground">Привлечено</div>
<div className="font-semibold text-sm">
{formatNumber(project.stats.total_subscriptions)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Eye className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Охват</div>
<div className="font-semibold text-sm">
{formatNumber(project.stats.total_views)}
</div>
</div> </div>
</div> </div>
</div> </div>
)}
{project.stats && ( <div className="flex items-center justify-between pt-2 border-t">
<>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<div> <CircleDollarSign className="h-4 w-4 text-primary" />
<div className="font-medium">
{formatNumber(project.stats.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Привлечено подписчиков
</div>
</div> </div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div> <div>
<div className="font-medium"> <div className="text-xs text-muted-foreground">CPF</div>
{formatNumber(project.stats.total_views)} <div className="font-semibold">
</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_cpf)} {formatCurrency(project.stats.avg_cpf)}
</div> </div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div> </div>
</div> </div>
</> {project.stats.placements_count > 0 && (
)} <Badge variant="outline" className="text-xs">
</div> {project.stats.placements_count} размещ.
</Badge>
)}
</div>
</>
)}
{/* Actions */} <div className="flex gap-2 pt-2 border-t">
<div className="flex gap-2 flex-wrap"> <Button variant="outline" size="sm" className="flex-1" asChild>
<Button variant="outline" size="sm" asChild>
<Link <Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`} href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
> >
План закупов <Zap className="h-3.5 w-3.5 mr-1.5" />
Размещения
</Link> </Link>
</Button> </Button>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link <Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`} href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
> >
<Folder className="h-4 w-4 mr-1" /> <Target className="h-3.5 w-3.5" />
Креативы
</Link> </Link>
</Button> </Button>
<Button <Button
@@ -606,8 +728,7 @@ export default function ProjectsPage() {
size="sm" size="sm"
onClick={() => handleOpenSettings(project)} onClick={() => handleOpenSettings(project)}
> >
<Settings className="h-4 w-4 mr-1" /> <Settings className="h-3.5 w-3.5" />
Настройки
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -616,7 +737,6 @@ export default function ProjectsPage() {
</div> </div>
)} )}
{/* Settings Dialog */}
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}> <Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
@@ -641,45 +761,71 @@ export default function ProjectsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта Тип пригласительной ссылки по умолчанию при создании размещений
</p> </p>
</div> </div>
{/* Danger Zone */}
<div className="space-y-3 pt-4 border-t"> <div className="space-y-3 pt-4 border-t">
<div className="space-y-2"> <h4 className="text-sm font-semibold">Действия</h4>
<h4 className="text-sm font-semibold text-destructive">Опасная зона</h4> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> {settingsProject?.status === "archived" ? (
{settingsProject?.status !== "archived" && (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button>
)}
<Button <Button
variant="destructive" variant="outline"
onClick={handleOpenDeleteDialog} onClick={handleUnarchive}
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isUnarchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Восстановление...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</>
)}
</Button>
) : (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings} disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start" className="w-full justify-start"
> >
<Trash2 className="h-4 w-4 mr-2" /> {isArchiving ? (
Удалить проект <>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button> </Button>
</div> )}
<Button
variant="outline"
onClick={handleOpenMoveDialog}
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс
</Button>
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -688,11 +834,11 @@ export default function ProjectsPage() {
variant="outline" variant="outline"
onClick={() => setSettingsProject(null)} onClick={() => setSettingsProject(null)}
> >
Отмена Закрыть
</Button> </Button>
<Button <Button
onClick={handleSaveSettings} onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting} disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
> >
{isUpdatingSettings ? ( {isUpdatingSettings ? (
<> <>
@@ -707,7 +853,6 @@ export default function ProjectsPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}> <AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@@ -747,6 +892,89 @@ export default function ProjectsPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Move Project Dialog */}
<Dialog open={showMoveDialog} onOpenChange={handleCloseMoveDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Переместить проект</DialogTitle>
<DialogDescription>
{settingsProject?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isCheckingTargetPermissions ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="target-workspace">Воркспейс назначения</Label>
<Select
value={targetWorkspaceId}
onValueChange={handleTargetWorkspaceChange}
>
<SelectTrigger id="target-workspace">
<SelectValue placeholder="Выберите воркспейс" />
</SelectTrigger>
<SelectContent>
{workspaces
.filter((ws) => ws.id !== workspaceId)
.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!targetWorkspaceId && (
<p className="text-xs text-muted-foreground">
Выберите воркспейс для перемещения проекта
</p>
)}
</div>
{targetWorkspaceId && !targetWorkspaceAdmin && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
У вас нет прав администратора в этом воркспейсе. Для перемещения проекта вы должны быть администратором в обоих воркспейсах.
</div>
)}
{targetWorkspaceId && workspaces.find(w => w.id === targetWorkspaceId)?.avatar_url && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Проект будет перемещён в воркспейс «{workspaces.find(w => w.id === targetWorkspaceId)?.name}»</span>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCloseMoveDialog}
>
Отмена
</Button>
<Button
onClick={handleMoveProject}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
>
{isMoving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Перемещение...
</>
) : (
<>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,28 @@
"use client"; "use client";
// ============================================================================ // ============================================================================
// Purchase Plans Overview Page // Placements Overview Page - Projects with Placement Statistics
// ============================================================================ // ============================================================================
import { useEffect, useState } from "react"; import { useEffect, useState, useMemo } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
Loader2, Loader2,
ClipboardList, FolderKanban,
ChevronRight, ChevronRight,
Tv, Users,
Calendar, Eye,
DollarSign, DollarSign,
CheckCircle, Target,
Clock, TrendingUp,
XCircle, CircleDollarSign,
} from "lucide-react"; } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePurchasePlanApi } from "@/lib/demo"; import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi } from "@/lib/api";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -30,14 +32,20 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import type { Project, PurchasePlanChannel } from "@/lib/types/api"; import { cn } from "@/lib/utils";
import type { Project, ProjectPlacementsSummary, Placement } from "@/lib/types/api";
// ============================================================================ // ============================================================================
// Helpers // Helpers
// ============================================================================ // ============================================================================
const formatCurrency = (value: number | null) => { const formatNumber = (value: number | null | undefined): string => {
if (value === null) return "—"; 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", { return new Intl.NumberFormat("ru-RU", {
style: "currency", style: "currency",
currency: "RUB", currency: "RUB",
@@ -46,13 +54,12 @@ const formatCurrency = (value: number | null) => {
}).format(value); }).format(value);
}; };
interface ProjectPlanStats { const formatCompact = (value: number | null | undefined): string => {
totalChannels: number; if (value === null || value === undefined) return "—";
plannedChannels: number; if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
completedChannels: number; if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
cancelledChannels: number; return value.toString();
totalPlannedCost: number; };
}
// ============================================================================ // ============================================================================
// Component // Component
@@ -62,53 +69,57 @@ export default function PurchasePlansPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects } = useWorkspace(); const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
const [projectStats, setProjectStats] = useState<Record<string, ProjectPlanStats>>({}); const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({});
const [loadingStats, setLoadingStats] = useState(true); const [loadingStats, setLoadingStats] = useState(true);
const { isDemoMode } = useWorkspace();
useEffect(() => { useEffect(() => {
if (projects.length > 0) { if (projects.length > 0) {
loadAllStats(); loadAllPlacements();
} else { } else {
setLoadingStats(false); setLoadingStats(false);
} }
}, [projects, workspaceId]); }, [projects, workspaceId]);
const loadAllStats = async () => { const loadAllPlacements = async () => {
setLoadingStats(true); setLoadingStats(true);
const stats: Record<string, ProjectPlanStats> = {}; const placementsMap: Record<string, Placement[]> = {};
for (const project of projects) { for (const project of projects) {
try { try {
const response = await demoAwarePurchasePlanApi.list(workspaceId, project.id, { size: 100 }); const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
const channels = response.items; const placements = await api.getByProject(workspaceId, project.id);
placementsMap[project.id] = placements;
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) { } catch (err) {
console.error(`Failed to load plan for project ${project.id}:`, err); console.error(`Failed to load placements for project ${project.id}:`, err);
stats[project.id] = { placementsMap[project.id] = [];
totalChannels: 0,
plannedChannels: 0,
completedChannels: 0,
cancelledChannels: 0,
totalPlannedCost: 0,
};
} }
} }
setProjectStats(stats); setPlacementsByProject(placementsMap);
setLoadingStats(false); setLoadingStats(false);
}; };
const calculateProjectSummary = (placements: Placement[]): ProjectPlacementsSummary => {
const total_cost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const total_subscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const total_views = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
const channelsSet = new Set(placements.map(p => p.channel.id));
return {
total_placements: placements.length,
total_channels: channelsSet.size,
total_cost,
total_subscriptions,
total_views,
avg_cpf: total_subscriptions > 0 ? total_cost / total_subscriptions : null,
avg_cpm: total_views > 0 ? (total_cost / total_views) * 1000 : null,
};
};
const isLoading = isLoadingProjects || loadingStats; const isLoading = isLoadingProjects || loadingStats;
return ( return (
@@ -116,16 +127,16 @@ export default function PurchasePlansPage() {
<DashboardHeader <DashboardHeader
breadcrumbs={[ breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` }, { label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Планы закупов" }, { label: "Размещения" },
]} ]}
/> />
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4"> <div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Планы закупов</h1> <h1 className="text-2xl font-bold tracking-tight">Размещения</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Управление планами закупов по проектам Статистика по всем размещениям ваших проектов
</p> </p>
</div> </div>
</div> </div>
@@ -137,10 +148,10 @@ export default function PurchasePlansPage() {
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" /> <FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3> <h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center mb-4"> <p className="text-muted-foreground text-center mb-4">
Добавьте проект через Telegram бота для создания плана закупов Добавьте проект через Telegram бота для отслеживания размещений
</p> </p>
<Button asChild> <Button asChild>
<Link href={`/dashboard/${workspaceId}/projects`}> <Link href={`/dashboard/${workspaceId}/projects`}>
@@ -152,11 +163,14 @@ export default function PurchasePlansPage() {
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => { {projects.map((project) => {
const stats = projectStats[project.id]; const placements = placementsByProject[project.id] || [];
const summary = calculateProjectSummary(placements);
const avatarUrl = project.username
? `https://t.me/i/userpic/320/${project.username}.jpg`
: null;
return ( return (
<Card <Card key={project.id}
key={project.id}
className="cursor-pointer hover:bg-muted/50 transition-colors" className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => onClick={() =>
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`) router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
@@ -165,9 +179,12 @@ export default function PurchasePlansPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"> <Avatar className="h-10 w-10">
<Tv className="h-5 w-5 text-primary" /> <AvatarImage src={avatarUrl || undefined} alt={project.title} />
</div> <AvatarFallback className="rounded-lg bg-primary/10">
<FolderKanban className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div> <div>
<CardTitle className="text-lg">{project.title}</CardTitle> <CardTitle className="text-lg">{project.title}</CardTitle>
{project.username && ( {project.username && (
@@ -179,46 +196,62 @@ export default function PurchasePlansPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{stats ? ( {summary.total_placements > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="grid grid-cols-2 gap-3">
<span className="text-muted-foreground">Всего каналов:</span> <div className="flex items-center gap-2">
<span className="font-medium">{stats.totalChannels}</span> <div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
</div> <Target className="h-4 w-4 text-primary" />
</div>
<div className="flex gap-2 flex-wrap"> <div>
{stats.plannedChannels > 0 && ( <div className="text-xs text-muted-foreground">Размещений</div>
<Badge variant="outline" className="gap-1"> <div className="font-semibold">{formatNumber(summary.total_placements)}</div>
<Clock className="h-3 w-3" /> </div>
{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="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Каналов</div>
<div className="font-semibold">{formatNumber(summary.total_channels)}</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Охват:</span>
<span className="font-medium">{formatCompact(summary.total_views)}</span>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Подписок:</span>
<span className="font-medium">{formatCompact(summary.total_subscriptions)}</span>
</div>
<div className="pt-2 border-t space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<CircleDollarSign className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Бюджет</span>
</div>
<span className="font-semibold">{formatCurrency(summary.total_cost)}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">CPF</span>
</div>
<span className="font-medium">{formatCurrency(summary.avg_cpf)}</span>
</div>
</div>
</div> </div>
) : ( ) : (
<div className="text-sm text-muted-foreground">Загрузка...</div> <div className="text-sm text-muted-foreground">
Нет размещений
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -230,4 +263,3 @@ export default function PurchasePlansPage() {
</> </>
); );
} }

View File

@@ -73,12 +73,12 @@ export default function InvitesPage() {
const handleCreateInvite = async () => { const handleCreateInvite = async () => {
if (!username.trim()) return; if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
try { try {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
// Remove @ if present // Remove @ if present
const cleanUsername = username.trim().replace(/^@/, "");
const invite = await invitesApi.create(workspaceId, { const invite = await invitesApi.create(workspaceId, {
username: cleanUsername, username: cleanUsername,
@@ -87,10 +87,13 @@ export default function InvitesPage() {
setInvites((prev) => [invite, ...prev]); setInvites((prev) => [invite, ...prev]);
setShowDialog(false); setShowDialog(false);
setUsername(""); setUsername("");
} catch (err: any) { } catch (err: any) {
setError(err?.error?.message || "Не удалось отправить приглашение"); const errorMessage = err?.error?.message || err?.detail;
} finally { if (errorMessage?.includes("not found")) {
setIsCreating(false); setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
} else {
setError(errorMessage || "Не удалось отправить приглашение");
}
} }
}; };

View File

@@ -4,9 +4,9 @@
// Workspace Settings Page // Workspace Settings Page
// ============================================================================ // ============================================================================
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock } from "lucide-react"; import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock, Upload, ImageIcon } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
import { workspacesApi, membersApi, invitesApi } from "@/lib/api"; import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
Card, Card,
CardContent, CardContent,
@@ -97,6 +98,12 @@ export default function SettingsPage() {
const [deleteConfirm, setDeleteConfirm] = useState(""); const [deleteConfirm, setDeleteConfirm] = useState("");
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Avatar upload
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [isDeletingAvatar, setIsDeletingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Members and invites // Members and invites
const [members, setMembers] = useState<WorkspaceMember[]>([]); const [members, setMembers] = useState<WorkspaceMember[]>([]);
const [invites, setInvites] = useState<WorkspaceInvite[]>([]); const [invites, setInvites] = useState<WorkspaceInvite[]>([]);
@@ -147,15 +154,46 @@ export default function SettingsPage() {
} }
}; };
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
setIsUploadingAvatar(true);
const updatedWorkspace = await workspacesApi.uploadAvatar(workspaceId, file);
await refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setAvatarFile(null);
} catch (err) {
console.error("Failed to upload avatar:", err);
} finally {
setIsUploadingAvatar(false);
}
};
const handleAvatarDelete = async () => {
try {
setIsDeletingAvatar(true);
await workspacesApi.deleteAvatar(workspaceId);
await refreshWorkspaces();
} catch (err) {
console.error("Failed to delete avatar:", err);
} finally {
setIsDeletingAvatar(false);
}
};
const handleCreateInvite = async () => { const handleCreateInvite = async () => {
if (!username.trim()) return; if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
try { try {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
const cleanUsername = username.trim().replace(/^@/, "");
const invite = await invitesApi.create(workspaceId, { const invite = await invitesApi.create(workspaceId, {
username: cleanUsername, username: cleanUsername,
}); });
@@ -163,10 +201,13 @@ export default function SettingsPage() {
setInvites((prev) => [invite, ...prev]); setInvites((prev) => [invite, ...prev]);
setShowInviteDialog(false); setShowInviteDialog(false);
setUsername(""); setUsername("");
} catch (err: any) { } catch (err: any) {
setError(err?.error?.message || "Не удалось отправить приглашение"); const errorMessage = err?.error?.message || err?.detail;
} finally { if (errorMessage?.includes("not found")) {
setIsCreating(false); setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
} else {
setError(errorMessage || "Не удалось отправить приглашение");
}
} }
}; };
@@ -268,10 +309,79 @@ export default function SettingsPage() {
<CardHeader> <CardHeader>
<CardTitle>Основные настройки</CardTitle> <CardTitle>Основные настройки</CardTitle>
<CardDescription> <CardDescription>
Название и основные параметры воркспейса Название и аватар воркспейса
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-6">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={currentWorkspace.avatar_url || undefined} alt={currentWorkspace.name} />
<AvatarFallback className="text-2xl">
{currentWorkspace.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingAvatar}
>
{isUploadingAvatar ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Загрузить
</Button>
{currentWorkspace.avatar_url && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isDeletingAvatar}
>
{isDeletingAvatar ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить аватар</AlertDialogTitle>
<AlertDialogDescription>
Вы уверены, что хотите удалить аватар воркспейса?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleAvatarDelete}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG или GIF. Максимум 2 МБ.
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
</div>
</div>
{/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="workspace-name">Название</Label> <Label htmlFor="workspace-name">Название</Label>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@@ -4,13 +4,14 @@
// Onboarding Page - First Workspace Creation // Onboarding Page - First Workspace Creation
// ============================================================================ // ============================================================================
import { useState } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Building2, Loader2, Megaphone } from "lucide-react"; import { Building2, Loader2, Megaphone, Upload, Trash2, X } from "lucide-react";
import { workspacesApi } from "@/lib/api"; import { workspacesApi } from "@/lib/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
Card, Card,
CardContent, CardContent,
@@ -24,8 +25,37 @@ import { STORAGE_KEYS } from "@/lib/utils/constants";
export default function OnboardingPage() { export default function OnboardingPage() {
const router = useRouter(); const router = useRouter();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setError("Размер файла не должен превышать 2 МБ");
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
setError(null);
};
const handleAvatarRemove = () => {
setAvatarFile(null);
setAvatarPreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleCreate = async () => { const handleCreate = async () => {
if (!name.trim()) return; if (!name.trim()) return;
@@ -34,8 +64,8 @@ export default function OnboardingPage() {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
const workspace = await workspacesApi.create({ name: name.trim() }); const workspace = await workspacesApi.create({ name: name.trim(), avatar_file: avatarFile });
// Save as selected workspace // Save as selected workspace
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id); localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
@@ -62,6 +92,49 @@ export default function OnboardingPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={avatarPreview || undefined} alt="Avatar preview" />
<AvatarFallback className="text-xl">
{name.charAt(0).toUpperCase() || "W"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isCreating}
>
<Upload className="h-4 w-4 mr-2" />
Загрузить
</Button>
{avatarPreview && (
<Button
variant="outline"
size="sm"
onClick={handleAvatarRemove}
disabled={isCreating}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG или GIF. Максимум 2 МБ.
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="workspace-name">Название воркспейса</Label> <Label htmlFor="workspace-name">Название воркспейса</Label>
<Input <Input

View File

@@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { Loader2, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { parseChannelInput } from "@/lib/utils/channel-parser";
import { channelsApi, placementsApi } from "@/lib/api";
import type { Placement } from "@/lib/types/api";
interface AddChannelDialogProps {
workspaceId: string;
projectId: string;
existingPlacements: Placement[];
onSuccess: () => void;
children?: React.ReactNode;
}
export function AddChannelDialog({
workspaceId,
projectId,
existingPlacements,
onSuccess,
children,
}: AddChannelDialogProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const existingUsernames = new Set(
existingPlacements
.map((p) => p.channel.username?.toLowerCase())
.filter(Boolean)
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(false);
const username = parseChannelInput(inputValue);
if (!username) {
setError("Не удалось распознать username из ссылки");
return;
}
if (existingUsernames.has(username.toLowerCase())) {
setError("Этот канал уже добавлен в проект");
return;
}
try {
setLoading(true);
// Step 1: Verify/add channel via channels API
await channelsApi.list({ username });
// Step 2: Create empty placement
await placementsApi.createBulk(workspaceId, projectId, {
channels: [
{
username,
status: "Без статуса",
},
],
});
setSuccess(true);
setInputValue("");
// Close dialog after short delay
setTimeout(() => {
setOpen(false);
setSuccess(false);
onSuccess();
}, 1500);
} catch (err: any) {
console.error("Failed to add channel:", err);
setError(err.detail || "Не удалось добавить канал");
} finally {
setLoading(false);
}
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setInputValue("");
setError(null);
setSuccess(false);
}
setOpen(isOpen);
};
const examples = [
"t.me/rian_ru",
"@rian_ru",
"telemetr.me/content/rian_ru",
"tgstat.ru/channel/@rian_ru",
];
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Добавить канал</DialogTitle>
<DialogDescription>
Введите ссылку или username канала для проверки и добавления в проект
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="channel-input">Ссылка или username канала</Label>
<Input
id="channel-input"
placeholder="https://t.me/rian_ru или @rian_ru"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={loading}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Поддерживаются: t.me, telemetr.me, tgstat.ru, а также username с @
</p>
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 text-sm">
<CheckCircle2 className="h-4 w-4 shrink-0" />
Канал успешно добавлен!
</div>
)}
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Примеры:</p>
<div className="flex flex-wrap gap-2">
{examples.map((example) => (
<button
key={example}
type="button"
onClick={() => setInputValue(example)}
className="text-xs px-2 py-1 rounded-md bg-muted hover:bg-muted/80 transition-colors"
>
{example}
</button>
))}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
>
Отмена
</Button>
<Button type="submit" disabled={loading || !inputValue.trim()}>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Проверка...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Проверить и добавить
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,454 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Loader2, Plus, Check, ChevronRight, X, AlertCircle } from "lucide-react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { parseChannelInput } from "@/lib/utils/channel-parser";
import { placementsApi, creativesApi } from "@/lib/api";
import type { Placement, Creative } from "@/lib/types/api";
interface ChannelInput {
username: string;
isNew: boolean;
}
interface CreatePlacementsDialogProps {
workspaceId: string;
projectId: string;
existingPlacements: Placement[];
onSuccess: () => void;
children?: React.ReactNode;
}
const DEFAULT_STATUS = "Согласование условий";
const DEFAULT_PLACEMENT_TYPE = "standard";
export function CreatePlacementsDialog({
workspaceId,
projectId,
existingPlacements,
onSuccess,
children,
}: CreatePlacementsDialogProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Step 1: Channels
const [newChannelInput, setNewChannelInput] = useState("");
const [selectedChannels, setSelectedChannels] = useState<ChannelInput[]>([]);
const [existingChannels, setExistingChannels] = useState<string[]>([]);
// Step 2: Details
const [creativeId, setCreativeId] = useState<string | null>(null);
const [format, setFormat] = useState("");
const [cost, setCost] = useState("");
const [placementDate, setPlacementDate] = useState("");
const [comment, setComment] = useState("");
// Step 3: Confirm
const [creatives, setCreatives] = useState<Creative[]>([]);
const [creating, setCreating] = useState(false);
const [createdCount, setCreatedCount] = useState<number | null>(null);
const existingUsernames = new Set(
existingPlacements
.map((p) => p.channel.username?.toLowerCase())
.filter(Boolean)
);
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
resetForm();
}
setOpen(isOpen);
};
const resetForm = () => {
setStep(1);
setNewChannelInput("");
setSelectedChannels([]);
setExistingChannels([]);
setCreativeId(null);
setFormat("");
setCost("");
setPlacementDate("");
setComment("");
setError(null);
setCreatedCount(null);
};
const loadCreatives = async () => {
try {
const response = await creativesApi.listByProject(workspaceId, projectId);
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives:", err);
}
};
const addChannel = () => {
const username = parseChannelInput(newChannelInput);
if (!username) {
setError("Не удалось распознать username");
return;
}
const lowerUsername = username.toLowerCase();
if (existingUsernames.has(lowerUsername)) {
setError("Этот канал уже добавлен в проект");
return;
}
if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) {
setError("Канал уже добавлен в список");
return;
}
setSelectedChannels([...selectedChannels, { username, isNew: true }]);
setNewChannelInput("");
setError(null);
};
const removeChannel = (username: string) => {
setSelectedChannels(selectedChannels.filter((c) => c.username !== username));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
addChannel();
}
};
const canProceedStep1 = selectedChannels.length > 0;
const handleCreate = async () => {
try {
setCreating(true);
setError(null);
const details: any = {};
details.placement_type = DEFAULT_PLACEMENT_TYPE;
if (format) details.format = format;
if (cost) details.cost = { type: "fixed", value: parseFloat(cost) };
if (placementDate) details.placement_at = new Date(placementDate).toISOString();
if (comment) details.comment = comment;
await placementsApi.createBulk(workspaceId, projectId, {
creative_id: creativeId || undefined,
channels: selectedChannels.map((c) => ({
username: c.username,
status: DEFAULT_STATUS,
})),
details: Object.keys(details).length > 0 ? details : undefined,
});
setCreatedCount(selectedChannels.length);
onSuccess();
} catch (err: any) {
console.error("Failed to create placements:", err);
setError(err.detail || "Не удалось создать размещения");
setCreating(false);
}
};
const handleTriggerClick = () => {
resetForm();
setOpen(true);
};
const selectedCreative = creatives.find((c) => c.id === creativeId);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild onClick={handleTriggerClick}>{children}</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Создать размещения</DialogTitle>
<DialogDescription>
Шаг {step} из 3:{" "}
{step === 1 && "Выберите каналы"}
{step === 2 && "Укажите параметры"}
{step === 3 && "Подтвердите создание"}
</DialogDescription>
</DialogHeader>
{createdCount !== null ? (
<div className="py-8 text-center">
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-emerald-100 text-emerald-600 mb-4">
<Check className="h-8 w-8" />
</div>
<h3 className="text-lg font-semibold mb-2">
Размещения созданы!
</h3>
<p className="text-muted-foreground">
Создано {createdCount} размещений
</p>
</div>
) : (
<>
{/* Progress Steps */}
<div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((s) => (
<div
key={s}
className={cn(
"flex items-center gap-2",
s < 3 && "mr-4"
)}
>
<div
className={cn(
"flex items-center justify-center h-8 w-8 rounded-full text-sm font-medium",
step === s
? "bg-primary text-primary-foreground"
: step > s
? "bg-emerald-500 text-white"
: "bg-muted text-muted-foreground"
)}
>
{step > s ? <Check className="h-4 w-4" /> : s}
</div>
</div>
))}
</div>
{/* Error */}
{error && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm mb-4">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</div>
)}
{/* Step 1: Channels */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Добавить каналы</Label>
<div className="flex gap-2">
<Input
placeholder="t.me/username или @username"
value={newChannelInput}
onChange={(e) => setNewChannelInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={loading}
/>
<Button type="button" onClick={addChannel} disabled={!newChannelInput.trim()}>
<Plus className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Поддерживаются: t.me, telemetr.me, tgstat.ru, username с @
</p>
</div>
{selectedChannels.length > 0 && (
<div className="space-y-2">
<Label>Выбранные каналы ({selectedChannels.length})</Label>
<div className="max-h-48 overflow-y-auto space-y-2">
{selectedChannels.map((channel) => (
<div
key={channel.username}
className="flex items-center justify-between p-2 rounded-md bg-muted"
>
<span className="text-sm">@{channel.username}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeChannel(channel.username)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Step 2: Details */}
{step === 2 && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Креатив</Label>
<Select value={creativeId || ""} onValueChange={setCreativeId}>
<SelectTrigger>
<SelectValue placeholder="Выберите креатив" />
</SelectTrigger>
<SelectContent>
{creatives.map((creative) => (
<SelectItem key={creative.id} value={creative.id}>
{creative.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Формат</Label>
<Input
placeholder="Стандарт"
value={format}
onChange={(e) => setFormat(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Стоимость ()</Label>
<Input
type="number"
placeholder="5000"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Дата размещения</Label>
<Input
type="date"
value={placementDate}
onChange={(e) => setPlacementDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Комментарий</Label>
<Textarea
placeholder="Дополнительные заметки..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
/>
</div>
</div>
)}
{/* Step 3: Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Каналы ({selectedChannels.length})</Label>
<div className="max-h-32 overflow-y-auto p-3 rounded-md bg-muted text-sm space-y-1">
{selectedChannels.map((c) => (
<div key={c.username}>@{c.username}</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Параметры</Label>
<div className="grid grid-cols-2 gap-2 text-sm p-3 rounded-md bg-muted">
{selectedCreative && (
<div>
<span className="text-muted-foreground">Креатив:</span>{" "}
{selectedCreative.name}
</div>
)}
<div>
<span className="text-muted-foreground">Тип:</span>{" "}
Стандартный
</div>
{format && (
<div>
<span className="text-muted-foreground">Формат:</span>{" "}
{format}
</div>
)}
{cost && (
<div>
<span className="text-muted-foreground">Стоимость:</span>{" "}
{parseFloat(cost).toLocaleString("ru-RU")}
</div>
)}
{placementDate && (
<div>
<span className="text-muted-foreground">Дата:</span>{" "}
{new Date(placementDate).toLocaleDateString("ru-RU")}
</div>
)}
{comment && (
<div className="col-span-2">
<span className="text-muted-foreground">Комментарий:</span>{" "}
{comment}
</div>
)}
</div>
</div>
</div>
)}
{/* Navigation */}
<DialogFooter>
{step > 1 && (
<Button
type="button"
variant="outline"
onClick={() => setStep(step - 1)}
>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
onClick={() => {
if (step === 1) {
loadCreatives();
}
setStep(step + 1);
}}
disabled={step === 1 && !canProceedStep1}
>
Далее
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button type="button" onClick={handleCreate} disabled={creating}>
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Создание...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Создать {selectedChannels.length} размещений
</>
)}
</Button>
)}
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,523 @@
"use client";
import { useState } from "react";
import { Loader2, Filter, ArrowUpDown, X, Check, ArrowUp, ArrowDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { Placement, PlacementStatus } from "@/lib/types/api";
type FilterCondition = "contains" | "not_contains" | "equals";
interface FilterField {
key: string;
label: string;
type: "text" | "number" | "date";
}
interface SortField {
key: string;
label: string;
}
interface SortOption {
field: string;
direction: "asc" | "desc";
}
interface FiltersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filters: PlacementFilters) => void;
existingPlacements: Placement[];
initialFilters: PlacementFilters;
}
export interface PlacementFilters {
channelFilters: {
name: { value: string; condition: FilterCondition };
username: { value: string; condition: FilterCondition };
subscribersMin: number | null;
subscribersMax: number | null;
};
placementFilters: {
statuses: PlacementStatus[];
costMin: number | null;
costMax: number | null;
dateFrom: string | null;
dateTo: string | null;
hasPlacement: boolean | null;
};
sort: SortOption | null;
}
const CHANNEL_FILTER_FIELDS: FilterField[] = [
{ key: "name", label: "Название канала", type: "text" },
{ key: "username", label: "Username", type: "text" },
];
const SORT_FIELDS: SortField[] = [
{ key: "title", label: "Название канала" },
{ key: "subscribers", label: "Подписчики" },
{ key: "cost", label: "Стоимость" },
{ key: "date", label: "Дата размещения" },
{ key: "cpm", label: "CPM" },
{ key: "cpf", label: "CPF" },
];
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
{ value: "contains", label: "содержит" },
{ value: "not_contains", label: "не содержит" },
{ value: "equals", label: "равно" },
];
const STATUS_OPTIONS: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
const CONDITION_LABELS: Record<FilterCondition, string> = {
contains: "содержит",
not_contains: "не содержит",
equals: "равно",
};
export function FiltersModal({
open,
onOpenChange,
onApply,
existingPlacements,
initialFilters,
}: FiltersModalProps) {
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("channels");
const handleApply = () => {
onApply(filters);
onOpenChange(false);
};
const handleReset = () => {
const defaultFilters: PlacementFilters = {
channelFilters: {
name: { value: "", condition: "contains" },
username: { value: "", condition: "contains" },
subscribersMin: null,
subscribersMax: null,
},
placementFilters: {
statuses: [],
costMin: null,
costMax: null,
dateFrom: null,
dateTo: null,
hasPlacement: null,
},
sort: null,
};
setFilters(defaultFilters);
};
const toggleStatus = (status: PlacementStatus) => {
const currentStatuses = filters.placementFilters.statuses;
const newStatuses = currentStatuses.includes(status)
? currentStatuses.filter((s) => s !== status)
: [...currentStatuses, status];
setFilters({
...filters,
placementFilters: { ...filters.placementFilters, statuses: newStatuses },
});
};
const updateChannelFilter = (
key: "name" | "username",
updates: { value?: string; condition?: FilterCondition }
) => {
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
[key]: { ...filters.channelFilters[key], ...updates },
},
});
};
const hasActiveFilters =
filters.channelFilters.name.value ||
filters.channelFilters.username.value ||
filters.channelFilters.subscribersMin !== null ||
filters.channelFilters.subscribersMax !== null ||
filters.placementFilters.statuses.length > 0 ||
filters.placementFilters.costMin !== null ||
filters.placementFilters.costMax !== null ||
filters.placementFilters.dateFrom ||
filters.placementFilters.dateTo ||
filters.sort;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Фильтры и сортировка
</DialogTitle>
<DialogDescription>
Настройте фильтры для каналов и размещений
</DialogDescription>
</DialogHeader>
<div className="flex border-b">
<button
onClick={() => setActiveTab("channels")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "channels"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Каналы
</button>
<button
onClick={() => setActiveTab("placements")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
activeTab === "placements"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
Размещения
</button>
<button
onClick={() => setActiveTab("sort")}
className={cn(
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
activeTab === "sort"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<ArrowUpDown className="h-4 w-4" />
Сортировка
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "channels" && (
<div className="space-y-4">
{CHANNEL_FILTER_FIELDS.map((field) => (
<div key={field.key} className="space-y-2">
<Label className="flex items-center gap-2">
{field.label}
<Select
value={filters.channelFilters[field.key as "name" | "username"].condition}
onValueChange={(value: FilterCondition) =>
updateChannelFilter(field.key as "name" | "username", { condition: value })
}
>
<SelectTrigger className="h-7 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Input
placeholder={
filters.channelFilters[field.key as "name" | "username"].condition === "equals"
? "Точное значение"
: "Например: новости"
}
value={filters.channelFilters[field.key as "name" | "username"].value}
onChange={(e) =>
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
}
/>
</div>
))}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. подписчиков</Label>
<Input
type="number"
placeholder="0"
value={filters.channelFilters.subscribersMin || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMin: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Макс. подписчиков</Label>
<Input
type="number"
placeholder="1000000"
value={filters.channelFilters.subscribersMax || ""}
onChange={(e) =>
setFilters({
...filters,
channelFilters: {
...filters.channelFilters,
subscribersMax: e.target.value ? parseInt(e.target.value) : null,
},
})
}
/>
</div>
</div>
</div>
)}
{activeTab === "placements" && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Статусы размещений</Label>
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
{STATUS_OPTIONS.map((status) => (
<label
key={status}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
filters.placementFilters.statuses.includes(status)
? "bg-primary/10 border-primary text-primary"
: "hover:bg-muted"
)}
>
<Checkbox
checked={filters.placementFilters.statuses.includes(status)}
onCheckedChange={() => toggleStatus(status)}
/>
{status}
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Мин. стоимость ()</Label>
<Input
type="number"
placeholder="0"
value={filters.placementFilters.costMin || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMin: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Макс. стоимость ()</Label>
<Input
type="number"
placeholder="100000"
value={filters.placementFilters.costMax || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
costMax: e.target.value ? parseFloat(e.target.value) : null,
},
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Дата размещения от</Label>
<Input
type="date"
value={filters.placementFilters.dateFrom || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateFrom: e.target.value || null,
},
})
}
/>
</div>
<div className="space-y-2">
<Label>Дата размещения до</Label>
<Input
type="date"
value={filters.placementFilters.dateTo || ""}
onChange={(e) =>
setFilters({
...filters,
placementFilters: {
...filters.placementFilters,
dateTo: e.target.value || null,
},
})
}
/>
</div>
</div>
</div>
)}
{activeTab === "sort" && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-2">
<Label>Сортировать по</Label>
<Select
value={filters.sort?.field || ""}
onValueChange={(field) =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, field }
: { field, direction: "desc" },
})
}
>
<SelectTrigger>
<SelectValue placeholder="Выберите поле" />
</SelectTrigger>
<SelectContent>
{SORT_FIELDS.map((field) => (
<SelectItem key={field.key} value={field.key}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Направление</Label>
<div className="flex gap-1 p-1 border rounded-md">
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "asc" }
: { field: "title", direction: "asc" },
})
}
className={cn(
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
filters.sort?.direction === "asc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По возрастанию"
>
<ArrowUp className="h-4 w-4" />
<span className="ml-1 text-xs">A-Z</span>
</button>
<button
onClick={() =>
setFilters({
...filters,
sort: filters.sort
? { ...filters.sort, direction: "desc" }
: { field: "title", direction: "desc" },
})
}
className={cn(
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
filters.sort?.direction === "desc"
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
title="По убыванию"
>
<ArrowDown className="h-4 w-4" />
<span className="ml-1 text-xs">Z-A</span>
</button>
</div>
</div>
</div>
{filters.sort && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Текущая сортировка:</span>
<span className="font-medium">
{SORT_FIELDS.find((f) => f.key === filters.sort?.field)?.label ||
filters.sort?.field}
</span>
<span>
{filters.sort.direction === "asc" ? "↑" : "↓"} (
{filters.sort.direction === "asc" ? "A-Z" : "Z-A"})
</span>
</div>
)}
</div>
)}
</div>
<DialogFooter className="flex justify-between">
<Button variant="outline" onClick={handleReset}>
Сбросить
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button onClick={handleApply}>
Применить
{hasActiveFilters && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
</span>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,7 +16,7 @@ import {
ClipboardCheck, ClipboardCheck,
ClipboardList, ClipboardList,
Filter, Filter,
Folder, FolderKanban,
Gauge, Gauge,
KanbanSquare, KanbanSquare,
LayoutDashboard, LayoutDashboard,
@@ -27,12 +27,15 @@ import {
MessageCircle, MessageCircle,
MonitorPlay, MonitorPlay,
NotebookPen, NotebookPen,
Palette,
PlayCircle, PlayCircle,
Settings, Settings,
ShoppingCart, ShoppingCart,
Sparkles, Sparkles,
Trash2,
Tv, Tv,
TrendingUp, TrendingUp,
Upload,
BookOpen, BookOpen,
ChevronRight, ChevronRight,
X, X,
@@ -41,6 +44,7 @@ import {
import { type NavGuideState, NavMain } from "@/components/nav-main"; import { type NavGuideState, NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user"; import { NavUser } from "@/components/nav-user";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -97,10 +101,12 @@ interface NavItem {
icon?: LucideIcon; icon?: LucideIcon;
isActive?: boolean; isActive?: boolean;
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full"; requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
tooltip?: string;
items?: { items?: {
title: string; title: string;
url: string; url: string;
icon?: LucideIcon; icon?: LucideIcon;
tooltip?: string;
}[]; }[];
} }
@@ -338,7 +344,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const [showCreateDialog, setShowCreateDialog] = React.useState(false); const [showCreateDialog, setShowCreateDialog] = React.useState(false);
const [newWorkspaceName, setNewWorkspaceName] = React.useState(""); const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
const [newWorkspaceAvatar, setNewWorkspaceAvatar] = React.useState<File | null>(null);
const [newWorkspaceAvatarPreview, setNewWorkspaceAvatarPreview] = React.useState<string | null>(null);
const [isCreating, setIsCreating] = React.useState(false); const [isCreating, setIsCreating] = React.useState(false);
const newWorkspaceAvatarInputRef = React.useRef<HTMLInputElement>(null);
const handleWorkspaceSelect = React.useCallback( const handleWorkspaceSelect = React.useCallback(
(workspaceId: string) => { (workspaceId: string) => {
if (currentWorkspace?.id === workspaceId) return; if (currentWorkspace?.id === workspaceId) return;
@@ -362,13 +371,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: buildRoute.dashboard(workspaceId), url: buildRoute.dashboard(workspaceId),
icon: LayoutDashboard, icon: LayoutDashboard,
isActive: true, isActive: true,
tooltip: "Обзор ключевых метрик: расходы, охват, подписчики, CPF и CPM",
}); });
// 2. Проекты (всегда доступны) // 2. Проекты (всегда доступны)
items.push({ items.push({
title: "Проекты", title: "Проекты",
url: buildRoute.projects(workspaceId), url: buildRoute.projects(workspaceId),
icon: Tv, icon: FolderKanban,
tooltip: "Управление рекламными кампаниями: статус, бюджет, ответственные",
}); });
// 3. Креативы (только с доступом к размещениям) // 3. Креативы (только с доступом к размещениям)
@@ -376,67 +387,62 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
items.push({ items.push({
title: "Креативы", title: "Креативы",
url: buildRoute.creatives(workspaceId), url: buildRoute.creatives(workspaceId),
icon: Folder, icon: Palette,
requiredPermission: "placements_read", requiredPermission: "placements_read",
tooltip: "Шаблоны рекламных сообщений для закупов",
}); });
} }
// 4. Каталог каналов (всегда доступен) // 4. Размещения (ранее "Планы закупов")
items.push({
title: "Каталог каналов",
url: buildRoute.channels(workspaceId),
icon: Building2,
});
// 5. Планы закупов (правка названия)
if (hasPermission("placements_read")) { if (hasPermission("placements_read")) {
items.push({ items.push({
title: "Планы закупов", title: "Планы закупов",
url: buildRoute.purchasePlans(workspaceId), url: buildRoute.purchasePlans(workspaceId),
icon: ClipboardList, icon: ClipboardList,
requiredPermission: "placements_read", requiredPermission: "placements_read",
tooltip: "Черновики кампаний: сбор каналов, фиксация ставок, согласование бюджета",
}); });
} }
// 6. Размещения // 5. Аналитика — только подразделы
if (hasPermission("placements_read")) {
items.push({
title: "Размещения",
url: buildRoute.placements(workspaceId),
icon: ShoppingCart,
requiredPermission: "placements_read",
});
}
// 7. Аналитика — только два подраздела
if (hasPermission("analytics_read")) { if (hasPermission("analytics_read")) {
items.push({ items.push({
title: "Аналитика", title: "Аналитика",
url: buildRoute.analytics(workspaceId), url: buildRoute.analytics(workspaceId),
icon: BarChart3, icon: BarChart3,
requiredPermission: "analytics_read", requiredPermission: "analytics_read",
tooltip: "Сводные отчеты: эффективность по проектам и креативам",
items: [ items: [
{
title: "Все размещения",
url: buildRoute.analyticsPlacements(workspaceId),
icon: ShoppingCart,
tooltip: "Сводная таблица по всем закупкам",
},
{ {
title: "По проектам", title: "По проектам",
url: buildRoute.analyticsProjects(workspaceId), url: buildRoute.analyticsProjects(workspaceId),
icon: Tv, icon: Tv,
tooltip: "Метрики эффективности по каждому проекту",
}, },
{ {
title: "По креативам", title: "По креативам",
url: buildRoute.analyticsCreatives(workspaceId), url: buildRoute.analyticsCreatives(workspaceId),
icon: Folder, icon: Palette,
tooltip: "Анализ результатов по рекламным шаблонам",
}, },
], ],
}); });
} }
// 8. Настройки (без подразделов, админ) // 6. Настройки (без подразделов, админ)
if (isAdmin) { if (isAdmin) {
items.push({ items.push({
title: "Настройки", title: "Настройки",
url: buildRoute.settings(workspaceId), url: buildRoute.settings(workspaceId),
icon: Settings, icon: Settings,
requiredPermission: "admin_full", requiredPermission: "admin_full",
tooltip: "Управление доступом и параметрами рабочего пространства",
}); });
} }
@@ -503,7 +509,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [isTrainingOpen, currentTrainingStep]); }, [isTrainingOpen, currentTrainingStep]);
const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep); const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep);
const channelLink = React.useMemo<SupportLink>( type SimpleLink = { title: string; href: string; icon: LucideIcon; external?: boolean };
const channelLink = React.useMemo<SimpleLink>(
() => ({ () => ({
title: "Наш канал", title: "Наш канал",
href: "https://t.me/+xqHlRelJ2etkMGIy", href: "https://t.me/+xqHlRelJ2etkMGIy",
@@ -593,9 +601,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
try { try {
setIsCreating(true); setIsCreating(true);
const workspace = await createWorkspace(newWorkspaceName.trim()); const workspace = await createWorkspace(newWorkspaceName.trim(), newWorkspaceAvatar);
setShowCreateDialog(false); setShowCreateDialog(false);
setNewWorkspaceName(""); setNewWorkspaceName("");
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
setCurrentWorkspace(workspace); setCurrentWorkspace(workspace);
} catch (err) { } catch (err) {
console.error("Failed to create workspace:", err); console.error("Failed to create workspace:", err);
@@ -604,6 +614,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
} }
}; };
const handleNewWorkspaceAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
return;
}
setNewWorkspaceAvatar(file);
const reader = new FileReader();
reader.onload = () => {
setNewWorkspaceAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleNewWorkspaceAvatarRemove = () => {
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
if (newWorkspaceAvatarInputRef.current) {
newWorkspaceAvatarInputRef.current.value = "";
}
};
return ( return (
<> <>
<Sidebar collapsible="icon" {...props}> <Sidebar collapsible="icon" {...props}>
@@ -663,7 +697,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarGroup className="px-0"> <SidebarGroup className="px-0 pb-0">
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{/* Наш канал */} {/* Наш канал */}
@@ -671,6 +705,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuButton <SidebarMenuButton
asChild asChild
size="sm" size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? channelLink.title : undefined} tooltip={isSidebarCollapsed ? channelLink.title : undefined}
> >
<a <a
@@ -679,9 +714,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<channelLink.icon className="size-4 text-muted-foreground/90" /> <channelLink.icon className="size-3.5 text-muted-foreground/90" />
<span className="text-muted-foreground/90">{channelLink.title}</span> <span className="text-xs text-muted-foreground/90">{channelLink.title}</span>
<ArrowUpRight className="ml-auto size-3.5 text-muted-foreground/60" /> <ArrowUpRight className="ml-auto size-3 text-muted-foreground/60" />
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -690,12 +725,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
size="sm" size="sm"
className="!p-1.5 !h-7"
tooltip={isSidebarCollapsed ? "Обучение" : undefined} tooltip={isSidebarCollapsed ? "Обучение" : undefined}
onClick={openTraining} onClick={openTraining}
className="cursor-pointer"
> >
<MonitorPlay className="size-4 text-muted-foreground/90" /> <MonitorPlay className="size-3.5 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Обучение</span> <span className="text-xs text-muted-foreground/90">Обучение</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -706,14 +741,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size="sm" size="sm"
className="!p-1.5 !h-7 justify-between"
tooltip="Помощь" tooltip="Помощь"
className="justify-between"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LifeBuoy className="size-4 text-muted-foreground/90" /> <LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Помощь</span> <span className="text-xs text-muted-foreground/90">Помощь</span>
</div> </div>
<ChevronRight className="size-4 text-muted-foreground/60" /> <ChevronRight className="size-3 text-muted-foreground/60" />
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -735,20 +770,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
size="sm" size="sm"
className="justify-between" className="!p-1.5 !h-7 justify-between"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LifeBuoy className="size-4 text-muted-foreground/90" /> <LifeBuoy className="size-3.5 text-muted-foreground/90" />
<span className="text-muted-foreground/90">Помощь</span> <span className="text-xs text-muted-foreground/90">Помощь</span>
</div> </div>
<ChevronRight className="size-4 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" /> <ChevronRight className="size-3 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton> </SidebarMenuButton>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<SidebarMenuSub> <SidebarMenuSub>
{helpSubItems.map((item) => ( {helpSubItems.map((item) => (
<SidebarMenuSubItem key={item.title}> <SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton asChild className=""> <SidebarMenuSubButton asChild className="!p-1.5 !h-7">
{renderHelpSubItem(item)} {renderHelpSubItem(item)}
</SidebarMenuSubButton> </SidebarMenuSubButton>
</SidebarMenuSubItem> </SidebarMenuSubItem>
@@ -826,6 +861,47 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarImage src={newWorkspaceAvatarPreview || undefined} alt="Avatar" />
<AvatarFallback>
{newWorkspaceName.charAt(0).toUpperCase() || "W"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => newWorkspaceAvatarInputRef.current?.click()}
disabled={isCreating}
>
<Upload className="h-4 w-4 mr-2" />
Загрузить
</Button>
{newWorkspaceAvatarPreview && (
<Button
variant="outline"
size="sm"
onClick={handleNewWorkspaceAvatarRemove}
disabled={isCreating}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<input
ref={newWorkspaceAvatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleNewWorkspaceAvatarChange}
/>
</div>
</div>
{/* Name */}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="workspace-name">Название</Label> <Label htmlFor="workspace-name">Название</Label>
<Input <Input
@@ -838,13 +914,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
handleCreateWorkspace(); handleCreateWorkspace();
} }
}} }}
disabled={isCreating}
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowCreateDialog(false)} onClick={() => {
setShowCreateDialog(false);
setNewWorkspaceName("");
setNewWorkspaceAvatar(null);
setNewWorkspaceAvatarPreview(null);
}}
> >
Отмена Отмена
</Button> </Button>

View File

@@ -42,10 +42,12 @@ export function NavMain({
url: string; url: string;
icon?: LucideIcon; icon?: LucideIcon;
isActive?: boolean; isActive?: boolean;
tooltip?: string;
items?: { items?: {
title: string; title: string;
url: string; url: string;
icon?: LucideIcon; icon?: LucideIcon;
tooltip?: string;
}[]; }[];
}[]; }[];
guideState?: NavGuideState; guideState?: NavGuideState;
@@ -60,13 +62,15 @@ export function NavMain({
<SidebarMenu> <SidebarMenu>
{items.map((item) => { {items.map((item) => {
const hasSubmenu = item.items && item.items.length > 0; const hasSubmenu = item.items && item.items.length > 0;
const isActive = pathname === item.url; const isActive = hasSubmenu
? pathname.startsWith(item.url)
: pathname === item.url || (item.title === "Главная" ? false : pathname.startsWith(item.url + "/"));
const hasActiveSubmenu = const hasActiveSubmenu =
hasSubmenu && item.items!.some((sub) => pathname === sub.url); hasSubmenu && item.items!.some((sub) => pathname === sub.url);
const shouldForceOpen = guideState?.expandedItems?.includes(item.title); const shouldForceOpen = guideState?.expandedItems?.includes(item.title);
const shouldHighlightButton = item.title === "Аналитика" const shouldHighlightButton = item.title === "Аналитика"
? false ? false
: isActive; : isActive || hasActiveSubmenu;
const isGuideHighlight = highlightedItem === item.title; const isGuideHighlight = highlightedItem === item.title;
const highlightedButtonClass = isGuideHighlight const highlightedButtonClass = isGuideHighlight
? "ring-2 ring-primary/70" ? "ring-2 ring-primary/70"
@@ -79,7 +83,7 @@ export function NavMain({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
tooltip={item.title} tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
isActive={shouldHighlightButton} isActive={shouldHighlightButton}
className={cn(highlightedButtonClass)} className={cn(highlightedButtonClass)}
> >
@@ -161,7 +165,7 @@ export function NavMain({
<> <>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
tooltip={item.title} tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
isActive={shouldHighlightButton} isActive={shouldHighlightButton}
className={cn(highlightedButtonClass)} className={cn(highlightedButtonClass)}
> >
@@ -234,7 +238,7 @@ export function NavMain({
) : ( ) : (
<SidebarMenuButton <SidebarMenuButton
asChild asChild
tooltip={item.title} tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
isActive={shouldHighlightButton} isActive={shouldHighlightButton}
className={cn(highlightedButtonClass)} className={cn(highlightedButtonClass)}
> >

View File

@@ -107,19 +107,23 @@ export function NavUser({
{workspaceSwitcher.workspaces.length > 0 ? ( {workspaceSwitcher.workspaces.length > 0 ? (
workspaceSwitcher.workspaces.map((workspace) => { workspaceSwitcher.workspaces.map((workspace) => {
const isDemo = workspace.id === DEMO_WORKSPACE_ID; const isDemo = workspace.id === DEMO_WORKSPACE_ID;
const isSelected = workspaceSwitcher.currentWorkspaceId === workspace.id;
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={workspace.id} key={workspace.id}
onClick={() => workspaceSwitcher.onSelect(workspace)} onClick={() => workspaceSwitcher.onSelect(workspace)}
className="gap-2 p-2 text-sm" className="gap-2 p-2 text-sm"
> >
<div className="flex size-6 items-center justify-center rounded-sm border"> <Avatar className="h-6 w-6 rounded-sm">
{isDemo ? ( <AvatarImage src={workspace.avatar_url || undefined} alt={workspace.name} />
<Sparkles className="size-4 shrink-0 text-purple-500" /> <AvatarFallback className="rounded-sm text-xs">
) : ( {isDemo ? (
<Building2 className="size-4 shrink-0" /> <Sparkles className="h-3 w-3 text-purple-500" />
)} ) : (
</div> workspace.name.charAt(0).toUpperCase()
)}
</AvatarFallback>
</Avatar>
<span className="flex-1 truncate"> <span className="flex-1 truncate">
{workspace.name} {workspace.name}
{isDemo && ( {isDemo && (
@@ -128,7 +132,7 @@ export function NavUser({
</span> </span>
)} )}
</span> </span>
{workspaceSwitcher.currentWorkspaceId === workspace.id && ( {isSelected && (
<Check className="size-4 ml-auto" /> <Check className="size-4 ml-auto" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -55,6 +55,8 @@ interface WorkspaceContextType {
permissions: Permission[]; permissions: Permission[];
hasPermission: (key: PermissionKey, projectId?: string) => boolean; hasPermission: (key: PermissionKey, projectId?: string) => boolean;
isAdmin: boolean; isAdmin: boolean;
// Check if user is admin in a specific workspace
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
// Loading states // Loading states
isLoading: boolean; isLoading: boolean;
@@ -64,7 +66,7 @@ interface WorkspaceContextType {
// Actions // Actions
refreshWorkspaces: () => Promise<Workspace[]>; refreshWorkspaces: () => Promise<Workspace[]>;
refreshProjects: () => Promise<void>; refreshProjects: () => Promise<void>;
createWorkspace: (name: string) => Promise<Workspace>; createWorkspace: (name: string, avatarFile?: File | null) => Promise<Workspace>;
} }
const WorkspaceContext = createContext<WorkspaceContextType | undefined>( const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
@@ -406,12 +408,49 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
} }
}, [projects, saveSelectedProjects]); }, [projects, saveSelectedProjects]);
// ============================================================================
// Check if user is admin in a specific workspace
// ============================================================================
const isWorkspaceAdmin = useCallback(async (workspaceId: string): Promise<boolean> => {
// Demo mode - user is admin
if (isDemoMode || workspaceId === DEMO_WORKSPACE_ID) {
return true;
}
// Check if this is the current workspace
if (currentWorkspace?.id === workspaceId) {
return isAdmin;
}
// Load permissions for the target workspace
try {
const me = await authApi.me();
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
const member = membersResponse.items.find((m) => m.user.id === me.id);
if (!member) {
// Check if user is creator (members list is empty or this is first member)
if (membersResponse.items.length === 0) {
return true;
}
return false;
}
// Check if has admin_full permission
return member.permissions.some((p) => p.key === "admin_full");
} catch (err) {
console.error("Failed to check workspace permissions:", err);
return false;
}
}, [currentWorkspace, isAdmin, isDemoMode]);
// ============================================================================ // ============================================================================
// Create Workspace // Create Workspace
// ============================================================================ // ============================================================================
const createWorkspace = useCallback(async (name: string): Promise<Workspace> => { const createWorkspace = useCallback(async (name: string, avatarFile?: File | null): Promise<Workspace> => {
const workspace = await workspacesApi.create({ name }); const workspace = await workspacesApi.create({ name, avatar_file: avatarFile });
setWorkspaces((prev) => [...prev, workspace]); setWorkspaces((prev) => [...prev, workspace]);
return workspace; return workspace;
}, []); }, []);
@@ -462,6 +501,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
permissions, permissions,
hasPermission, hasPermission,
isAdmin, isAdmin,
isWorkspaceAdmin,
// Loading states // Loading states
isLoading, isLoading,

View File

@@ -128,7 +128,7 @@ function SidebarProvider({
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={500}>
<div <div
data-slot="sidebar-wrapper" data-slot="sidebar-wrapper"
style={ style={
@@ -533,12 +533,12 @@ function SidebarMenuButton({
} }
return ( return (
<Tooltip> <Tooltip delayDuration={500}>
<TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent <TooltipContent
side="right" side="right"
align="center" align="center"
hidden={state !== "collapsed" || isMobile} hidden={isMobile}
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
@@ -643,7 +643,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub" data-slot="sidebar-menu-sub"
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", "flex min-w-0 flex-col gap-1 px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className
)} )}

View File

@@ -19,10 +19,11 @@ function TooltipProvider({
} }
function Tooltip({ function Tooltip({
delayDuration = 500,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return ( return (
<TooltipProvider> <TooltipProvider delayDuration={delayDuration}>
<TooltipPrimitive.Root data-slot="tooltip" {...props} /> <TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider> </TooltipProvider>
) )
@@ -46,13 +47,13 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance shadow-md border border-border/50",
className className
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-popover fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )

View File

@@ -15,13 +15,14 @@ import type {
OverviewAnalyticsQueryParams, OverviewAnalyticsQueryParams,
ProjectsAnalyticsResponse, ProjectsAnalyticsResponse,
ProjectsAnalyticsQueryParams, ProjectsAnalyticsQueryParams,
PlacementsAnalyticsQueryParams,
} from "@/lib/types/api"; } from "@/lib/types/api";
export const analyticsApi = { export const analyticsApi = {
/** /**
* Get placements analytics * Get placements analytics
*/ */
placements: (workspaceId: string, params?: AnalyticsQueryParams) => placements: (workspaceId: string, params?: PlacementsAnalyticsQueryParams) =>
api.get<PaginatedResponse<PlacementAnalyticsItem>>( api.get<PaginatedResponse<PlacementAnalyticsItem>>(
`/workspaces/${workspaceId}/analytics/placements`, `/workspaces/${workspaceId}/analytics/placements`,
{ params } { params }

View File

@@ -81,15 +81,20 @@ export const apiRequest = async <T = any>(
options: RequestOptions = {} options: RequestOptions = {}
): Promise<T> => { ): Promise<T> => {
const { params, requireAuth = true, ...fetchOptions } = options; const { params, requireAuth = true, ...fetchOptions } = options;
const body = fetchOptions.body;
// Build URL // Build URL
const url = buildUrl(`${API_URL}${endpoint}`, params); const url = buildUrl(`${API_URL}${endpoint}`, params);
const headers: HeadersInit = { const headers: HeadersInit = {
"Content-Type": "application/json",
...fetchOptions.headers, ...fetchOptions.headers,
}; };
// Don't set Content-Type for FormData - browser will set it with boundary
if (!(body instanceof FormData)) {
(headers as Record<string, string>)["Content-Type"] = "application/json";
}
// Add auth token if required // Add auth token if required
if (requireAuth) { if (requireAuth) {
const token = getAuthToken(); const token = getAuthToken();
@@ -99,9 +104,16 @@ export const apiRequest = async <T = any>(
} }
try { try {
// Serialize body to JSON if it's an object (not FormData or string)
let serializedBody = body;
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof String)) {
serializedBody = JSON.stringify(body);
}
const response = await fetch(url, { const response = await fetch(url, {
...fetchOptions, ...fetchOptions,
headers, headers,
body: serializedBody,
}); });
// Handle 204 No Content // Handle 204 No Content
@@ -145,21 +157,21 @@ export const api = {
apiRequest<T>(endpoint, { apiRequest<T>(endpoint, {
...options, ...options,
method: "POST", method: "POST",
body: body ? JSON.stringify(body) : undefined, body,
}), }),
put: <T = any>(endpoint: string, body?: any, options?: RequestOptions) => put: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
apiRequest<T>(endpoint, { apiRequest<T>(endpoint, {
...options, ...options,
method: "PUT", method: "PUT",
body: body ? JSON.stringify(body) : undefined, body,
}), }),
patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) => patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
apiRequest<T>(endpoint, { apiRequest<T>(endpoint, {
...options, ...options,
method: "PATCH", method: "PATCH",
body: body ? JSON.stringify(body) : undefined, body,
}), }),
delete: <T = any>(endpoint: string, options?: RequestOptions) => delete: <T = any>(endpoint: string, options?: RequestOptions) =>

View File

@@ -21,6 +21,15 @@ export const creativesApi = {
{ params } { params }
), ),
/**
* Get list of creatives for a specific project
*/
listByProject: (workspaceId: string, projectId: string) =>
api.get<PaginatedResponse<Creative>>(
`/workspaces/${workspaceId}/creatives`,
{ params: { project_id: projectId } }
),
/** /**
* Get single creative * Get single creative
*/ */

View File

@@ -5,6 +5,7 @@
import { api } from "./client"; import { api } from "./client";
import type { import type {
Placement, Placement,
PlacementWithStats,
PlacementCreateRequest, PlacementCreateRequest,
PlacementUpdateRequest, PlacementUpdateRequest,
PlacementsQueryParams, PlacementsQueryParams,
@@ -23,6 +24,13 @@ export const placementsApi = {
{ params } { params }
), ),
/**
* Get placements for a specific project (grouped by channel UI)
*/
getByProject: (workspaceId: string, projectId: string) =>
api.get<{ placements: Placement[] }>(`/workspaces/${workspaceId}/projects/${projectId}/placements`)
.then((response) => response.placements),
/** /**
* Get single placement * Get single placement
*/ */
@@ -35,6 +43,46 @@ export const placementsApi = {
create: (workspaceId: string, data: PlacementCreateRequest) => create: (workspaceId: string, data: PlacementCreateRequest) =>
api.post<Placement>(`/workspaces/${workspaceId}/placements`, data), api.post<Placement>(`/workspaces/${workspaceId}/placements`, data),
/**
* Create placements for multiple channels (bulk creation)
*/
createBulk: (
workspaceId: string,
projectId: string,
data: {
creative_id?: string;
channels: Array<{
username: string;
status?: string;
comment?: string;
details?: {
placement_at?: string;
payment_at?: string;
cost?: { type: string; value: number };
cost_before_bargain?: number;
placement_type?: string;
format?: string;
comment?: string;
creative_id?: string;
};
}>;
details?: {
placement_at?: string;
payment_at?: string;
cost?: { type: string; value: number };
cost_before_bargain?: number;
placement_type?: string;
format?: string;
comment?: string;
creative_id?: string;
};
}
) =>
api.post<{ placements: Placement[] }>(
`/workspaces/${workspaceId}/projects/${projectId}/placements`,
data
),
/** /**
* Update placement * Update placement
*/ */

View File

@@ -9,6 +9,10 @@ export interface UpdateProjectInviteLinkTypeRequest {
purchase_invite_type_default: "public" | "approval"; purchase_invite_type_default: "public" | "approval";
} }
export interface MoveProjectRequest {
target_workspace_id: string;
}
export const projectsApi = { export const projectsApi = {
/** /**
* Get list of projects for workspace * Get list of projects for workspace
@@ -41,10 +45,31 @@ export const projectsApi = {
`/workspaces/${workspaceId}/projects/${projectId}/archive` `/workspaces/${workspaceId}/projects/${projectId}/archive`
), ),
/**
* Unarchive project
*/
unarchive: (workspaceId: string, projectId: string) =>
api.post<Project>(
`/workspaces/${workspaceId}/projects/${projectId}/unarchive`
),
/** /**
* Delete project (soft delete) * Delete project (soft delete)
*/ */
delete: (workspaceId: string, projectId: string) => delete: (workspaceId: string, projectId: string) =>
api.delete(`/workspaces/${workspaceId}/projects/${projectId}`), api.delete(`/workspaces/${workspaceId}/projects/${projectId}`),
/**
* Move project to another workspace
*/
move: (
workspaceId: string,
projectId: string,
data: MoveProjectRequest
) =>
api.put<Project>(
`/workspaces/${workspaceId}/projects/${projectId}/move`,
data
),
}; };

View File

@@ -37,5 +37,22 @@ export const workspacesApi = {
*/ */
delete: (workspaceId: string) => delete: (workspaceId: string) =>
api.delete<void>(`${BASE_PATH}/${workspaceId}`), api.delete<void>(`${BASE_PATH}/${workspaceId}`),
/**
* Upload workspace avatar
*/
uploadAvatar: (workspaceId: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return api.post<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
/**
* Delete workspace avatar
*/
deleteAvatar: (workspaceId: string) =>
api.delete<Workspace>(`${BASE_PATH}/${workspaceId}/avatar`),
}; };

View File

@@ -113,14 +113,15 @@ export const demoApi = {
} }
if (params?.placement_channel_id) { if (params?.placement_channel_id) {
items = items.filter( items = items.filter(
(p) => p.placement_channel_id === params.placement_channel_id (p) => p.channel.id === params.placement_channel_id
); );
} }
if (params?.creative_id) { if (params?.creative_id) {
items = items.filter((p) => p.creative_id === params.creative_id); items = items.filter((p) => p.creative_id === params.creative_id);
} }
// Filter out cancelled/archived placements
if (!params?.include_archived) { if (!params?.include_archived) {
items = items.filter((p) => p.status === "active"); items = items.filter((p) => p.status !== "Отмена" && p.status !== "Неактуально");
} }
return Promise.resolve(paginate(items)); return Promise.resolve(paginate(items));
}, },
@@ -145,18 +146,18 @@ export const demoApi = {
// Filter by project if specified // Filter by project if specified
if (params?.project_id) { if (params?.project_id) {
const projectPlacements = demoPlacements.filter( const projectPlacements = demoPlacements.filter(
(p) => p.project_id === params.project_id && p.status === "active" (p) => p.project_id === params.project_id && p.status !== "Отмена" && p.status !== "Неактуально"
); );
const totalCost = projectPlacements.reduce( const totalCost = projectPlacements.reduce(
(sum, p) => sum + (p.cost || 0), (sum, p) => sum + (p.details?.cost?.value || 0),
0 0
); );
const totalSubs = projectPlacements.reduce( const totalSubs = projectPlacements.reduce(
(sum, p) => sum + (p.subscriptions_count || 0), (sum, p) => sum + (p.placement_post?.subscriptions_count || 0),
0 0
); );
const totalViews = projectPlacements.reduce( const totalViews = projectPlacements.reduce(
(sum, p) => sum + (p.views_count || 0), (sum, p) => sum + (p.placement_post?.views_count || 0),
0 0
); );
@@ -167,6 +168,7 @@ export const demoApi = {
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null, avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
chart_data: demoSpendingAnalytics.chart_data.slice(0, 3), chart_data: demoSpendingAnalytics.chart_data.slice(0, 3),
placements_count: 12,
} as SpendingAnalytics); } as SpendingAnalytics);
} }
return Promise.resolve(demoSpendingAnalytics); return Promise.resolve(demoSpendingAnalytics);
@@ -187,7 +189,7 @@ export const demoApi = {
const projectPlacementChannelIds = new Set( const projectPlacementChannelIds = new Set(
demoPlacements demoPlacements
.filter((p) => p.project_id === params.project_id) .filter((p) => p.project_id === params.project_id)
.map((p) => p.placement_channel_id) .map((p) => p.channel.id)
); );
items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id)); items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id));
} }

View File

@@ -8,6 +8,7 @@ import { ru } from "date-fns/locale";
import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants"; import { isDemoWorkspace, DEMO_WORKSPACE_ID } from "./constants";
import { import {
demoChannels, demoChannels,
demoPlacementChannels,
demoCreatives, demoCreatives,
demoPlacements, demoPlacements,
demoPurchasePlanChannels, demoPurchasePlanChannels,
@@ -124,6 +125,17 @@ export const demoAwareProjectsApi = {
} }
return projectsApi.archive(workspaceId, projectId); return projectsApi.archive(workspaceId, projectId);
}, },
unarchive: async (
workspaceId: string,
projectId: string
): Promise<Project> => {
if (isDemoWorkspace(workspaceId)) {
const project = demoProjects.find(p => p.id === projectId);
if (!project) throw new Error("Project not found");
return { ...project, status: "active" };
}
return projectsApi.unarchive(workspaceId, projectId);
},
delete: async ( delete: async (
workspaceId: string, workspaceId: string,
projectId: string projectId: string
@@ -138,6 +150,19 @@ export const demoAwareProjectsApi = {
} }
return projectsApi.delete(workspaceId, projectId); return projectsApi.delete(workspaceId, projectId);
}, },
move: async (
workspaceId: string,
projectId: string,
data: { target_workspace_id: string }
): Promise<Project> => {
if (isDemoWorkspace(workspaceId)) {
const project = demoProjects.find(p => p.id === projectId);
if (!project) throw new Error("Project not found");
// In demo mode, just return the project (mock move)
return project;
}
return projectsApi.move(workspaceId, projectId, data);
},
}; };
// ============================================================================ // ============================================================================
@@ -234,14 +259,14 @@ export const demoAwarePlacementsApi = {
} }
if (params?.placement_channel_id) { if (params?.placement_channel_id) {
items = items.filter( items = items.filter(
(p) => p.placement_channel_id === params.placement_channel_id (p) => p.channel.id === params.placement_channel_id
); );
} }
if (params?.creative_id) { if (params?.creative_id) {
items = items.filter((p) => p.creative_id === params.creative_id); items = items.filter((p) => p.creative_id === params.creative_id);
} }
if (!params?.include_archived) { if (!params?.include_archived) {
items = items.filter((p) => p.status === "active"); items = items.filter((p) => p.status !== "Отмена" && p.status !== "Неактуально");
} }
return paginate(items, params?.page, params?.size); return paginate(items, params?.page, params?.size);
} }
@@ -255,6 +280,18 @@ export const demoAwarePlacementsApi = {
} }
return placementsApi.get(workspaceId, placementId); return placementsApi.get(workspaceId, placementId);
}, },
getByProject: async (workspaceId: string, projectId: string): Promise<Placement[]> => {
if (isDemoWorkspace(workspaceId)) {
// In demo mode, return all placements for demo project
// Filter by demo project id for realistic behavior
return demoPlacements.filter((p) => {
// For demo purposes, we'll just return all placements
// In real usage, this would filter by project_id
return true;
});
}
return placementsApi.getByProject(workspaceId, projectId);
},
}; };
// ============================================================================ // ============================================================================
@@ -292,18 +329,18 @@ export const demoAwareAnalyticsApi = {
if (isDemoWorkspace(workspaceId)) { if (isDemoWorkspace(workspaceId)) {
if (params?.project_id) { if (params?.project_id) {
const projectPlacements = demoPlacements.filter( const projectPlacements = demoPlacements.filter(
(p) => p.project_id === params.project_id && p.status === "active" (p) => p.project_id === params.project_id && p.status !== "Отмена" && p.status !== "Неактуально"
); );
const totalCost = projectPlacements.reduce( const totalCost = projectPlacements.reduce(
(sum, p) => sum + (p.cost || 0), (sum, p) => sum + (p.details?.cost?.value || 0),
0 0
); );
const totalSubs = projectPlacements.reduce( const totalSubs = projectPlacements.reduce(
(sum, p) => sum + (p.subscriptions_count || 0), (sum, p) => sum + (p.placement_post?.subscriptions_count || 0),
0 0
); );
const totalViews = projectPlacements.reduce( const totalViews = projectPlacements.reduce(
(sum, p) => sum + (p.views_count || 0), (sum, p) => sum + (p.placement_post?.views_count || 0),
0 0
); );
return { return {
@@ -313,6 +350,7 @@ export const demoAwareAnalyticsApi = {
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null, avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
chart_data: demoSpendingAnalytics.chart_data.slice(0, 4), chart_data: demoSpendingAnalytics.chart_data.slice(0, 4),
placements_count: projectPlacements.length,
}; };
} }
return demoSpendingAnalytics; return demoSpendingAnalytics;
@@ -345,7 +383,7 @@ export const demoAwareAnalyticsApi = {
const projectPlacementChannelIds = new Set( const projectPlacementChannelIds = new Set(
demoPlacements demoPlacements
.filter((p) => p.project_id === params.project_id) .filter((p) => p.project_id === params.project_id)
.map((p) => p.placement_channel_id) .map((p) => p.channel.id)
); );
items = items.filter((a) => items = items.filter((a) =>
projectPlacementChannelIds.has(a.channel_id) projectPlacementChannelIds.has(a.channel_id)
@@ -357,13 +395,35 @@ export const demoAwareAnalyticsApi = {
}, },
placements: async ( placements: async (
workspaceId: string, workspaceId: string,
params?: { project_id?: string; page?: number; size?: number } params?: {
project_id?: string;
placement_channel_id?: string;
creative_id?: string;
date_from?: string;
date_to?: string;
page?: number;
size?: number;
}
): Promise<PaginatedResponse<PlacementAnalyticsItem>> => { ): Promise<PaginatedResponse<PlacementAnalyticsItem>> => {
if (isDemoWorkspace(workspaceId)) { if (isDemoWorkspace(workspaceId)) {
let items = demoPlacementAnalytics; let items = demoPlacementAnalytics;
if (params?.project_id) { if (params?.project_id) {
items = items.filter((p) => p.project_id === params.project_id); items = items.filter((p) => p.project_id === params.project_id);
} }
if (params?.placement_channel_id) {
items = items.filter((p) => p.placement_channel_id === params.placement_channel_id);
}
if (params?.creative_id) {
items = items.filter((p) => p.creative_id === params.creative_id);
}
if (params?.date_from) {
const from = new Date(params.date_from);
items = items.filter((p) => new Date(p.placement_date) >= from);
}
if (params?.date_to) {
const to = new Date(params.date_to);
items = items.filter((p) => new Date(p.placement_date) <= to);
}
return paginate(items, params?.page, params?.size); return paginate(items, params?.page, params?.size);
} }
return analyticsApi.placements(workspaceId, params); return analyticsApi.placements(workspaceId, params);

View File

@@ -165,18 +165,18 @@ export const useDemoSpendingAnalytics = (
if (projectId) { if (projectId) {
// Filter by project // Filter by project
const projectPlacements = demoPlacements.filter( const projectPlacements = demoPlacements.filter(
(p) => p.project_id === projectId && p.status === "active" (p) => p.project_id === projectId && p.status !== "Отмена" && p.status !== "Неактуально"
); );
const totalCost = projectPlacements.reduce( const totalCost = projectPlacements.reduce(
(sum, p) => sum + (p.cost || 0), (sum, p) => sum + (p.details?.cost?.value || 0),
0 0
); );
const totalSubs = projectPlacements.reduce( const totalSubs = projectPlacements.reduce(
(sum, p) => sum + (p.subscriptions_count || 0), (sum, p) => sum + (p.placement_post?.subscriptions_count || 0),
0 0
); );
const totalViews = projectPlacements.reduce( const totalViews = projectPlacements.reduce(
(sum, p) => sum + (p.views_count || 0), (sum, p) => sum + (p.placement_post?.views_count || 0),
0 0
); );
@@ -188,6 +188,7 @@ export const useDemoSpendingAnalytics = (
avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null, avg_cpf: totalSubs > 0 ? totalCost / totalSubs : null,
avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, avg_cpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
chart_data: demoSpendingAnalytics.chart_data.slice(0, 3), chart_data: demoSpendingAnalytics.chart_data.slice(0, 3),
placements_count: 12,
}, },
isDemo: true, isDemo: true,
}; };
@@ -250,7 +251,7 @@ export const useDemoChannelAnalytics = (
const projectPlacementChannelIds = new Set( const projectPlacementChannelIds = new Set(
demoPlacements demoPlacements
.filter((p) => p.project_id === projectId) .filter((p) => p.project_id === projectId)
.map((p) => p.placement_channel_id) .map((p) => p.channel.id)
); );
items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id)); items = items.filter((a) => projectPlacementChannelIds.has(a.channel_id));
} }

View File

@@ -8,6 +8,7 @@ import type {
Channel, Channel,
Creative, Creative,
Placement, Placement,
PlacementChannel,
PurchasePlanChannel, PurchasePlanChannel,
WorkspaceMember, WorkspaceMember,
Permission, Permission,
@@ -187,139 +188,6 @@ export const demoCreatives: Creative[] = [
}, },
]; ];
// ============================================================================
// Placements
// ============================================================================
export const demoPlacements: Placement[] = [
{
id: "plac-0001-0000-0000-000000000001",
placement_date: "2024-12-20T12:00:00Z",
cost: 5500,
comment: "Отличное размещение",
ad_post_url: "https://t.me/tech_future/1234",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo1",
status: "active",
subscriptions_count: 156,
views_count: 8500,
views_availability: "available",
last_views_fetch_at: "2024-12-21T10:00:00Z",
created_at: "2024-12-19T15:00:00Z",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0001-0000-0000-000000000001",
placement_channel_title: "Технологии Будущего",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
},
{
id: "plac-0002-0000-0000-000000000002",
placement_date: "2024-12-18T14:00:00Z",
cost: 3200,
comment: null,
ad_post_url: "https://t.me/invest_pro/5678",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo2",
status: "active",
subscriptions_count: 89,
views_count: 4200,
views_availability: "available",
last_views_fetch_at: "2024-12-20T08:00:00Z",
created_at: "2024-12-17T10:00:00Z",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0002-0000-0000-000000000002",
placement_channel_title: "Инвестиции Pro",
creative_id: "crea-0001-0000-0000-000000000001",
creative_name: "Крипто - Основной",
},
{
id: "plac-0003-0000-0000-000000000003",
placement_date: "2024-12-15T10:00:00Z",
cost: 4800,
comment: "Программисты - хорошая аудитория",
ad_post_url: "https://t.me/programmers_hub/9012",
invite_link_type: "public",
invite_link: "https://t.me/+ABC123demo3",
status: "active",
subscriptions_count: 234,
views_count: 12000,
views_availability: "available",
last_views_fetch_at: "2024-12-19T14:00:00Z",
created_at: "2024-12-14T09:00:00Z",
project_id: "proj-0002-0000-0000-000000000002",
project_channel_title: "IT Вакансии",
placement_channel_id: "chan-0003-0000-0000-000000000003",
placement_channel_title: "Программисты",
creative_id: "crea-0003-0000-0000-000000000003",
creative_name: "IT Jobs - Основной",
},
{
id: "plac-0004-0000-0000-000000000004",
placement_date: "2024-12-10T16:00:00Z",
cost: 2500,
comment: null,
ad_post_url: "https://t.me/marketing_pro/3456",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo4",
status: "active",
subscriptions_count: 67,
views_count: 3100,
views_availability: "manual",
last_views_fetch_at: "2024-12-12T11:00:00Z",
created_at: "2024-12-09T14:00:00Z",
project_id: "proj-0003-0000-0000-000000000003",
project_channel_title: "Стартапы и Бизнес",
placement_channel_id: "chan-0004-0000-0000-000000000004",
placement_channel_title: "Маркетинг Pro",
creative_id: "crea-0004-0000-0000-000000000004",
creative_name: "Startups - Нетворкинг",
},
{
id: "plac-0005-0000-0000-000000000005",
placement_date: "2024-12-05T11:00:00Z",
cost: 6200,
comment: "Финансовая аудитория",
ad_post_url: "https://t.me/finance_edu/7890",
invite_link_type: "approval",
invite_link: "https://t.me/+ABC123demo5",
status: "active",
subscriptions_count: 312,
views_count: 15600,
views_availability: "available",
last_views_fetch_at: "2024-12-08T09:00:00Z",
created_at: "2024-12-04T10:00:00Z",
project_id: "proj-0001-0000-0000-000000000001",
project_channel_title: "Крипто Новости",
placement_channel_id: "chan-0005-0000-0000-000000000005",
placement_channel_title: "Финансовая грамотность",
creative_id: "crea-0002-0000-0000-000000000002",
creative_name: "Крипто - Агрессивный",
},
{
id: "plac-0006-0000-0000-000000000006",
placement_date: "2024-11-28T13:00:00Z",
cost: 1800,
comment: null,
ad_post_url: "https://t.me/startup_daily/2345",
invite_link_type: "public",
invite_link: "https://t.me/+ABC123demo6",
status: "archived",
subscriptions_count: 45,
views_count: 2100,
views_availability: "unavailable",
last_views_fetch_at: null,
created_at: "2024-11-27T15:00:00Z",
project_id: "proj-0003-0000-0000-000000000003",
project_channel_title: "Стартапы и Бизнес",
placement_channel_id: "chan-0006-0000-0000-000000000006",
placement_channel_title: "Startup Daily",
creative_id: "crea-0004-0000-0000-000000000004",
creative_name: "Startups - Нетворкинг",
},
];
// ============================================================================ // ============================================================================
// Purchase Plan Channels // Purchase Plan Channels
// ============================================================================ // ============================================================================
@@ -363,60 +231,204 @@ export const demoPurchasePlanChannels: Record<string, PurchasePlanChannel[]> = {
}, },
}, },
], ],
"proj-0002-0000-0000-000000000002": [
{
id: "plan-0004-0000-0000-000000000004",
status: "completed",
planned_cost: 4800,
comment: "IT аудитория",
channel: {
id: "chan-0003-0000-0000-000000000003",
telegram_id: -1002345678003,
title: "Программисты",
username: "programmers_hub",
},
},
{
id: "plan-0005-0000-0000-000000000005",
status: "planned",
planned_cost: 3000,
comment: null,
channel: {
id: "chan-0001-0000-0000-000000000001",
telegram_id: -1002345678001,
title: "Технологии Будущего",
username: "tech_future",
},
},
],
"proj-0003-0000-0000-000000000003": [
{
id: "plan-0006-0000-0000-000000000006",
status: "completed",
planned_cost: 2500,
comment: null,
channel: {
id: "chan-0004-0000-0000-000000000004",
telegram_id: -1002345678004,
title: "Маркетинг Pro",
username: "marketing_pro",
},
},
{
id: "plan-0007-0000-0000-000000000007",
status: "completed",
planned_cost: 1800,
comment: "Стартап аудитория",
channel: {
id: "chan-0006-0000-0000-000000000006",
telegram_id: -1002345678006,
title: "Startup Daily",
username: "startup_daily",
},
},
],
}; };
// ============================================================================
// Placements for Project (New Format)
// ============================================================================
export const demoPlacementChannels: PlacementChannel[] = [
{
id: "chan-0001-0000-0000-000000000001",
telegram_id: -1001234001001,
title: "Технологии Будущего",
username: "tech_future",
subscribers_count: 125000,
},
{
id: "chan-0002-0000-0000-000000000002",
telegram_id: -1001234001002,
title: "Инвестиции Pro",
username: "invest_pro",
subscribers_count: 89000,
},
{
id: "chan-0003-0000-0000-000000000003",
telegram_id: -1001234001003,
title: "Программисты",
username: "programmers",
subscribers_count: 230000,
},
{
id: "chan-0004-0000-0000-000000000004",
telegram_id: -1001234001004,
title: "Маркетинг Pro",
username: "marketing_pro",
subscribers_count: 67000,
},
];
export const demoPlacements: (Placement & { project_id: string })[] = [
{
id: "plac-0001-0000-0000-000000000001",
project_id: "proj-0001-0000-0000-000000000001",
status: "Оплачено",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "crypto_jan",
invite_link: "https://t.me/+abc123",
invite_link_type: "public",
channel: demoPlacementChannels[0],
details: {
placement_at: "2024-12-20T12:00:00Z",
payment_at: "2024-12-19T10:00:00Z",
cost: { type: "fixed", value: 5500 },
cost_before_bargain: 6000,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: {
subscriptions_count: 156,
views_count: 8500,
created_at: "2024-12-20T12:00:00Z",
post: {
id: "post-0001-0000-0000-000000000001",
message_id: 12345,
text: "Привет! Отличные новости...",
url: "https://t.me/tech_future/12345",
deleted_from_channel_at: null,
created_at: "2024-12-20T12:00:00Z",
updated_at: "2024-12-20T12:00:00Z",
},
},
created_at: "2024-12-18T08:00:00Z",
},
{
id: "plac-0002-0000-0000-000000000002",
project_id: "proj-0001-0000-0000-000000000001",
status: "Согласование условий",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "crypto_feb",
invite_link: "https://t.me/+def456",
invite_link_type: "approval",
channel: demoPlacementChannels[1],
details: {
placement_at: "2024-12-25T14:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 3200 },
cost_before_bargain: 3500,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: null,
created_at: "2024-12-22T09:00:00Z",
},
{
id: "plac-0003-0000-0000-000000000003",
project_id: "proj-0001-0000-0000-000000000001",
status: "Оплачено",
creative_id: "crea-0001-0000-0000-000000000001",
comment: null,
invite_link: "https://t.me/+ghi789",
invite_link_type: "public",
channel: demoPlacementChannels[2],
details: {
placement_at: "2024-12-15T10:00:00Z",
payment_at: "2024-12-14T16:00:00Z",
cost: { type: "fixed", value: 4800 },
cost_before_bargain: 5000,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: {
subscriptions_count: 234,
views_count: 12000,
created_at: "2024-12-15T10:00:00Z",
post: {
id: "post-0002-0000-0000-000000000002",
message_id: 67890,
text: "Криптовалюты продолжают расти...",
url: "https://t.me/programmers/67890",
deleted_from_channel_at: null,
created_at: "2024-12-15T10:00:00Z",
updated_at: "2024-12-15T10:00:00Z",
},
},
created_at: "2024-12-12T11:00:00Z",
},
{
id: "plac-0004-0000-0000-000000000004",
project_id: "proj-0001-0000-0000-000000000001",
status: "Ждём ответа",
creative_id: "crea-0001-0000-0000-000000000001",
comment: null,
invite_link: "https://t.me/+jkl012",
invite_link_type: "public",
channel: demoPlacementChannels[3],
details: {
placement_at: "2024-12-28T09:00:00Z",
payment_at: null,
cost: { type: "fixed", value: 2500 },
cost_before_bargain: 2800,
placement_type: "self_promo",
format: "Стандарт",
comment: null,
creative_id: "crea-0001-0000-0000-000000000001",
},
placement_post: null,
created_at: "2024-12-24T14:00:00Z",
},
{
id: "plac-0005-0000-0000-000000000005",
project_id: "proj-0002-0000-0000-000000000002",
status: "Написать",
creative_id: "crea-0002-0000-0000-000000000002",
comment: "Новый оффер",
invite_link: null,
invite_link_type: "approval",
channel: demoPlacementChannels[0],
details: {
placement_at: null,
payment_at: null,
cost: { type: "fixed", value: 6000 },
cost_before_bargain: null,
placement_type: "self_promo",
format: "Спонсорский",
comment: null,
creative_id: "crea-0002-0000-0000-000000000002",
},
placement_post: null,
created_at: "2024-12-26T08:00:00Z",
},
{
id: "plac-0006-0000-0000-000000000006",
project_id: "proj-0002-0000-0000-000000000002",
status: "Отмена",
creative_id: "crea-0001-0000-0000-000000000001",
comment: "Неактуально",
invite_link: null,
invite_link_type: "public",
channel: demoPlacementChannels[1],
details: {
placement_at: null,
payment_at: null,
cost: null,
cost_before_bargain: null,
placement_type: null,
format: null,
comment: "Клиент отказался",
creative_id: null,
},
placement_post: null,
created_at: "2024-12-10T10:00:00Z",
},
];
// ============================================================================ // ============================================================================
// Analytics Data // Analytics Data
// ============================================================================ // ============================================================================
@@ -427,6 +439,7 @@ export const demoSpendingAnalytics: SpendingAnalytics = {
total_views: 45500, total_views: 45500,
avg_cpf: 26.58, avg_cpf: 26.58,
avg_cpm: 527.47, avg_cpm: 527.47,
placements_count: 12,
chart_data: [ chart_data: [
{ {
period: "2024-11", period: "2024-11",
@@ -607,6 +620,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 8500, views_count: 8500,
cpf: 35.26, cpf: 35.26,
cpm: 647.06, cpm: 647.06,
post_url: "https://t.me/tech_future/1001",
}, },
{ {
id: "plac-0002-0000-0000-000000000002", id: "plac-0002-0000-0000-000000000002",
@@ -622,6 +636,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 4200, views_count: 4200,
cpf: 35.96, cpf: 35.96,
cpm: 761.9, cpm: 761.9,
post_url: "https://t.me/invest_pro/502",
}, },
{ {
id: "plac-0003-0000-0000-000000000003", id: "plac-0003-0000-0000-000000000003",
@@ -637,6 +652,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 12000, views_count: 12000,
cpf: 20.51, cpf: 20.51,
cpm: 400.0, cpm: 400.0,
post_url: "https://t.me/programmers_hub/2050",
}, },
{ {
id: "plac-0004-0000-0000-000000000004", id: "plac-0004-0000-0000-000000000004",
@@ -652,6 +668,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 3100, views_count: 3100,
cpf: 37.31, cpf: 37.31,
cpm: 806.45, cpm: 806.45,
post_url: "https://t.me/marketing_pro/803",
}, },
{ {
id: "plac-0005-0000-0000-000000000005", id: "plac-0005-0000-0000-000000000005",
@@ -667,6 +684,7 @@ export const demoPlacementAnalytics: PlacementAnalyticsItem[] = [
views_count: 15600, views_count: 15600,
cpf: 19.87, cpf: 19.87,
cpm: 397.44, cpm: 397.44,
post_url: "https://t.me/finance_edu/1204",
}, },
]; ];

View File

@@ -42,10 +42,12 @@ export interface PaginationParams {
export interface Workspace { export interface Workspace {
id: string; // UUID id: string; // UUID
name: string; name: string;
avatar_url?: string | null;
} }
export interface WorkspaceCreateRequest { export interface WorkspaceCreateRequest {
name: string; name: string;
avatar_file?: File | null;
} }
export interface WorkspaceUpdateRequest { export interface WorkspaceUpdateRequest {
@@ -215,7 +217,18 @@ export interface CreativesQueryParams extends PaginationParams {
// Placements // Placements
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
export type PlacementStatus = "active" | "archived"; export type PlacementStatus =
| "Без статуса"
| "Написать"
| "Ждём ответа"
| "Согласование условий"
| "Оплатить"
| "Оплачено"
| "Отмена"
| "Не подходит цена"
| "Неактуально"
| "Не отвечает";
export type InviteLinkType = "public" | "approval"; export type InviteLinkType = "public" | "approval";
export type PostViewsAvailability = export type PostViewsAvailability =
| "unknown" | "unknown"
@@ -223,26 +236,79 @@ export type PostViewsAvailability =
| "unavailable" | "unavailable"
| "manual"; | "manual";
export interface Placement { export interface PlacementChannel {
id: string; // UUID id: string;
project_id: string; telegram_id: number;
project_channel_title: string; title: string;
placement_channel_id: string; username: string | null;
placement_channel_title: string; subscribers_count?: number;
creative_id: string; }
creative_name: string;
placement_date: string; // ISO 8601 export interface PlacementCostInfo {
cost: number | null; type: "fixed" | "cpm";
value: number;
}
export interface PlacementDetails {
placement_at: string | null; // ISO 8601
payment_at: string | null; // ISO 8601
cost: PlacementCostInfo | null;
cost_before_bargain: number | null;
placement_type: string | null;
format: string | null;
comment: string | null; comment: string | null;
ad_post_url: string | null; creative_id: string | null;
invite_link_type: InviteLinkType; }
invite_link: string;
status: PlacementStatus; export interface PlacementPost {
subscriptions_count: number; subscriptions_count: number;
views_count?: number | null; views_count: number | null;
views_availability?: PostViewsAvailability;
last_views_fetch_at?: string | null;
created_at: string; created_at: string;
post: {
id: string;
message_id: number;
text: string;
url: string | null;
deleted_from_channel_at: string | null;
created_at: string;
updated_at: string;
} | null;
}
export interface Placement {
id: string;
status: PlacementStatus;
creative_id: string | null;
comment: string | null;
invite_link: string | null;
invite_link_type: InviteLinkType;
channel: PlacementChannel;
details: PlacementDetails | null;
placement_post: PlacementPost | null;
created_at: string;
}
export interface PlacementWithStats extends Placement {
cpf: number | null;
cpm: number | null;
}
export interface PlacementsGroupedByChannel {
channel: PlacementChannel;
placements: PlacementWithStats[];
total_cost: number;
total_subscriptions: number;
total_views: number;
}
export interface ProjectPlacementsSummary {
total_placements: number;
total_channels: number;
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
} }
export interface PlacementCreateRequest { export interface PlacementCreateRequest {
@@ -306,6 +372,15 @@ export interface PlacementAnalyticsItem {
views_count: number; views_count: number;
cpf: number; cpf: number;
cpm: number; cpm: number;
post_url?: string | null;
}
export interface PlacementsAnalyticsQueryParams extends PaginationParams {
project_id?: string;
placement_channel_id?: string;
creative_id?: string;
date_from?: string;
date_to?: string;
} }
// Creatives Analytics (matches actual API response) // Creatives Analytics (matches actual API response)
@@ -350,6 +425,7 @@ export interface SpendingAnalytics {
total_views: number; total_views: number;
avg_cpf: number | null; avg_cpf: number | null;
avg_cpm: number | null; avg_cpm: number | null;
placements_count: number;
} }
export interface AnalyticsQueryParams extends PaginationParams { export interface AnalyticsQueryParams extends PaginationParams {

View File

@@ -0,0 +1,53 @@
// ============================================================================
// Channel URL Parser Utility
// Parses various URL formats and usernames to extract channel username
// ============================================================================
export function parseChannelInput(input: string): string | null {
if (!input || typeof input !== "string") return null;
let cleaned = input.trim();
// Remove @ prefix if present
cleaned = cleaned.replace(/^@/, "");
// Patterns for different URL formats
const patterns: RegExp[] = [
/telemetr\.me\/content\/([a-zA-Z0-9_]+)/, // telemetr.me/content/rian_ru
/telemetr\.me\/([a-zA-Z0-9_]+)/, // telemetr.me/rian_ru (alternative)
/t\.me\/([a-zA-Z0-9_]+)/, // t.me/rian_ru or https://t.me/rian_ru
/tgstat\.ru\/channel\/@?([a-zA-Z0-9_]+)/, // tgstat.ru/channel/@rian_ru or tgstat.ru/channel/rian_ru
/tg\.me\/([a-zA-Z0-9_]+)/, // tg.me/rian_ru
];
for (const pattern of patterns) {
const match = cleaned.match(pattern);
if (match && match[1]) {
return match[1];
}
}
// If it's just a plain username (alphanumeric + underscore)
if (/^[a-zA-Z0-9_]+$/.test(cleaned)) {
return cleaned;
}
return null;
}
// Check if input is a valid channel identifier
export function isValidChannelInput(input: string): boolean {
return parseChannelInput(input) !== null;
}
// Get platform hint from URL (for analytics links)
export function getChannelAnalyticsUrls(username: string): {
telemetr: string;
tgstat: string;
} {
const cleanUsername = username.replace(/^@/, "");
return {
telemetr: `https://telemetr.me/channel/${cleanUsername}`,
tgstat: `https://tgstat.ru/channel/@${cleanUsername}`,
};
}

BIN
public/icons/telemetr.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/icons/tgstat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB