feat: global ui & functional update

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

View File

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

View File

@@ -13,7 +13,6 @@ import {
ExternalLink,
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>

View File

@@ -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"
>

View File

@@ -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

View File

@@ -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() {
</>
);
}

View File

@@ -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 || "Не удалось отправить приглашение");
}
}
};

View File

@@ -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">

View File

@@ -4,13 +4,14 @@
// Onboarding Page - First Workspace Creation
// ============================================================================
import { useState } from "react";
import { useState, useRef } from "react";
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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Card,
CardContent,
@@ -24,8 +25,37 @@ import { STORAGE_KEYS } from "@/lib/utils/constants";
export default function OnboardingPage() {
const router = useRouter();
const [name, setName] = useState("");
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
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 () => {
if (!name.trim()) return;
@@ -34,8 +64,8 @@ export default function OnboardingPage() {
setIsCreating(true);
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
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
@@ -62,6 +92,49 @@ export default function OnboardingPage() {
</CardHeader>
<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">
<Label htmlFor="workspace-name">Название воркспейса</Label>
<Input