feat: update permissions
This commit is contained in:
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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("Размещения созданы", "Успешно добавлено");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user