feat: update permissions
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user