feat: update permissions
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
@@ -74,14 +75,41 @@ type SortOrder = "asc" | "desc" | null;
|
||||
export default function ChannelsAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { getProjectFilterId } = useWorkspace();
|
||||
const { getProjectFilterId, canViewSubscriptions, hasAnyAnalyticsPermission } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
const canViewSubs = canViewSubscriptions;
|
||||
const hasPermission = hasAnyAnalyticsPermission;
|
||||
|
||||
const [channels, setChannels] = useState<ChannelAnalyticsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField | null>("cost");
|
||||
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(() => {
|
||||
loadData();
|
||||
}, [workspaceId, projectFilterId]);
|
||||
@@ -250,6 +278,7 @@ export default function ChannelsAnalyticsPage() {
|
||||
{getSortIcon("views")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
{canViewSubs && (
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -261,6 +290,7 @@ export default function ChannelsAnalyticsPage() {
|
||||
{getSortIcon("cps")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -287,11 +317,11 @@ export default function ChannelsAnalyticsPage() {
|
||||
</TableCell>
|
||||
<TableCell>{channel.placements_count}</TableCell>
|
||||
<TableCell>{formatCurrency(channel.total_cost)}</TableCell>
|
||||
{canViewSubs && (
|
||||
<TableCell>
|
||||
{formatNumber(channel.total_subscriptions)}
|
||||
{formatCurrency(channel.avg_cps)}
|
||||
</TableCell>
|
||||
<TableCell>{formatNumber(channel.total_views)}</TableCell>
|
||||
<TableCell>{formatCurrency(channel.avg_cps)}</TableCell>
|
||||
)}
|
||||
<TableCell>{formatCurrency(channel.avg_cpm)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -7,10 +7,40 @@
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
|
||||
export default function CreativesAnalyticsPage() {
|
||||
const params = useParams();
|
||||
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 (
|
||||
<>
|
||||
|
||||
@@ -136,7 +136,31 @@ const formatDate = (dateStr: string | undefined) => {
|
||||
export default function PlacementsAnalyticsPage() {
|
||||
const params = useParams();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -761,7 +761,31 @@ const SortablePanel: React.FC<SortablePanelProps> = ({
|
||||
export default function ProjectsAnalyticsPage() {
|
||||
const params = useParams();
|
||||
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 [tableData, setTableData] = useState<ProjectsAnalyticsResponse | null>(null);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
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 {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -132,17 +132,47 @@ const chartConfig: ChartConfig = {
|
||||
export default function SpendingAnalyticsPage() {
|
||||
const params = useParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { selectedProjects, getProjectFilterId, allProjectsSelected } = useWorkspace();
|
||||
const { selectedProjects, getProjectFilterId, allProjectsSelected, canViewSubscriptions, hasAnyAnalyticsPermission } = useWorkspace();
|
||||
const projectFilterId = getProjectFilterId();
|
||||
const canViewSubs = canViewSubscriptions;
|
||||
const hasPermission = hasAnyAnalyticsPermission;
|
||||
|
||||
const [spending, setSpending] = useState<SpendingAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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
|
||||
const [chartType, setChartType] = useState<ChartType>("line");
|
||||
const [showLabels, setShowLabels] = useState(false);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(["cost", "subscriptions"]);
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<MetricKey[]>(
|
||||
canViewSubs ? ["cost", "subscriptions"] : ["cost"]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@@ -320,6 +350,7 @@ export default function SpendingAnalyticsPage() {
|
||||
<div className="text-2xl font-bold">{formatCurrency(spending.total_cost)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{canViewSubs && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
@@ -330,6 +361,7 @@ export default function SpendingAnalyticsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Просмотров</CardTitle>
|
||||
@@ -398,7 +430,26 @@ export default function SpendingAnalyticsPage() {
|
||||
{/* Metrics Selection */}
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t">
|
||||
<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">
|
||||
<Checkbox
|
||||
id={`metric-${metric}`}
|
||||
@@ -435,8 +486,9 @@ export default function SpendingAnalyticsPage() {
|
||||
<TableRow>
|
||||
<TableHead>Период</TableHead>
|
||||
<TableHead className="text-right">Расходы</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
{canViewSubs && <TableHead className="text-right">Подписчики</TableHead>}
|
||||
<TableHead className="text-right">Просмотры</TableHead>
|
||||
{canViewSubs && (
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
CPS
|
||||
@@ -448,6 +500,7 @@ export default function SpendingAnalyticsPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
CPM
|
||||
@@ -470,13 +523,17 @@ export default function SpendingAnalyticsPage() {
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium">{item.period}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(item.cost)}</TableCell>
|
||||
{canViewSubs && (
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.subscriptions)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right">{formatNumber(item.views)}</TableCell>
|
||||
{canViewSubs && (
|
||||
<TableCell className="text-right">
|
||||
{cps ? formatCurrency(cps) : "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right">
|
||||
{cpm ? formatCurrency(cpm) : "—"}
|
||||
</TableCell>
|
||||
|
||||
@@ -84,6 +84,7 @@ import { PlacementPostStatusSelector } from "@/components/placement-post-status-
|
||||
import { PlacementWizard } from "@/components/placement-wizard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { isPermissionError, showSuccessToast } from "@/lib/utils/error-handler";
|
||||
import type {
|
||||
Project,
|
||||
Placement,
|
||||
@@ -1361,25 +1362,24 @@ export default function PurchasePlanDetailPage() {
|
||||
const entries = Object.entries(draftsByPlacementId);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
try {
|
||||
setSavingEdits(true);
|
||||
setSaveError(null);
|
||||
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
for (const [placementId, draft] of entries) {
|
||||
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest);
|
||||
}
|
||||
|
||||
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);
|
||||
})(),
|
||||
{
|
||||
loading: "Сохранение изменений...",
|
||||
success: "Изменения сохранены",
|
||||
error: (err) => {
|
||||
if (isPermissionError(err as any)) {
|
||||
return "Недостаточно прав. Обратитесь к администратору.";
|
||||
}
|
||||
return (err as any)?.detail || "Не удалось сохранить изменения";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const toggleChannelSelection = (channelId: string, placementIds: string[]) => {
|
||||
@@ -1413,15 +1413,25 @@ export default function PurchasePlanDetailPage() {
|
||||
const selectedIds = Array.from(selectedPlacementIds);
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
for (const placementId of selectedIds) {
|
||||
await placementsApi.deleteInProject(workspaceId, projectId, placementId);
|
||||
}
|
||||
await loadPlacements();
|
||||
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 () => {
|
||||
@@ -1429,9 +1439,8 @@ export default function PurchasePlanDetailPage() {
|
||||
const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id));
|
||||
if (selectedPlacements.length === 0) return;
|
||||
|
||||
try {
|
||||
setCopyingChannels(true);
|
||||
await placementsApi.createBulk(workspaceId, targetProjectId, {
|
||||
await toast.promise(
|
||||
placementsApi.createBulk(workspaceId, targetProjectId, {
|
||||
creative_id: undefined,
|
||||
channels: selectedPlacements.map((p) => ({
|
||||
channel_id: p.channel.id,
|
||||
@@ -1447,21 +1456,28 @@ export default function PurchasePlanDetailPage() {
|
||||
creative_id: p.details.creative_id || undefined,
|
||||
} : undefined,
|
||||
})),
|
||||
});
|
||||
}),
|
||||
{
|
||||
loading: "Копирование размещений...",
|
||||
success: () => {
|
||||
setShowCopyDialog(false);
|
||||
setTargetProjectId(null);
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
console.error("Failed to copy placements:", err);
|
||||
} finally {
|
||||
setCopyingChannels(false);
|
||||
return `Скопировано ${selectedPlacements.length} размещений`;
|
||||
},
|
||||
error: (err) => {
|
||||
if (isPermissionError(err as any)) {
|
||||
return "Недостаточно прав. Обратитесь к администратору.";
|
||||
}
|
||||
return (err as any)?.detail || "Не удалось скопировать размещения";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddPlacement = async (channel: Placement["channel"]) => {
|
||||
if (!canEditPlacements) return;
|
||||
|
||||
try {
|
||||
const result = await placementsApi.createInProject(workspaceId, projectId, {
|
||||
creative_id: undefined,
|
||||
channels: [{
|
||||
@@ -1473,24 +1489,29 @@ export default function PurchasePlanDetailPage() {
|
||||
details: {},
|
||||
});
|
||||
|
||||
// Extract the new placement from the response
|
||||
toast.promise(Promise.resolve(result), {
|
||||
loading: "Добавление размещения...",
|
||||
success: () => {
|
||||
const newPlacement = result.placements[0];
|
||||
|
||||
// Add the new placement to the list and switch to edit mode
|
||||
setPlacements((prev) => [...prev, newPlacement]);
|
||||
|
||||
// Scroll to and focus the new placement after a short delay
|
||||
setTimeout(() => {
|
||||
const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`);
|
||||
if (rowElement) {
|
||||
rowElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Set editing state for the first editable field
|
||||
setEditingCell({ placementId: newPlacement.id, field: "status" });
|
||||
}
|
||||
}, 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(() => {
|
||||
@@ -1959,7 +1980,10 @@ export default function PurchasePlanDetailPage() {
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
initialCreativeId={wizardInitialCreative}
|
||||
onSuccess={loadPlacements}
|
||||
onSuccess={() => {
|
||||
loadPlacements();
|
||||
showSuccessToast("Размещения созданы", "Успешно добавлено");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 type { Creative } from "@/lib/types/api";
|
||||
|
||||
@@ -161,10 +162,15 @@ export function PlacementWizard({
|
||||
})),
|
||||
});
|
||||
|
||||
showSuccessToast("Размещения созданы", "Успешно добавлено");
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch (err: any) {
|
||||
if (isPermissionError(err)) {
|
||||
showErrorToast("Недостаточно прав", "Обратитесь к администратору");
|
||||
} else {
|
||||
setError(err?.detail || "Ошибка создания размещения");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user