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,6 +278,7 @@ export default function ChannelsAnalyticsPage() {
{getSortIcon("views")} {getSortIcon("views")}
</Button> </Button>
</TableHead> </TableHead>
{canViewSubs && (
<TableHead> <TableHead>
<Button <Button
variant="ghost" variant="ghost"
@@ -261,6 +290,7 @@ export default function ChannelsAnalyticsPage() {
{getSortIcon("cps")} {getSortIcon("cps")}
</Button> </Button>
</TableHead> </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>
{canViewSubs && (
<TableCell> <TableCell>
{formatNumber(channel.total_subscriptions)} {formatCurrency(channel.avg_cps)}
</TableCell> </TableCell>
<TableCell>{formatNumber(channel.total_views)}</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,6 +350,7 @@ 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>
{canViewSubs && (
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle> <CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
@@ -330,6 +361,7 @@ export default function SpendingAnalyticsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </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>
@@ -398,7 +430,26 @@ export default function SpendingAnalyticsPage() {
{/* 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">
<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>
))}
{canViewSubs && (["subscriptions"] 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}`}
@@ -435,8 +486,9 @@ 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>
{canViewSubs && (
<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">
CPS CPS
@@ -448,6 +500,7 @@ export default function SpendingAnalyticsPage() {
</Tooltip> </Tooltip>
</div> </div>
</TableHead> </TableHead>
)}
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
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>
{canViewSubs && (
<TableCell className="text-right"> <TableCell className="text-right">
{formatNumber(item.subscriptions)} {formatNumber(item.subscriptions)}
</TableCell> </TableCell>
)}
<TableCell className="text-right">{formatNumber(item.views)}</TableCell> <TableCell className="text-right">{formatNumber(item.views)}</TableCell>
{canViewSubs && (
<TableCell className="text-right"> <TableCell className="text-right">
{cps ? formatCurrency(cps) : "—"} {cps ? formatCurrency(cps) : "—"}
</TableCell> </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) { for (const [placementId, draft] of entries) {
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest); await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest);
} }
await loadPlacements(); await loadPlacements();
setSavingEdits(false); })(),
} catch (err: unknown) { {
console.error("Failed to save placement edits:", err); loading: "Сохранение изменений...",
const message = success: "Изменения сохранены",
typeof err === "object" && err && "detail" in err error: (err) => {
? String((err as any).detail) if (isPermissionError(err as any)) {
: "Не удалось сохранить изменения"; return "Недостаточно прав. Обратитесь к администратору.";
setSaveError(message);
setSavingEdits(false);
} }
return (err as any)?.detail || "Не удалось сохранить изменения";
},
}
);
}; };
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(
(async () => {
for (const placementId of selectedIds) { for (const placementId of selectedIds) {
await placementsApi.deleteInProject(workspaceId, projectId, placementId); await placementsApi.deleteInProject(workspaceId, projectId, placementId);
} }
await loadPlacements(); await loadPlacements();
clearSelection(); clearSelection();
} catch (err) { })(),
console.error("Failed to delete placements:", err); {
loading: "Удаление размещений...",
success: () => `Удалено ${selectedIds.length} размещений`,
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
} }
return (err as any)?.detail || "Не удалось удалить размещения";
},
}
);
}; };
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,21 +1456,28 @@ export default function PurchasePlanDetailPage() {
creative_id: p.details.creative_id || undefined, creative_id: p.details.creative_id || undefined,
} : undefined, } : undefined,
})), })),
}); }),
{
loading: "Копирование размещений...",
success: () => {
setShowCopyDialog(false); setShowCopyDialog(false);
setTargetProjectId(null); setTargetProjectId(null);
clearSelection(); clearSelection();
} catch (err) { return `Скопировано ${selectedPlacements.length} размещений`;
console.error("Failed to copy placements:", err); },
} finally { error: (err) => {
setCopyingChannels(false); 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: [{
@@ -1473,24 +1489,29 @@ export default function PurchasePlanDetailPage() {
details: {}, details: {},
}); });
// Extract the new placement from the response toast.promise(Promise.resolve(result), {
loading: "Добавление размещения...",
success: () => {
const newPlacement = result.placements[0]; const newPlacement = result.placements[0];
// Add the new placement to the list and switch to edit mode
setPlacements((prev) => [...prev, newPlacement]); setPlacements((prev) => [...prev, newPlacement]);
// Scroll to and focus the new placement after a short delay
setTimeout(() => { setTimeout(() => {
const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`); const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`);
if (rowElement) { if (rowElement) {
rowElement.scrollIntoView({ behavior: "smooth", block: "center" }); rowElement.scrollIntoView({ behavior: "smooth", block: "center" });
// Set editing state for the first editable field
setEditingCell({ placementId: newPlacement.id, field: "status" }); setEditingCell({ placementId: newPlacement.id, field: "status" });
} }
}, 100); }, 100);
} catch (err) {
console.error("Failed to add placement:", err); return `Размещение добавлено: ${channel.title}`;
},
error: (err) => {
if (isPermissionError(err as any)) {
return "Недостаточно прав. Обратитесь к администратору.";
} }
return (err as any)?.detail || "Не удалось добавить размещение";
},
});
}; };
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) {
if (isPermissionError(err)) {
showErrorToast("Недостаточно прав", "Обратитесь к администратору");
} else {
setError(err?.detail || "Ошибка создания размещения"); setError(err?.detail || "Ошибка создания размещения");
}
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }