feat: update permissions

This commit is contained in:
ivannoskov
2026-02-10 17:41:11 +03:00
parent e1117e46c8
commit f0574db79f
7 changed files with 335 additions and 140 deletions

View File

@@ -12,6 +12,7 @@ import {
ArrowUpDown, ArrowUpDown,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
Lock,
} from "lucide-react"; } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
@@ -74,14 +75,41 @@ type SortOrder = "asc" | "desc" | null;
export default function ChannelsAnalyticsPage() { export default function ChannelsAnalyticsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { getProjectFilterId } = useWorkspace(); const { getProjectFilterId, canViewSubscriptions, hasAnyAnalyticsPermission } = useWorkspace();
const projectFilterId = getProjectFilterId(); const projectFilterId = getProjectFilterId();
const canViewSubs = canViewSubscriptions;
const hasPermission = hasAnyAnalyticsPermission;
const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]); const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sortField, setSortField] = useState<SortField | null>("cost"); const [sortField, setSortField] = useState<SortField | null>("cost");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc"); const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
if (!hasPermission) {
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">
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<Lock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Доступ ограничен</h3>
<p className="text-muted-foreground text-center">
У вас нет разрешения на просмотр аналитики. Обратитесь к администратору рабочего пространства.
</p>
</CardContent>
</Card>
</div>
</>
);
}
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [workspaceId, projectFilterId]); }, [workspaceId, projectFilterId]);
@@ -250,17 +278,19 @@ export default function ChannelsAnalyticsPage() {
{getSortIcon("views")} {getSortIcon("views")}
</Button> </Button>
</TableHead> </TableHead>
<TableHead> {canViewSubs && (
<Button <TableHead>
variant="ghost" <Button
size="sm" variant="ghost"
className="-ml-3 h-8" size="sm"
onClick={() => handleSort("cps")} className="-ml-3 h-8"
> onClick={() => handleSort("cps")}
Ср. CPS >
{getSortIcon("cps")} Ср. CPS
</Button> {getSortIcon("cps")}
</TableHead> </Button>
</TableHead>
)}
<TableHead> <TableHead>
<Button <Button
variant="ghost" variant="ghost"
@@ -287,11 +317,11 @@ export default function ChannelsAnalyticsPage() {
</TableCell> </TableCell>
<TableCell>{channel.placements_count}</TableCell> <TableCell>{channel.placements_count}</TableCell>
<TableCell>{formatCurrency(channel.total_cost)}</TableCell> <TableCell>{formatCurrency(channel.total_cost)}</TableCell>
<TableCell> {canViewSubs && (
{formatNumber(channel.total_subscriptions)} <TableCell>
</TableCell> {formatCurrency(channel.avg_cps)}
<TableCell>{formatNumber(channel.total_views)}</TableCell> </TableCell>
<TableCell>{formatCurrency(channel.avg_cps)}</TableCell> )}
<TableCell>{formatCurrency(channel.avg_cpm)}</TableCell> <TableCell>{formatCurrency(channel.avg_cpm)}</TableCell>
</TableRow> </TableRow>
))} ))}

View File

@@ -7,10 +7,40 @@
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Lock } from "lucide-react";
import { useWorkspace } from "@/components/providers/workspace-provider";
export default function CreativesAnalyticsPage() { export default function CreativesAnalyticsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { hasAnyAnalyticsPermission } = useWorkspace();
const hasPermission = hasAnyAnalyticsPermission;
if (!hasPermission) {
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">
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<Lock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Доступ ограничен</h3>
<p className="text-muted-foreground text-center">
У вас нет разрешения на просмотр аналитики. Обратитесь к администратору рабочего пространства.
</p>
</CardContent>
</Card>
</div>
</>
);
}
return ( return (
<> <>

View File

@@ -136,7 +136,31 @@ const formatDate = (dateStr: string | undefined) => {
export default function PlacementsAnalyticsPage() { export default function PlacementsAnalyticsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects } = useWorkspace(); const { projects, hasAnyAnalyticsPermission } = useWorkspace();
if (!hasAnyAnalyticsPermission) {
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">
<Card className="flex flex-col items-center justify-center py-16">
<CardContent className="text-center">
<h2 className="text-xl font-semibold mb-2">Доступ запрещён</h2>
<p className="text-muted-foreground">
У вас нет прав для просмотра аналитики. Обратитесь к администратору workspace.
</p>
</CardContent>
</Card>
</div>
</>
);
}
const [placements, setPlacements] = useState<PlacementAnalyticsItem[]>([]); const [placements, setPlacements] = useState<PlacementAnalyticsItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -761,7 +761,31 @@ const SortablePanel: React.FC<SortablePanelProps> = ({
export default function ProjectsAnalyticsPage() { export default function ProjectsAnalyticsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects } = useWorkspace(); const { projects, hasAnyAnalyticsPermission } = useWorkspace();
if (!hasAnyAnalyticsPermission) {
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">
<Card className="flex flex-col items-center justify-center py-16">
<CardContent className="text-center">
<h2 className="text-xl font-semibold mb-2">Доступ запрещён</h2>
<p className="text-muted-foreground">
У вас нет прав для просмотра аналитики. Обратитесь к администратору workspace.
</p>
</CardContent>
</Card>
</div>
</>
);
}
const [panels, setPanels] = useState<ChartPanelConfig[]>([]); const [panels, setPanels] = useState<ChartPanelConfig[]>([]);
const [tableData, setTableData] = useState<ProjectsAnalyticsResponse | null>(null); const [tableData, setTableData] = useState<ProjectsAnalyticsResponse | null>(null);

View File

@@ -6,7 +6,7 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info } from "lucide-react"; import { Loader2, TrendingUp, BarChart3, LineChart as LineChartIcon, Info, Lock } from "lucide-react";
import { import {
LineChart, LineChart,
Line, Line,
@@ -132,17 +132,47 @@ const chartConfig: ChartConfig = {
export default function SpendingAnalyticsPage() { export default function SpendingAnalyticsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace(); const { selectedProjects, getProjectFilterId, allProjectsSelected, canViewSubscriptions, hasAnyAnalyticsPermission } = useWorkspace();
const projectFilterId = getProjectFilterId(); const projectFilterId = getProjectFilterId();
const canViewSubs = canViewSubscriptions;
const hasPermission = hasAnyAnalyticsPermission;
const [spending, setSpending] = useState<SpendingAnalytics | null>(null); const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [grouping, setGrouping] = useState<DateGrouping>("month"); const [grouping, setGrouping] = useState<DateGrouping>("month");
if (!hasPermission) {
return (
<TooltipProvider>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Аналитика", href: `/dashboard/${workspaceId}/analytics` },
{ label: "Расходы" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<Lock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Доступ ограничен</h3>
<p className="text-muted-foreground text-center">
У вас нет разрешения на просмотр аналитики. Обратитесь к администратору рабочего пространства.
</p>
</CardContent>
</Card>
</div>
</TooltipProvider>
);
}
// Chart settings // Chart settings
const [chartType, setChartType] = useState<ChartType>("line"); const [chartType, setChartType] = useState<ChartType>("line");
const [showLabels, setShowLabels] = useState(false); const [showLabels, setShowLabels] = useState(false);
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]); const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(
canViewSubs ? ["cost", "subscriptions"] : ["cost"]
);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -320,16 +350,18 @@ export default function SpendingAnalyticsPage() {
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div> <div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
</CardContent> </CardContent>
</Card> </Card>
<Card> {canViewSubs && (
<CardHeader className="pb-2"> <Card>
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle> <CardHeader className="pb-2">
</CardHeader> <CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
<CardContent> </CardHeader>
<div className="text-2xl font-bold"> <CardContent>
{formatNumber(spending.total_subscriptions)} <div className="text-2xl font-bold">
</div> {formatNumber(spending.total_subscriptions)}
</CardContent> </div>
</Card> </CardContent>
</Card>
)}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Просмотров</CardTitle> <CardTitle className="text-sm font-medium">Просмотров</CardTitle>
@@ -395,29 +427,48 @@ export default function SpendingAnalyticsPage() {
</div> </div>
</div> </div>
{/* Metrics Selection */} {/* Metrics Selection */}
<div className="flex items-center gap-4 mt-3 pt-3 border-t"> <div className="flex items-center gap-4 mt-3 pt-3 border-t">
<span className="text-sm text-muted-foreground">Метрики:</span> <span className="text-sm text-muted-foreground">Метрики:</span>
{(["cost", "subscriptions", "views"] as MetricKey[]).map((metric) => ( {(["cost", "views"] as MetricKey[]).map((metric) => (
<div key={metric} className="flex items-center gap-2"> <div key={metric} className="flex items-center gap-2">
<Checkbox <Checkbox
id={`metric-${metric}`} id={`metric-${metric}`}
checked={selectedMetrics.includes(metric)} checked={selectedMetrics.includes(metric)}
onCheckedChange={() => handleToggleMetric(metric)} onCheckedChange={() => handleToggleMetric(metric)}
/>
<Label
htmlFor={`metric-${metric}`}
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: `var(--color-${metric})` }}
/> />
{chartConfig[metric].label} <Label
</Label> htmlFor={`metric-${metric}`}
</div> className="text-sm cursor-pointer flex items-center gap-1.5"
))} >
</div> <div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: `var(--color-${metric})` }}
/>
{chartConfig[metric].label}
</Label>
</div>
))}
{canViewSubs && (["subscriptions"] as MetricKey[]).map((metric) => (
<div key={metric} className="flex items-center gap-2">
<Checkbox
id={`metric-${metric}`}
checked={selectedMetrics.includes(metric)}
onCheckedChange={() => handleToggleMetric(metric)}
/>
<Label
htmlFor={`metric-${metric}`}
className="text-sm cursor-pointer flex items-center gap-1.5"
>
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: `var(--color-${metric})` }}
/>
{chartConfig[metric].label}
</Label>
</div>
))}
</div>
</CardHeader> </CardHeader>
<CardContent className="px-2 sm:p-6">{renderChart()}</CardContent> <CardContent className="px-2 sm:p-6">{renderChart()}</CardContent>
</Card> </Card>
@@ -435,19 +486,21 @@ export default function SpendingAnalyticsPage() {
<TableRow> <TableRow>
<TableHead>Период</TableHead> <TableHead>Период</TableHead>
<TableHead className="text-right">Расходы</TableHead> <TableHead className="text-right">Расходы</TableHead>
<TableHead className="text-right">Подписчики</TableHead> {canViewSubs && <TableHead className="text-right">Подписчики</TableHead>}
<TableHead className="text-right">Просмотры</TableHead> <TableHead className="text-right">Просмотры</TableHead>
<TableHead className="text-right"> {canViewSubs && (
<div className="flex items-center justify-end gap-1"> <TableHead className="text-right">
CPS <div className="flex items-center justify-end gap-1">
<Tooltip> CPS
<TooltipTrigger asChild> <Tooltip>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <TooltipTrigger asChild>
</TooltipTrigger> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
<TooltipContent>Cost Per Subscription</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent>Cost Per Subscription</TooltipContent>
</div> </Tooltip>
</TableHead> </div>
</TableHead>
)}
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
CPM CPM
@@ -470,13 +523,17 @@ export default function SpendingAnalyticsPage() {
<TableRow key={index}> <TableRow key={index}>
<TableCell className="font-medium">{item.period}</TableCell> <TableCell className="font-medium">{item.period}</TableCell>
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell> <TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
<TableCell className="text-right"> {canViewSubs && (
{formatNumber(item.subscriptions)} <TableCell className="text-right">
</TableCell> {formatNumber(item.subscriptions)}
</TableCell>
)}
<TableCell className="text-right">{formatNumber(item.views)}</TableCell> <TableCell className="text-right">{formatNumber(item.views)}</TableCell>
<TableCell className="text-right"> {canViewSubs && (
{cps ? formatCurrency(cps) : "—"} <TableCell className="text-right">
</TableCell> {cps ? formatCurrency(cps) : "—"}
</TableCell>
)}
<TableCell className="text-right"> <TableCell className="text-right">
{cpm ? formatCurrency(cpm) : "—"} {cpm ? formatCurrency(cpm) : "—"}
</TableCell> </TableCell>

View File

@@ -84,6 +84,7 @@ import { PlacementPostStatusSelector } from "@/components/placement-post-status-
import { PlacementWizard } from "@/components/placement-wizard"; import { PlacementWizard } from "@/components/placement-wizard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { isPermissionError, showSuccessToast } from "@/lib/utils/error-handler";
import type { import type {
Project, Project,
Placement, Placement,
@@ -1361,25 +1362,24 @@ export default function PurchasePlanDetailPage() {
const entries = Object.entries(draftsByPlacementId); const entries = Object.entries(draftsByPlacementId);
if (entries.length === 0) return; if (entries.length === 0) return;
try { await toast.promise(
setSavingEdits(true); (async () => {
setSaveError(null); for (const [placementId, draft] of entries) {
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest);
for (const [placementId, draft] of entries) { }
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest); await loadPlacements();
})(),
{
loading: "Сохранение изменений...",
success: "Изменения сохранены",
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось сохранить изменения";
},
} }
);
await loadPlacements();
setSavingEdits(false);
} catch (err: unknown) {
console.error("Failed to save placement edits:", err);
const message =
typeof err === "object" && err && "detail" in err
? String((err as any).detail)
: "Не удалось сохранить изменения";
setSaveError(message);
setSavingEdits(false);
}
}; };
const toggleChannelSelection = (channelId: string, placementIds: string[]) => { const toggleChannelSelection = (channelId: string, placementIds: string[]) => {
@@ -1413,15 +1413,25 @@ export default function PurchasePlanDetailPage() {
const selectedIds = Array.from(selectedPlacementIds); const selectedIds = Array.from(selectedPlacementIds);
if (selectedIds.length === 0) return; if (selectedIds.length === 0) return;
try { await toast.promise(
for (const placementId of selectedIds) { (async () => {
await placementsApi.deleteInProject(workspaceId, projectId, placementId); for (const placementId of selectedIds) {
await placementsApi.deleteInProject(workspaceId, projectId, placementId);
}
await loadPlacements();
clearSelection();
})(),
{
loading: "Удаление размещений...",
success: () => `Удалено ${selectedIds.length} размещений`,
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось удалить размещения";
},
} }
await loadPlacements(); );
clearSelection();
} catch (err) {
console.error("Failed to delete placements:", err);
}
}; };
const handleCopyPlacements = async () => { const handleCopyPlacements = async () => {
@@ -1429,9 +1439,8 @@ export default function PurchasePlanDetailPage() {
const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id)); const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id));
if (selectedPlacements.length === 0) return; if (selectedPlacements.length === 0) return;
try { await toast.promise(
setCopyingChannels(true); placementsApi.createBulk(workspaceId, targetProjectId, {
await placementsApi.createBulk(workspaceId, targetProjectId, {
creative_id: undefined, creative_id: undefined,
channels: selectedPlacements.map((p) => ({ channels: selectedPlacements.map((p) => ({
channel_id: p.channel.id, channel_id: p.channel.id,
@@ -1447,50 +1456,62 @@ export default function PurchasePlanDetailPage() {
creative_id: p.details.creative_id || undefined, creative_id: p.details.creative_id || undefined,
} : undefined, } : undefined,
})), })),
}); }),
setShowCopyDialog(false); {
setTargetProjectId(null); loading: "Копирование размещений...",
clearSelection(); success: () => {
} catch (err) { setShowCopyDialog(false);
console.error("Failed to copy placements:", err); setTargetProjectId(null);
} finally { clearSelection();
setCopyingChannels(false); return `Скопировано ${selectedPlacements.length} размещений`;
} },
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
}
return (err as any)?.detail || "Не удалось скопировать размещения";
},
}
);
}; };
const handleAddPlacement = async (channel: Placement["channel"]) => { const handleAddPlacement = async (channel: Placement["channel"]) => {
if (!canEditPlacements) return; if (!canEditPlacements) return;
try { const result = await placementsApi.createInProject(workspaceId, projectId, {
const result = await placementsApi.createInProject(workspaceId, projectId, { creative_id: undefined,
creative_id: undefined, channels: [{
channels: [{ channel_id: channel.id,
channel_id: channel.id, status: "Без статуса",
status: "Без статуса", comment: undefined,
comment: undefined,
details: {},
}],
details: {}, details: {},
}); }],
details: {},
// Extract the new placement from the response });
const newPlacement = result.placements[0];
toast.promise(Promise.resolve(result), {
// Add the new placement to the list and switch to edit mode loading: "Добавление размещения...",
setPlacements((prev) => [...prev, newPlacement]); success: () => {
const newPlacement = result.placements[0];
// Scroll to and focus the new placement after a short delay setPlacements((prev) => [...prev, newPlacement]);
setTimeout(() => {
const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`); setTimeout(() => {
if (rowElement) { const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`);
rowElement.scrollIntoView({ behavior: "smooth", block: "center" }); if (rowElement) {
// Set editing state for the first editable field rowElement.scrollIntoView({ behavior: "smooth", block: "center" });
setEditingCell({ placementId: newPlacement.id, field: "status" }); setEditingCell({ placementId: newPlacement.id, field: "status" });
}
}, 100);
return `Размещение добавлено: ${channel.title}`;
},
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
} }
}, 100); return (err as any)?.detail || "Не удалось добавить размещение";
} catch (err) { },
console.error("Failed to add placement:", err); });
}
}; };
const groupedPlacements = useMemo(() => { const groupedPlacements = useMemo(() => {
@@ -1959,7 +1980,10 @@ export default function PurchasePlanDetailPage() {
workspaceId={workspaceId} workspaceId={workspaceId}
projectId={projectId} projectId={projectId}
initialCreativeId={wizardInitialCreative} initialCreativeId={wizardInitialCreative}
onSuccess={loadPlacements} onSuccess={() => {
loadPlacements();
showSuccessToast("Размещения созданы", "Успешно добавлено");
}}
/> />
</> </>
); );

View File

@@ -27,6 +27,7 @@ import {
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { placementsApi, creativesApi, channelsApi } from "@/lib/api"; import { placementsApi, creativesApi, channelsApi } from "@/lib/api";
import { isPermissionError, showSuccessToast, showErrorToast } from "@/lib/utils/error-handler";
import { ChannelInput, type ParsedChannel } from "@/components/channel-input"; import { ChannelInput, type ParsedChannel } from "@/components/channel-input";
import type { Creative } from "@/lib/types/api"; import type { Creative } from "@/lib/types/api";
@@ -161,10 +162,15 @@ export function PlacementWizard({
})), })),
}); });
showSuccessToast("Размещения созданы", "Успешно добавлено");
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
} catch (err: any) { } catch (err: any) {
setError(err?.detail || "Ошибка создания размещения"); if (isPermissionError(err)) {
showErrorToast("Недостаточно прав", "Обратитесь к администратору");
} else {
setError(err?.detail || "Ошибка создания размещения");
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }