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,
|
||||
Copy,
|
||||
Check,
|
||||
Archive,
|
||||
Eye,
|
||||
Users,
|
||||
TrendingUp,
|
||||
@@ -30,17 +29,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import type { Placement } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
@@ -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 = () => {
|
||||
if (placement?.invite_link) {
|
||||
navigator.clipboard.writeText(placement.invite_link);
|
||||
@@ -159,14 +138,11 @@ export default function PlacementDetailPage() {
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
const cps =
|
||||
placement.cost && placement.subscriptions_count > 0
|
||||
? placement.cost / placement.subscriptions_count
|
||||
: null;
|
||||
const cpm =
|
||||
placement.cost && placement.views_count
|
||||
? (placement.cost / placement.views_count) * 1000
|
||||
: null;
|
||||
const cost = placement.details?.cost?.value;
|
||||
const subscriptionsCount = placement.placement_post?.subscriptions_count ?? 0;
|
||||
const viewsCount = placement.placement_post?.views_count ?? 0;
|
||||
const cps = cost && subscriptionsCount > 0 ? cost / subscriptionsCount : null;
|
||||
const cpm = cost && viewsCount > 0 ? (cost / viewsCount) * 1000 : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -174,7 +150,7 @@ export default function PlacementDetailPage() {
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Размещения", href: `/dashboard/${workspaceId}/placements` },
|
||||
{ label: placement.placement_channel_title },
|
||||
{ label: placement.channel.title },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -192,46 +168,21 @@ export default function PlacementDetailPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{placement.placement_channel_title}
|
||||
{placement.channel.title}
|
||||
</h1>
|
||||
<Badge
|
||||
variant={
|
||||
placement.status === "active" ? "default" : "secondary"
|
||||
placement.status === "Оплачено" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{placement.status === "active" ? "Активно" : "Архив"}
|
||||
{placement.status === "Оплачено" ? "Активно" : placement.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{formatDate(placement.placement_date)}
|
||||
{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && placement.status === "active" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
В архив
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Архивировать размещение?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Размещение будет перемещено в архив. Статистика сохранится.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleArchive}>
|
||||
Архивировать
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@@ -241,11 +192,11 @@ export default function PlacementDetailPage() {
|
||||
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(placement.cost)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(cost)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -253,14 +204,14 @@ export default function PlacementDetailPage() {
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(placement.subscriptions_count)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CPS: {cps ? formatCurrency(cps) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(subscriptionsCount)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CPS: {cps ? formatCurrency(cps) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -268,14 +219,14 @@ export default function PlacementDetailPage() {
|
||||
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(placement.views_count)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CPM: {cpm ? formatCurrency(cpm) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(viewsCount)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CPM: {cpm ? formatCurrency(cpm) : "—"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -308,24 +259,31 @@ export default function PlacementDetailPage() {
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm truncate">
|
||||
{placement.invite_link}
|
||||
{placement.invite_link || "—"}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" onClick={handleCopyLink}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyLink}
|
||||
disabled={!placement.invite_link}
|
||||
>
|
||||
{copiedLink ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href={placement.invite_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{placement.invite_link && (
|
||||
<Button variant="outline" size="icon" asChild>
|
||||
<a
|
||||
href={placement.invite_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -334,16 +292,18 @@ export default function PlacementDetailPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Креатив</CardTitle>
|
||||
<CardDescription>{placement.creative_name}</CardDescription>
|
||||
<CardDescription>ID: {placement.creative_id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
|
||||
>
|
||||
Просмотреть креатив
|
||||
</Link>
|
||||
</Button>
|
||||
{placement.creative_id && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${placement.creative_id}`}
|
||||
>
|
||||
Просмотреть креатив
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -357,57 +317,45 @@ export default function PlacementDetailPage() {
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Проект
|
||||
Канал размещения
|
||||
</dt>
|
||||
<dd className="text-sm">{placement.project_channel_title}</dd>
|
||||
<dd className="text-sm">{placement.channel.title}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Дата размещения
|
||||
</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>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Статус просмотров
|
||||
Дата оплаты
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<Badge variant="outline">
|
||||
{placement.views_availability === "available"
|
||||
? "Автообновление"
|
||||
: placement.views_availability === "manual"
|
||||
? "Ручной ввод"
|
||||
: placement.views_availability === "unavailable"
|
||||
? "Недоступно"
|
||||
: "Не проверено"}
|
||||
</Badge>
|
||||
</dd>
|
||||
<dd className="text-sm">{placement.details?.payment_at ? formatDate(placement.details.payment_at) : "—"}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Последнее обновление просмотров
|
||||
Формат
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
{formatDateTime(placement.last_views_fetch_at)}
|
||||
</dd>
|
||||
<dd className="text-sm">{placement.details?.format || "—"}</dd>
|
||||
</div>
|
||||
|
||||
{placement.ad_post_url && (
|
||||
{placement.placement_post?.post?.url && (
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-sm font-medium text-muted-foreground">
|
||||
Ссылка на пост
|
||||
</dt>
|
||||
<dd className="text-sm">
|
||||
<a
|
||||
href={placement.ad_post_url}
|
||||
href={placement.placement_post.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
@@ -157,17 +157,17 @@ const exportToCSV = (placements: Placement[], filename: string) => {
|
||||
];
|
||||
|
||||
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.cost?.toString() || "",
|
||||
p.subscriptions_count.toString(),
|
||||
p.views_count?.toString() || "",
|
||||
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
|
||||
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
|
||||
"—", // creative_name not available in new structure
|
||||
p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "",
|
||||
p.details?.cost?.value?.toString() || "",
|
||||
p.placement_post?.subscriptions_count.toString() || "0",
|
||||
p.placement_post?.views_count?.toString() || "",
|
||||
calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "",
|
||||
calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "",
|
||||
p.status,
|
||||
p.ad_post_url || "",
|
||||
p.placement_post?.post?.url || "",
|
||||
p.invite_link,
|
||||
]);
|
||||
|
||||
@@ -192,15 +192,15 @@ const exportToExcel = (placements: Placement[], filename: string) => {
|
||||
];
|
||||
|
||||
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.cost?.toString() || "",
|
||||
p.subscriptions_count.toString(),
|
||||
p.views_count?.toString() || "",
|
||||
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
|
||||
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
|
||||
"—",
|
||||
p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "",
|
||||
p.details?.cost?.value?.toString() || "",
|
||||
p.placement_post?.subscriptions_count.toString() || "0",
|
||||
p.placement_post?.views_count?.toString() || "",
|
||||
calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "",
|
||||
calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "",
|
||||
p.status,
|
||||
]);
|
||||
|
||||
@@ -213,18 +213,21 @@ const exportToExcel = (placements: Placement[], filename: string) => {
|
||||
|
||||
const exportToTxt = (placements: Placement[], filename: string) => {
|
||||
const lines = placements.map((p) => {
|
||||
const cps = calculateCPS(p.cost, p.subscriptions_count);
|
||||
const cpm = calculateCPM(p.cost, p.views_count);
|
||||
const cost = p.details?.cost?.value ?? null;
|
||||
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 [
|
||||
`Канал: ${p.placement_channel_title}`,
|
||||
`Креатив: ${p.creative_name}`,
|
||||
`Дата: ${formatDate(p.placement_date)}`,
|
||||
`Стоимость: ${formatCurrency(p.cost)}`,
|
||||
`Подписки: ${p.subscriptions_count}`,
|
||||
`Просмотры: ${p.views_count || "—"}`,
|
||||
`Канал: ${p.channel.title}`,
|
||||
`Креатив: —`,
|
||||
`Дата: ${p.details?.placement_at ? formatDate(p.details.placement_at) : "—"}`,
|
||||
`Стоимость: ${formatCurrency(cost)}`,
|
||||
`Подписки: ${p.placement_post?.subscriptions_count ?? "—"}`,
|
||||
`Просмотры: ${p.placement_post?.views_count || "—"}`,
|
||||
`CPS: ${cps ? formatCurrency(cps) : "—"}`,
|
||||
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
|
||||
`Ссылка: ${p.invite_link}`,
|
||||
`Ссылка: ${p.invite_link || "—"}`,
|
||||
"---",
|
||||
].join("\n");
|
||||
});
|
||||
@@ -320,8 +323,8 @@ export default function PlacementsPage() {
|
||||
return placements
|
||||
.filter(
|
||||
(p) =>
|
||||
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.creative_name.toLowerCase().includes(search.toLowerCase())
|
||||
p.channel.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.creative_id && p.creative_id.toString().toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (!sortField || !sortOrder) return 0;
|
||||
@@ -329,30 +332,39 @@ export default function PlacementsPage() {
|
||||
let aVal: 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) {
|
||||
case "date":
|
||||
const aDate = a.details?.placement_at || "";
|
||||
const bDate = b.details?.placement_at || "";
|
||||
return sortOrder === "asc"
|
||||
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime()
|
||||
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime();
|
||||
? aDate.localeCompare(bDate)
|
||||
: bDate.localeCompare(aDate);
|
||||
case "cost":
|
||||
aVal = a.cost ?? 0;
|
||||
bVal = b.cost ?? 0;
|
||||
aVal = aCost ?? 0;
|
||||
bVal = bCost ?? 0;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.subscriptions_count;
|
||||
bVal = b.subscriptions_count;
|
||||
aVal = aSubs;
|
||||
bVal = bSubs;
|
||||
break;
|
||||
case "views":
|
||||
aVal = a.views_count ?? 0;
|
||||
bVal = b.views_count ?? 0;
|
||||
aVal = aViews ?? 0;
|
||||
bVal = bViews ?? 0;
|
||||
break;
|
||||
case "cps":
|
||||
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0;
|
||||
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0;
|
||||
aVal = calculateCPS(aCost, aSubs) ?? 0;
|
||||
bVal = calculateCPS(bCost, bSubs) ?? 0;
|
||||
break;
|
||||
case "cpm":
|
||||
aVal = calculateCPM(a.cost, a.views_count) ?? 0;
|
||||
bVal = calculateCPM(b.cost, b.views_count) ?? 0;
|
||||
aVal = calculateCPM(aCost, aViews) ?? 0;
|
||||
bVal = calculateCPM(bCost, bViews) ?? 0;
|
||||
break;
|
||||
case "status":
|
||||
aVal = a.status;
|
||||
@@ -370,14 +382,14 @@ export default function PlacementsPage() {
|
||||
|
||||
// Calculate totals with aggregation functions
|
||||
const totals = useMemo(() => {
|
||||
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null);
|
||||
const subs = filteredPlacements.map((p) => p.subscriptions_count);
|
||||
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null);
|
||||
const costs = filteredPlacements.map((p) => p.details?.cost?.value).filter((c): c is number => c !== null);
|
||||
const subs = filteredPlacements.map((p) => p.placement_post?.subscriptions_count ?? 0);
|
||||
const views = filteredPlacements.map((p) => p.placement_post?.views_count).filter((v): v is number => v !== null);
|
||||
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);
|
||||
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);
|
||||
|
||||
return {
|
||||
@@ -639,28 +651,31 @@ export default function PlacementsPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPlacements.map((placement) => {
|
||||
const cps = calculateCPS(placement.cost, placement.subscriptions_count);
|
||||
const cpm = calculateCPM(placement.cost, placement.views_count);
|
||||
const cost = placement.details?.cost?.value ?? null;
|
||||
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 (
|
||||
<TableRow key={placement.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{placement.placement_channel_title}</div>
|
||||
<div className="font-medium">{placement.channel.title}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{placement.creative_name}</div>
|
||||
<div className="text-sm">—</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(placement.placement_date)}</TableCell>
|
||||
<TableCell>{formatCurrency(placement.cost)}</TableCell>
|
||||
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell>
|
||||
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell>
|
||||
<TableCell>{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}</TableCell>
|
||||
<TableCell>{formatCurrency(cost)}</TableCell>
|
||||
<TableCell>{formatNumber(subscriptions)}</TableCell>
|
||||
<TableCell>{formatNumber(views)}</TableCell>
|
||||
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
|
||||
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={placement.status === "active" ? "default" : "secondary"}
|
||||
variant={placement.status === "Оплачено" ? "default" : "secondary"}
|
||||
>
|
||||
{placement.status === "active" ? "Активен" : "Архив"}
|
||||
{placement.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -677,10 +692,10 @@ export default function PlacementsPage() {
|
||||
Подробнее
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{placement.ad_post_url && (
|
||||
{placement.placement_post?.post?.url && (
|
||||
<DropdownMenuItem asChild>
|
||||
<a
|
||||
href={placement.ad_post_url}
|
||||
href={placement.placement_post.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -16,13 +16,18 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
Eye,
|
||||
ShoppingCart,
|
||||
Info,
|
||||
Bot,
|
||||
Settings,
|
||||
Folder,
|
||||
FolderKanban,
|
||||
Archive,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Zap,
|
||||
Target,
|
||||
CircleDollarSign,
|
||||
CircleQuestionMark,
|
||||
ArrowRightFromLine,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
@@ -80,6 +85,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||
import type { Project, SpendingAnalytics } from "@/lib/types/api";
|
||||
|
||||
@@ -93,6 +99,8 @@ interface ProjectWithStats extends Project {
|
||||
total_subscriptions: number;
|
||||
total_views: number;
|
||||
avg_cpf: number | null;
|
||||
avg_cpm: number | null;
|
||||
placements_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +123,19 @@ const formatCurrency = (value: number | null | undefined): string => {
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatStatus = (status: string): string => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "Активен";
|
||||
case "inactive":
|
||||
return "Неактивен";
|
||||
case "archived":
|
||||
return "В архиве";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
@@ -122,7 +143,7 @@ const formatCurrency = (value: number | null | undefined): string => {
|
||||
export default function ProjectsPage() {
|
||||
const params = useParams();
|
||||
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 [loadingStats, setLoadingStats] = useState(false);
|
||||
@@ -131,9 +152,18 @@ export default function ProjectsPage() {
|
||||
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
|
||||
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
|
||||
const [isArchiving, setIsArchiving] = useState(false);
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
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 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 () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
@@ -202,8 +248,67 @@ export default function ProjectsPage() {
|
||||
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) => {
|
||||
// Telegram channel avatar URL format
|
||||
if (project.username) {
|
||||
return `https://t.me/i/userpic/320/${project.username}.jpg`;
|
||||
}
|
||||
@@ -235,6 +340,11 @@ export default function ProjectsPage() {
|
||||
spending.total_subscriptions > 0
|
||||
? spending.total_cost / spending.total_subscriptions
|
||||
: null,
|
||||
avg_cpm:
|
||||
spending.total_views > 0
|
||||
? (spending.total_cost / spending.total_views) * 1000
|
||||
: null,
|
||||
placements_count: spending.placements_count || 0,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
@@ -275,27 +385,37 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Плиточный вид"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "table" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("table")}
|
||||
aria-label="Табличный вид"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
aria-label="Плиточный вид"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Плиточный вид</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<span>
|
||||
Чтобы добавить новый проект, добавьте бота{" "}
|
||||
@@ -334,7 +454,7 @@ export default function ProjectsPage() {
|
||||
) : projectsWithStats.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Tv className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
Добавьте бота администратором в ваш Telegram канал, и он
|
||||
@@ -343,7 +463,6 @@ export default function ProjectsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === "table" ? (
|
||||
// Table View
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -365,7 +484,7 @@ export default function ProjectsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Привлечено подписчиков
|
||||
Привлечено
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
@@ -378,7 +497,7 @@ export default function ProjectsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Суммарный охват
|
||||
Охват
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
@@ -389,19 +508,7 @@ export default function ProjectsPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<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="text-right">CPF</TableHead>
|
||||
<TableHead className="w-[200px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -413,7 +520,7 @@ export default function ProjectsPage() {
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
|
||||
<AvatarFallback className="rounded-lg bg-primary/10">
|
||||
<Tv className="h-4 w-4 text-primary" />
|
||||
<FolderKanban className="h-4 w-4 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
@@ -435,25 +542,38 @@ export default function ProjectsPage() {
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
project.status === "archived" && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{project.status === "active"
|
||||
? "Активен"
|
||||
: project.status === "inactive"
|
||||
? "Неактивен"
|
||||
: "Архив"}
|
||||
{formatStatus(project.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatNumber(project.subscribers_count)}
|
||||
</TableCell>
|
||||
<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 className="text-right">
|
||||
{formatNumber(project.stats?.total_views)}
|
||||
</TableCell>
|
||||
<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>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
@@ -461,14 +581,15 @@ export default function ProjectsPage() {
|
||||
<Link
|
||||
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
|
||||
>
|
||||
План закупов
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
Размещения
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
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>
|
||||
</Button>
|
||||
@@ -477,8 +598,7 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
onClick={() => handleOpenSettings(project)}
|
||||
>
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Настройки
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -488,31 +608,30 @@ export default function ProjectsPage() {
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
// Grid View
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projectsWithStats.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<Card key={project.id} className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
|
||||
<AvatarFallback className="rounded-lg bg-primary/10">
|
||||
<Tv className="h-5 w-5 text-primary" />
|
||||
<FolderKanban className="h-5 w-5 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-base">{project.title}</CardTitle>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base truncate">{project.title}</CardTitle>
|
||||
{project.username && (
|
||||
<CardDescription>
|
||||
<a
|
||||
href={`https://t.me/${project.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline inline-flex items-center gap-0.5"
|
||||
className="hover:underline inline-flex items-center gap-0.5 truncate"
|
||||
>
|
||||
@{project.username}
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
|
||||
</a>
|
||||
</CardDescription>
|
||||
)}
|
||||
@@ -520,85 +639,88 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<Badge
|
||||
variant={project.status === "active" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
project.status === "archived" && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{project.status === "active"
|
||||
? "Активен"
|
||||
: project.status === "inactive"
|
||||
? "Неактивен"
|
||||
: "Архив"}
|
||||
{formatStatus(project.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{project.subscribers_count !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(project.subscribers_count)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Подписчиков</span>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
{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 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>
|
||||
)}
|
||||
{project.stats && (
|
||||
<>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(project.stats.total_subscriptions)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Привлечено подписчиков
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
|
||||
<CircleDollarSign className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(project.stats.total_views)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Суммарный охват
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
<div className="text-xs text-muted-foreground">CPF</div>
|
||||
<div className="font-semibold">
|
||||
{formatCurrency(project.stats.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
CPF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{project.stats.placements_count > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.stats.placements_count} размещ.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link
|
||||
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
|
||||
>
|
||||
План закупов
|
||||
<Zap className="h-3.5 w-3.5 mr-1.5" />
|
||||
Размещения
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
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>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -606,8 +728,7 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
onClick={() => handleOpenSettings(project)}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Настройки
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -616,7 +737,6 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Dialog */}
|
||||
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
@@ -641,45 +761,71 @@ export default function ProjectsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта
|
||||
Тип пригласительной ссылки по умолчанию при создании размещений
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-destructive">Опасная зона</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{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>
|
||||
)}
|
||||
<h4 className="text-sm font-semibold">Действия</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{settingsProject?.status === "archived" ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenDeleteDialog}
|
||||
variant="outline"
|
||||
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}
|
||||
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>
|
||||
</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>
|
||||
@@ -688,11 +834,11 @@ export default function ProjectsPage() {
|
||||
variant="outline"
|
||||
onClick={() => setSettingsProject(null)}
|
||||
>
|
||||
Отмена
|
||||
Закрыть
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isUpdatingSettings || isArchiving || isDeleting}
|
||||
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
|
||||
>
|
||||
{isUpdatingSettings ? (
|
||||
<>
|
||||
@@ -707,7 +853,6 @@ export default function ProjectsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -747,6 +892,89 @@ export default function ProjectsPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,28 @@
|
||||
"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 Link from "next/link";
|
||||
import {
|
||||
Loader2,
|
||||
ClipboardList,
|
||||
FolderKanban,
|
||||
ChevronRight,
|
||||
Tv,
|
||||
Calendar,
|
||||
Users,
|
||||
Eye,
|
||||
DollarSign,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Target,
|
||||
TrendingUp,
|
||||
CircleDollarSign,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -30,14 +32,20 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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
|
||||
// ============================================================================
|
||||
|
||||
const formatCurrency = (value: number | null) => {
|
||||
if (value === null) return "—";
|
||||
const formatNumber = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU").format(value);
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
@@ -46,13 +54,12 @@ const formatCurrency = (value: number | null) => {
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
interface ProjectPlanStats {
|
||||
totalChannels: number;
|
||||
plannedChannels: number;
|
||||
completedChannels: number;
|
||||
cancelledChannels: number;
|
||||
totalPlannedCost: number;
|
||||
}
|
||||
const formatCompact = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
||||
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -62,53 +69,57 @@ export default function PurchasePlansPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
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 { isDemoMode } = useWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length > 0) {
|
||||
loadAllStats();
|
||||
loadAllPlacements();
|
||||
} else {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
}, [projects, workspaceId]);
|
||||
|
||||
const loadAllStats = async () => {
|
||||
const loadAllPlacements = async () => {
|
||||
setLoadingStats(true);
|
||||
const stats: Record<string, ProjectPlanStats> = {};
|
||||
const placementsMap: Record<string, Placement[]> = {};
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const response = await demoAwarePurchasePlanApi.list(workspaceId, project.id, { size: 100 });
|
||||
const channels = response.items;
|
||||
|
||||
stats[project.id] = {
|
||||
totalChannels: channels.length,
|
||||
plannedChannels: channels.filter((c) => c.status === "planned").length,
|
||||
completedChannels: channels.filter((c) => c.status === "completed").length,
|
||||
cancelledChannels: channels.filter((c) => c.status === "cancelled").length,
|
||||
totalPlannedCost: channels
|
||||
.filter((c) => c.status === "planned")
|
||||
.reduce((sum, c) => sum + (c.planned_cost || 0), 0),
|
||||
};
|
||||
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
|
||||
const placements = await api.getByProject(workspaceId, project.id);
|
||||
placementsMap[project.id] = placements;
|
||||
} catch (err) {
|
||||
console.error(`Failed to load plan for project ${project.id}:`, err);
|
||||
stats[project.id] = {
|
||||
totalChannels: 0,
|
||||
plannedChannels: 0,
|
||||
completedChannels: 0,
|
||||
cancelledChannels: 0,
|
||||
totalPlannedCost: 0,
|
||||
};
|
||||
console.error(`Failed to load placements for project ${project.id}:`, err);
|
||||
placementsMap[project.id] = [];
|
||||
}
|
||||
}
|
||||
|
||||
setProjectStats(stats);
|
||||
setPlacementsByProject(placementsMap);
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -116,16 +127,16 @@ export default function PurchasePlansPage() {
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: `/dashboard/${workspaceId}` },
|
||||
{ label: "Планы закупов" },
|
||||
{ label: "Размещения" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Планы закупов</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Размещения</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Управление планами закупов по проектам
|
||||
Статистика по всем размещениям ваших проектов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,10 +148,10 @@ export default function PurchasePlansPage() {
|
||||
) : projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<ClipboardList className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
Добавьте проект через Telegram бота для создания плана закупов
|
||||
Добавьте проект через Telegram бота для отслеживания размещений
|
||||
</p>
|
||||
<Button asChild>
|
||||
<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">
|
||||
{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 (
|
||||
<Card
|
||||
key={project.id}
|
||||
<Card key={project.id}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${workspaceId}/purchase-plans/${project.id}`)
|
||||
@@ -165,9 +179,12 @@ export default function PurchasePlansPage() {
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Tv className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={avatarUrl || undefined} alt={project.title} />
|
||||
<AvatarFallback className="rounded-lg bg-primary/10">
|
||||
<FolderKanban className="h-5 w-5 text-primary" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{project.title}</CardTitle>
|
||||
{project.username && (
|
||||
@@ -179,46 +196,62 @@ export default function PurchasePlansPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats ? (
|
||||
{summary.total_placements > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Всего каналов:</span>
|
||||
<span className="font-medium">{stats.totalChannels}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{stats.plannedChannels > 0 && (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{stats.plannedChannels} план.
|
||||
</Badge>
|
||||
)}
|
||||
{stats.completedChannels > 0 && (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
{stats.completedChannels} заверш.
|
||||
</Badge>
|
||||
)}
|
||||
{stats.cancelledChannels > 0 && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<XCircle className="h-3 w-3" />
|
||||
{stats.cancelledChannels} отмен.
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats.totalPlannedCost > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm pt-2 border-t">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">План. бюджет:</span>
|
||||
<span className="font-medium ml-auto">
|
||||
{formatCurrency(stats.totalPlannedCost)}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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="font-semibold">{formatNumber(summary.total_placements)}</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">
|
||||
<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 className="text-sm text-muted-foreground">Загрузка...</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Нет размещений
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -230,4 +263,3 @@ export default function PurchasePlansPage() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,12 +73,12 @@ export default function InvitesPage() {
|
||||
const handleCreateInvite = async () => {
|
||||
if (!username.trim()) return;
|
||||
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
// Remove @ if present
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
@@ -87,10 +87,13 @@ export default function InvitesPage() {
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowDialog(false);
|
||||
setUsername("");
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Не удалось отправить приглашение");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.message || err?.detail;
|
||||
if (errorMessage?.includes("not found")) {
|
||||
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
|
||||
} else {
|
||||
setError(errorMessage || "Не удалось отправить приглашение");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Workspace Settings Page
|
||||
// ============================================================================
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
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 { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -97,6 +98,12 @@ export default function SettingsPage() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState("");
|
||||
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
|
||||
const [members, setMembers] = useState<WorkspaceMember[]>([]);
|
||||
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 () => {
|
||||
if (!username.trim()) return;
|
||||
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
});
|
||||
@@ -163,10 +201,13 @@ export default function SettingsPage() {
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowInviteDialog(false);
|
||||
setUsername("");
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Не удалось отправить приглашение");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.message || err?.detail;
|
||||
if (errorMessage?.includes("not found")) {
|
||||
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
|
||||
} else {
|
||||
setError(errorMessage || "Не удалось отправить приглашение");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,10 +309,79 @@ export default function SettingsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>Основные настройки</CardTitle>
|
||||
<CardDescription>
|
||||
Название и основные параметры воркспейса
|
||||
Название и аватар воркспейса
|
||||
</CardDescription>
|
||||
</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">
|
||||
<Label htmlFor="workspace-name">Название</Label>
|
||||
<div className="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user