feat: global ui & functional update
This commit is contained in:
517
app/dashboard/[workspaceId]/analytics/placements/page.tsx
Normal file
517
app/dashboard/[workspaceId]/analytics/placements/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 || "Не удалось отправить приглашение");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
199
components/dialog-add-channel.tsx
Normal file
199
components/dialog-add-channel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
454
components/dialog-create-placements.tsx
Normal file
454
components/dialog-create-placements.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
523
components/filters-modal.tsx
Normal file
523
components/filters-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
112
lib/types/api.ts
112
lib/types/api.ts
@@ -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 {
|
||||||
|
|||||||
53
lib/utils/channel-parser.ts
Normal file
53
lib/utils/channel-parser.ts
Normal 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
BIN
public/icons/telemetr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/tgstat.jpg
Normal file
BIN
public/icons/tgstat.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Reference in New Issue
Block a user