feat: update permissions

This commit is contained in:
ivannoskov
2026-02-10 17:15:38 +03:00
parent 6941915d76
commit e1117e46c8
13 changed files with 961 additions and 523 deletions

View File

@@ -74,6 +74,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { handleApiError } from "@/lib/utils/error-handler";
import { BOT_USERNAME } from "@/lib/utils/constants";
import { ProjectSelector } from "@/components/project-selector";
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
@@ -157,17 +159,21 @@ export default function CreativesPage() {
const [includeArchived, setIncludeArchived] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
const canWrite = hasPermission("placements_write");
// Permission checks
const canReadCreatives = hasPermission("creatives_read");
const canWriteCreatives = hasPermission("creatives_write");
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
useEffect(() => {
loadCreatives();
}, [workspaceId, projectFilterId, includeArchived]);
useEffect(() => {
if (creatives.length > 0) {
if (creatives.length > 0 && canViewAnalytics) {
loadAnalytics();
}
}, [creatives, workspaceId, projectFilterId]);
}, [creatives, workspaceId, projectFilterId, canViewAnalytics]);
const loadCreatives = async () => {
try {
@@ -179,13 +185,18 @@ export default function CreativesPage() {
});
setCreatives(response.items);
} catch (err) {
console.error("Failed to load creatives:", err);
handleApiError(err);
} finally {
setLoading(false);
}
};
const loadAnalytics = async () => {
if (!canViewAnalytics) {
setAnalyticsData([]);
return;
}
try {
setLoadingAnalytics(true);
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
@@ -209,24 +220,42 @@ export default function CreativesPage() {
}, [creatives, analyticsData]);
const handleArchive = async (creative: Creative) => {
try {
const newStatus = creative.status === "active" ? "archived" : "active";
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
loadCreatives();
} catch (err) {
console.error("Failed to archive creative:", err);
}
await toast.promise(
(async () => {
const newStatus = creative.status === "active" ? "archived" : "active";
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
await loadCreatives();
})(),
{
loading: creative.status === "active" ? "Архивирование..." : "Восстановление...",
success: () => {
return creative.status === "active"
? `Креатив «${creative.name}» архивирован`
: `Креатив «${creative.name}» восстановлен`;
},
error: (err) => {
handleApiError(err);
return "Не удалось изменить статус";
},
}
);
};
const handleDelete = async (creativeId: string) => {
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
try {
await creativesApi.delete(workspaceId, creativeId);
loadCreatives();
} catch (err) {
console.error("Failed to delete creative:", err);
}
const handleDelete = async (creativeId: string, creativeName: string) => {
await toast.promise(
(async () => {
await creativesApi.delete(workspaceId, creativeId);
await loadCreatives();
})(),
{
loading: "Удаление креатива...",
success: () => `Креатив «${creativeName}» удалён`,
error: (err) => {
handleApiError(err);
return "Не удалось удалить креатив";
},
}
);
};
@@ -254,14 +283,16 @@ export default function CreativesPage() {
// Totals
const totals = useMemo(() => {
const data = filteredCreatives.filter((c) => c.analytics);
const data = filteredCreatives.filter((c) => canViewAnalytics && c.analytics);
return {
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0),
subscriptions: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0),
subscriptions: canViewSubscriptions
? data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0)
: 0,
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
};
}, [filteredCreatives]);
}, [filteredCreatives, canViewAnalytics, canViewSubscriptions]);
return (
<TooltipProvider>
@@ -300,7 +331,7 @@ export default function CreativesPage() {
<Grid className="h-4 w-4" />
</Button>
</div>
{canWrite && (
{canWriteCreatives && (
<Button onClick={handleCreateCreativeClick}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
@@ -346,7 +377,7 @@ export default function CreativesPage() {
? "Ничего не найдено по вашему запросу"
: "Создайте первый креатив для начала работы"}
</p>
{canWrite && !search && (
{canWriteCreatives && !search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Создать креатив
@@ -354,7 +385,7 @@ export default function CreativesPage() {
)}
</CardContent>
</Card>
) : viewMode === "table" ? (
) : viewMode === "table" ? (
<Card>
<Table>
<TableHeader>
@@ -387,19 +418,21 @@ export default function CreativesPage() {
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики
</TooltipContent>
</Tooltip>
</div>
</TableHead>
{canViewSubscriptions && (
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Привлеченные подписчики
</TooltipContent>
</Tooltip>
</div>
</TableHead>
)}
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Сред. CPS
@@ -454,28 +487,44 @@ export default function CreativesPage() {
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">
{creative.analytics?.placements_count ?? creative.placements_count}
{canViewAnalytics
? creative.analytics?.placements_count ?? creative.placements_count
: creative.placements_count}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
{canViewAnalytics ? (
loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.total_cost)
)
) : (
formatCurrency(creative.analytics?.total_cost)
"—"
)}
</TableCell>
{canViewSubscriptions && (
<TableCell className="text-right">
{canViewAnalytics ? (
loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatNumber(creative.analytics?.total_subscriptions)
)
) : (
"—"
)}
</TableCell>
)}
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
{canViewAnalytics ? (
loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
)
) : (
formatNumber(creative.analytics?.total_subscriptions)
)}
</TableCell>
<TableCell className="text-right">
{loadingAnalytics ? (
<Loader2 className="h-4 w-4 animate-spin inline" />
) : (
formatCurrency(creative.analytics?.avg_cpf)
"—"
)}
</TableCell>
<TableCell>
@@ -487,20 +536,20 @@ export default function CreativesPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{canWrite && creative.status === "active" && (
{canWriteCreatives && creative.status === "active" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
asChild
>
<Link
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
title="Создать размещение"
>
<Link
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
title="Создать размещение"
>
<Plus className="h-3.5 w-3.5" />
</Link>
</Button>
<Plus className="h-3.5 w-3.5" />
</Link>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -510,32 +559,28 @@ export default function CreativesPage() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
>
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
<Eye className="h-4 w-4 mr-2" />
Просмотр
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}
>
<BarChart3 className="h-4 w-4 mr-2" />
Аналитика
</Link>
</DropdownMenuItem>
{canViewAnalytics && (
<DropdownMenuItem asChild>
<Link href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}>
<BarChart3 className="h-4 w-4 mr-2" />
Аналитика
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
<Copy className="h-4 w-4 mr-2" />
Копировать текст
</DropdownMenuItem>
{canWrite && (
{canWriteCreatives && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
>
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}>
<Pencil className="h-4 w-4 mr-2" />
Редактировать
</Link>
@@ -546,7 +591,7 @@ export default function CreativesPage() {
</DropdownMenuItem>
{creative.placements_count === 0 && (
<DropdownMenuItem
onClick={() => handleDelete(creative.id)}
onClick={() => handleDelete(creative.id, creative.name)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
@@ -594,7 +639,7 @@ export default function CreativesPage() {
</CardHeader>
<CardContent className="space-y-4">
{/* Stats */}
{creative.analytics && (
{canViewAnalytics && creative.analytics && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
@@ -605,15 +650,17 @@ export default function CreativesPage() {
<div className="text-xs text-muted-foreground">Потрачено</div>
</div>
</div>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_subscriptions)}
{canViewSubscriptions && (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(creative.analytics.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div>
<div className="text-xs text-muted-foreground">Подписчиков</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>