feat: update permissions
This commit is contained in:
@@ -74,6 +74,8 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleApiError } from "@/lib/utils/error-handler";
|
||||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||||
import { ProjectSelector } from "@/components/project-selector";
|
import { ProjectSelector } from "@/components/project-selector";
|
||||||
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
|
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
|
||||||
@@ -157,17 +159,21 @@ export default function CreativesPage() {
|
|||||||
const [includeArchived, setIncludeArchived] = useState(false);
|
const [includeArchived, setIncludeArchived] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
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(() => {
|
useEffect(() => {
|
||||||
loadCreatives();
|
loadCreatives();
|
||||||
}, [workspaceId, projectFilterId, includeArchived]);
|
}, [workspaceId, projectFilterId, includeArchived]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creatives.length > 0) {
|
if (creatives.length > 0 && canViewAnalytics) {
|
||||||
loadAnalytics();
|
loadAnalytics();
|
||||||
}
|
}
|
||||||
}, [creatives, workspaceId, projectFilterId]);
|
}, [creatives, workspaceId, projectFilterId, canViewAnalytics]);
|
||||||
|
|
||||||
const loadCreatives = async () => {
|
const loadCreatives = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -179,13 +185,18 @@ export default function CreativesPage() {
|
|||||||
});
|
});
|
||||||
setCreatives(response.items);
|
setCreatives(response.items);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load creatives:", err);
|
handleApiError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAnalytics = async () => {
|
const loadAnalytics = async () => {
|
||||||
|
if (!canViewAnalytics) {
|
||||||
|
setAnalyticsData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingAnalytics(true);
|
setLoadingAnalytics(true);
|
||||||
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
||||||
@@ -209,24 +220,42 @@ export default function CreativesPage() {
|
|||||||
}, [creatives, analyticsData]);
|
}, [creatives, analyticsData]);
|
||||||
|
|
||||||
const handleArchive = async (creative: Creative) => {
|
const handleArchive = async (creative: Creative) => {
|
||||||
try {
|
await toast.promise(
|
||||||
const newStatus = creative.status === "active" ? "archived" : "active";
|
(async () => {
|
||||||
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
|
const newStatus = creative.status === "active" ? "archived" : "active";
|
||||||
loadCreatives();
|
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
|
||||||
} catch (err) {
|
await loadCreatives();
|
||||||
console.error("Failed to archive creative:", err);
|
})(),
|
||||||
}
|
{
|
||||||
|
loading: creative.status === "active" ? "Архивирование..." : "Восстановление...",
|
||||||
|
success: () => {
|
||||||
|
return creative.status === "active"
|
||||||
|
? `Креатив «${creative.name}» архивирован`
|
||||||
|
: `Креатив «${creative.name}» восстановлен`;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось изменить статус";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (creativeId: string) => {
|
const handleDelete = async (creativeId: string, creativeName: string) => {
|
||||||
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
|
await toast.promise(
|
||||||
|
(async () => {
|
||||||
try {
|
await creativesApi.delete(workspaceId, creativeId);
|
||||||
await creativesApi.delete(workspaceId, creativeId);
|
await loadCreatives();
|
||||||
loadCreatives();
|
})(),
|
||||||
} catch (err) {
|
{
|
||||||
console.error("Failed to delete creative:", err);
|
loading: "Удаление креатива...",
|
||||||
}
|
success: () => `Креатив «${creativeName}» удалён`,
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось удалить креатив";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -254,14 +283,16 @@ export default function CreativesPage() {
|
|||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const data = filteredCreatives.filter((c) => c.analytics);
|
const data = filteredCreatives.filter((c) => canViewAnalytics && c.analytics);
|
||||||
return {
|
return {
|
||||||
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
|
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
|
||||||
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 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),
|
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
|
||||||
};
|
};
|
||||||
}, [filteredCreatives]);
|
}, [filteredCreatives, canViewAnalytics, canViewSubscriptions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -300,7 +331,7 @@ export default function CreativesPage() {
|
|||||||
<Grid className="h-4 w-4" />
|
<Grid className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{canWrite && (
|
{canWriteCreatives && (
|
||||||
<Button onClick={handleCreateCreativeClick}>
|
<Button onClick={handleCreateCreativeClick}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Создать креатив
|
Создать креатив
|
||||||
@@ -346,7 +377,7 @@ export default function CreativesPage() {
|
|||||||
? "Ничего не найдено по вашему запросу"
|
? "Ничего не найдено по вашему запросу"
|
||||||
: "Создайте первый креатив для начала работы"}
|
: "Создайте первый креатив для начала работы"}
|
||||||
</p>
|
</p>
|
||||||
{canWrite && !search && (
|
{canWriteCreatives && !search && (
|
||||||
<Button onClick={() => setShowCreateDialog(true)}>
|
<Button onClick={() => setShowCreateDialog(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Создать креатив
|
Создать креатив
|
||||||
@@ -354,7 +385,7 @@ export default function CreativesPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : viewMode === "table" ? (
|
) : viewMode === "table" ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -387,19 +418,21 @@ export default function CreativesPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
{canViewSubscriptions && (
|
||||||
<div className="flex items-center justify-end gap-1">
|
<TableHead className="text-right">
|
||||||
Подписчики
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Tooltip>
|
Подписчики
|
||||||
<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>
|
</TooltipTrigger>
|
||||||
Привлеченные подписчики
|
<TooltipContent>
|
||||||
</TooltipContent>
|
Привлеченные подписчики
|
||||||
</Tooltip>
|
</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">
|
||||||
Сред. CPS
|
Сред. CPS
|
||||||
@@ -454,28 +487,44 @@ export default function CreativesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
{creative.analytics?.placements_count ?? creative.placements_count}
|
{canViewAnalytics
|
||||||
|
? creative.analytics?.placements_count ?? creative.placements_count
|
||||||
|
: creative.placements_count}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-medium">
|
<TableCell className="text-right font-medium">
|
||||||
{loadingAnalytics ? (
|
{canViewAnalytics ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
loadingAnalytics ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||||
|
) : (
|
||||||
|
formatCurrency(creative.analytics?.total_cost)
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
formatCurrency(creative.analytics?.total_cost)
|
"—"
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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">
|
<TableCell className="text-right">
|
||||||
{loadingAnalytics ? (
|
{canViewAnalytics ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -487,20 +536,20 @@ export default function CreativesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{canWrite && creative.status === "active" && (
|
{canWriteCreatives && creative.status === "active" && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
asChild
|
asChild
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
|
||||||
|
title="Создать размещение"
|
||||||
>
|
>
|
||||||
<Link
|
<Plus className="h-3.5 w-3.5" />
|
||||||
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
|
</Link>
|
||||||
title="Создать размещение"
|
</Button>
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -510,32 +559,28 @@ export default function CreativesPage() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
|
||||||
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4 mr-2" />
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
Просмотр
|
Просмотр
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
{canViewAnalytics && (
|
||||||
<Link
|
<DropdownMenuItem asChild>
|
||||||
href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}
|
<Link href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}>
|
||||||
>
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
<BarChart3 className="h-4 w-4 mr-2" />
|
Аналитика
|
||||||
Аналитика
|
</Link>
|
||||||
</Link>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
|
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
Копировать текст
|
Копировать текст
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canWrite && (
|
{canWriteCreatives && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link
|
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}>
|
||||||
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
Редактировать
|
Редактировать
|
||||||
</Link>
|
</Link>
|
||||||
@@ -546,7 +591,7 @@ export default function CreativesPage() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{creative.placements_count === 0 && (
|
{creative.placements_count === 0 && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleDelete(creative.id)}
|
onClick={() => handleDelete(creative.id, creative.name)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
@@ -594,7 +639,7 @@ export default function CreativesPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{creative.analytics && (
|
{canViewAnalytics && creative.analytics && (
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<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 className="text-xs text-muted-foreground">Потрачено</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
{canViewSubscriptions && (
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
<div className="font-medium">
|
<div>
|
||||||
{formatNumber(creative.analytics.total_subscriptions)}
|
<div className="font-medium">
|
||||||
|
{formatNumber(creative.analytics.total_subscriptions)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Подписчиков</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">Подписчиков</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Projects Page (Target Channels) - Enhanced with Statistics
|
// Projects Page (Target Channels) - Enhanced with Statistics
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useCallback, useRef, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Bot,
|
Bot,
|
||||||
Settings,
|
Settings,
|
||||||
|
RefreshCw,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Archive,
|
Archive,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -86,6 +88,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { handleApiError, showPermissionErrorToast } from "@/lib/utils/error-handler";
|
||||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||||
import type { Project, SpendingAnalytics } from "@/lib/types/api";
|
import type { Project, SpendingAnalytics } from "@/lib/types/api";
|
||||||
|
|
||||||
@@ -143,48 +147,62 @@ const formatStatus = (status: string): string => {
|
|||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const workspaceId = params?.workspaceId as string;
|
const workspaceId = params?.workspaceId as string;
|
||||||
const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace();
|
const { currentWorkspace, workspaces, isAdmin, isWorkspaceAdmin, hasPermission, isDemoMode } = useWorkspace();
|
||||||
|
|
||||||
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
// Local state for projects with proper include_archived support
|
||||||
|
const [localProjects, setLocalProjects] = useState<Project[]>([]);
|
||||||
|
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||||
|
|
||||||
|
const [localProjectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
|
||||||
const [loadingStats, setLoadingStats] = useState(false);
|
const [loadingStats, setLoadingStats] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||||
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
|
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
|
||||||
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
|
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
|
||||||
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
|
|
||||||
const [isArchiving, setIsArchiving] = useState(false);
|
|
||||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
|
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
|
||||||
|
|
||||||
// Move project state
|
// Move project state
|
||||||
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
||||||
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
||||||
const [isMoving, setIsMoving] = useState(false);
|
|
||||||
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
|
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
|
||||||
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
|
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
|
||||||
|
|
||||||
const { isDemoMode } = useWorkspace();
|
// Show archived projects toggle - must be defined before loadProjects
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
// Poll projects list every 5s so newly added projects (via bot) appear automatically.
|
// Load projects with include_archived support
|
||||||
const refreshProjectsRef = useRef(refreshProjects);
|
const loadProjects = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
if (isDemoMode) {
|
||||||
|
const demoProjectsRaw = (await import("@/lib/demo")).demoProjects;
|
||||||
|
const projects = showArchived
|
||||||
|
? demoProjectsRaw
|
||||||
|
: demoProjectsRaw.filter(p => p.status === "active");
|
||||||
|
setLocalProjects(projects);
|
||||||
|
} else {
|
||||||
|
const response = await projectsApi.list(workspaceId, { size: 100, include_archived: showArchived });
|
||||||
|
setLocalProjects(response.items);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load projects:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
}
|
||||||
|
}, [workspaceId, showArchived, isDemoMode]);
|
||||||
|
|
||||||
|
// Manual refresh - checks for new projects
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
await loadProjects();
|
||||||
|
setIsRefreshing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load and when showArchived changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshProjectsRef.current = refreshProjects;
|
loadProjects();
|
||||||
}, [refreshProjects]);
|
}, [loadProjects]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspaceId) return;
|
|
||||||
if (isDemoMode) return;
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
if (typeof document !== "undefined" && document.hidden) return;
|
|
||||||
void refreshProjectsRef.current();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(intervalId);
|
|
||||||
};
|
|
||||||
}, [workspaceId, isDemoMode]);
|
|
||||||
|
|
||||||
const handleOpenSettings = (project: Project) => {
|
const handleOpenSettings = (project: Project) => {
|
||||||
setSettingsProject(project);
|
setSettingsProject(project);
|
||||||
@@ -194,69 +212,97 @@ export default function ProjectsPage() {
|
|||||||
const handleSaveSettings = async () => {
|
const handleSaveSettings = async () => {
|
||||||
if (!settingsProject) return;
|
if (!settingsProject) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsUpdatingSettings(true);
|
(async () => {
|
||||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||||
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
|
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
|
||||||
purchase_invite_type_default: inviteLinkType,
|
purchase_invite_type_default: inviteLinkType,
|
||||||
});
|
});
|
||||||
await refreshProjects();
|
await loadProjects();
|
||||||
setSettingsProject(null);
|
})(),
|
||||||
} catch (err) {
|
{
|
||||||
console.error("Failed to update settings:", err);
|
loading: "Сохранение настроек...",
|
||||||
} finally {
|
success: () => {
|
||||||
setIsUpdatingSettings(false);
|
setSettingsProject(null);
|
||||||
}
|
return "Настройки сохранены";
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось сохранить настройки";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async () => {
|
||||||
if (!settingsProject) return;
|
if (!settingsProject) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsArchiving(true);
|
(async () => {
|
||||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||||
await api.archive(workspaceId, settingsProject.id);
|
await api.archive(workspaceId, settingsProject.id);
|
||||||
await refreshProjects();
|
await loadProjects();
|
||||||
setSettingsProject(null);
|
})(),
|
||||||
} catch (err) {
|
{
|
||||||
console.error("Failed to archive project:", err);
|
loading: "Архивирование проекта...",
|
||||||
} finally {
|
success: () => {
|
||||||
setIsArchiving(false);
|
setSettingsProject(null);
|
||||||
}
|
return `Проект «${settingsProject.title}» архивирован`;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось архивировать проект";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnarchive = async () => {
|
const handleUnarchive = async () => {
|
||||||
if (!settingsProject) return;
|
if (!settingsProject) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsUnarchiving(true);
|
(async () => {
|
||||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||||
await api.unarchive(workspaceId, settingsProject.id);
|
await api.unarchive(workspaceId, settingsProject.id);
|
||||||
await refreshProjects();
|
await loadProjects();
|
||||||
setSettingsProject(null);
|
})(),
|
||||||
} catch (err) {
|
{
|
||||||
console.error("Failed to unarchive project:", err);
|
loading: "Восстановление проекта...",
|
||||||
} finally {
|
success: () => {
|
||||||
setIsUnarchiving(false);
|
setSettingsProject(null);
|
||||||
}
|
return `Проект «${settingsProject.title}» восстановлен`;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось восстановить проект";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!settingsProject) return;
|
if (!settingsProject) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsDeleting(true);
|
(async () => {
|
||||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||||
await api.delete(workspaceId, settingsProject.id);
|
await api.delete(workspaceId, settingsProject.id);
|
||||||
await refreshProjects();
|
await loadProjects();
|
||||||
setSettingsProject(null);
|
})(),
|
||||||
setShowDeleteDialog(false);
|
{
|
||||||
setDeleteConfirmationText("");
|
loading: "Удаление проекта...",
|
||||||
} catch (err) {
|
success: () => {
|
||||||
console.error("Failed to delete project:", err);
|
setSettingsProject(null);
|
||||||
} finally {
|
setShowDeleteDialog(false);
|
||||||
setIsDeleting(false);
|
setDeleteConfirmationText("");
|
||||||
}
|
return `Проект «${settingsProject.title}» удалён`;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось удалить проект";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDeleteDialog = () => {
|
const handleOpenDeleteDialog = () => {
|
||||||
@@ -312,20 +358,29 @@ export default function ProjectsPage() {
|
|||||||
const handleMoveProject = async () => {
|
const handleMoveProject = async () => {
|
||||||
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
|
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
|
||||||
|
|
||||||
try {
|
const targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId);
|
||||||
setIsMoving(true);
|
|
||||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
await toast.promise(
|
||||||
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
|
(async () => {
|
||||||
await refreshProjects();
|
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||||
setSettingsProject(null);
|
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
|
||||||
setShowMoveDialog(false);
|
await loadProjects();
|
||||||
setTargetWorkspaceId("");
|
})(),
|
||||||
setTargetWorkspaceAdmin(false);
|
{
|
||||||
} catch (err) {
|
loading: "Перемещение проекта...",
|
||||||
console.error("Failed to move project:", err);
|
success: () => {
|
||||||
} finally {
|
setSettingsProject(null);
|
||||||
setIsMoving(false);
|
setShowMoveDialog(false);
|
||||||
}
|
setTargetWorkspaceId("");
|
||||||
|
setTargetWorkspaceAdmin(false);
|
||||||
|
return `Проект перемещён в «${targetWorkspace?.name}»`;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось переместить проект";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getChannelAvatar = (project: Project) => {
|
const getChannelAvatar = (project: Project) => {
|
||||||
@@ -335,17 +390,30 @@ export default function ProjectsPage() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if user has any analytics permission
|
||||||
|
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
|
||||||
|
// Check if user has subscriptions visibility
|
||||||
|
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
|
||||||
|
// Check if user can edit projects
|
||||||
|
const canEditProjects = hasPermission("projects_write");
|
||||||
|
|
||||||
// Load statistics for each project
|
// Load statistics for each project
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projects.length === 0) {
|
if (localProjects.length === 0) {
|
||||||
setProjectsWithStats([]);
|
setProjectsWithStats([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!canViewAnalytics) {
|
||||||
|
setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined })));
|
||||||
|
setLoadingStats(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
setLoadingStats(true);
|
setLoadingStats(true);
|
||||||
try {
|
try {
|
||||||
const statsPromises = projects.map(async (project) => {
|
const statsPromises = localProjects.map(async (project) => {
|
||||||
try {
|
try {
|
||||||
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
|
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
@@ -376,17 +444,23 @@ export default function ProjectsPage() {
|
|||||||
setProjectsWithStats(results);
|
setProjectsWithStats(results);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load project stats:", err);
|
console.error("Failed to load project stats:", err);
|
||||||
setProjectsWithStats(projects);
|
setProjectsWithStats(localProjects);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingStats(false);
|
setLoadingStats(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadStats();
|
loadStats();
|
||||||
}, [projects, workspaceId]);
|
}, [localProjects, workspaceId, canViewAnalytics]);
|
||||||
|
|
||||||
const isLoading = isLoadingProjects || loadingStats;
|
const isLoading = isLoadingProjects || loadingStats;
|
||||||
|
|
||||||
|
// Filter projects based on archived status (already filtered by API, but keep for safety)
|
||||||
|
const filteredProjects = localProjectsWithStats.filter((project) => {
|
||||||
|
if (showArchived) return true;
|
||||||
|
return project.status !== "archived";
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
@@ -467,11 +541,33 @@ export default function ProjectsPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="show-archived"
|
||||||
|
checked={showArchived}
|
||||||
|
onCheckedChange={(checked) => setShowArchived(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
|
||||||
|
Показать архивированные
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isLoadingProjects || isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : projectsWithStats.length === 0 ? (
|
) : filteredProjects.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
@@ -533,7 +629,7 @@ export default function ProjectsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{projectsWithStats.map((project) => (
|
{filteredProjects.map((project) => (
|
||||||
<TableRow key={project.id}>
|
<TableRow key={project.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -617,6 +713,7 @@ export default function ProjectsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleOpenSettings(project)}
|
onClick={() => handleOpenSettings(project)}
|
||||||
|
disabled={!canEditProjects}
|
||||||
>
|
>
|
||||||
<Settings className="h-3 w-3" />
|
<Settings className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -629,7 +726,7 @@ export default function ProjectsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{projectsWithStats.map((project) => (
|
{filteredProjects.map((project) => (
|
||||||
<Card key={project.id} className="overflow-hidden">
|
<Card key={project.id} className="overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -747,6 +844,7 @@ export default function ProjectsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleOpenSettings(project)}
|
onClick={() => handleOpenSettings(project)}
|
||||||
|
disabled={!canEditProjects}
|
||||||
>
|
>
|
||||||
<Settings className="h-3.5 w-3.5" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -792,47 +890,29 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleUnarchive}
|
onClick={handleUnarchive}
|
||||||
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
|
disabled={!canEditProjects || isDemoMode}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
>
|
>
|
||||||
{isUnarchiving ? (
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
<>
|
Восстановить из архива
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Восстановление...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RotateCcw className="h-4 w-4 mr-2" />
|
|
||||||
Восстановить из архива
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleArchive}
|
onClick={handleArchive}
|
||||||
disabled={isArchiving || isDeleting || isUpdatingSettings}
|
disabled={!canEditProjects || isDemoMode}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
>
|
>
|
||||||
{isArchiving ? (
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
<>
|
Архивировать проект
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Архивирование...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Archive className="h-4 w-4 mr-2" />
|
|
||||||
Архивировать проект
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleOpenMoveDialog}
|
onClick={handleOpenMoveDialog}
|
||||||
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
|
disabled={!isAdmin || !canEditProjects || isDemoMode}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
|
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
|
||||||
>
|
>
|
||||||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||||||
Переместить в другой воркспейс
|
Переместить в другой воркспейс
|
||||||
@@ -840,7 +920,7 @@ export default function ProjectsPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={handleOpenDeleteDialog}
|
onClick={handleOpenDeleteDialog}
|
||||||
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
|
disabled={!canEditProjects || isDemoMode || settingsProject?.status === "archived"}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
@@ -858,16 +938,9 @@ export default function ProjectsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSaveSettings}
|
onClick={handleSaveSettings}
|
||||||
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
|
disabled={!canEditProjects || isDemoMode}
|
||||||
>
|
>
|
||||||
{isUpdatingSettings ? (
|
Сохранить
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Сохранение...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Сохранить"
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -897,17 +970,10 @@ export default function ProjectsPage() {
|
|||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
|
disabled={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
Удалить
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Удаление...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Удалить"
|
|
||||||
)}
|
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -978,19 +1044,10 @@ export default function ProjectsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleMoveProject}
|
onClick={handleMoveProject}
|
||||||
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
|
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isDemoMode}
|
||||||
>
|
>
|
||||||
{isMoving ? (
|
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||||||
<>
|
Переместить
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
Перемещение...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
|
||||||
Переместить
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import { PlacementStatusSelector } from "@/components/placement-status-selector"
|
|||||||
import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector";
|
import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector";
|
||||||
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 type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
Placement,
|
Placement,
|
||||||
@@ -742,9 +743,12 @@ function CellRenderer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "subscriptions":
|
case "subscriptions":
|
||||||
|
const subsCountValue = placement.placement_post?.subscriptions_count;
|
||||||
|
// Show "—" if subscriptions are hidden due to permissions (0 means hidden)
|
||||||
|
const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue);
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
|
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
|
||||||
{formatNumber(placement.placement_post?.subscriptions_count)}
|
{showSubs}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1076,7 +1080,7 @@ export default function PurchasePlanDetailPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const workspaceId = params?.workspaceId as string;
|
const workspaceId = params?.workspaceId as string;
|
||||||
const projectId = params?.projectId as string;
|
const projectId = params?.projectId as string;
|
||||||
const { projects, hasPermission } = useWorkspace();
|
const { projects, hasPermission, canViewAnalytics, canViewSubscriptions } = useWorkspace();
|
||||||
|
|
||||||
const [placements, setPlacements] = useState<Placement[]>([]);
|
const [placements, setPlacements] = useState<Placement[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -1098,9 +1102,30 @@ export default function PurchasePlanDetailPage() {
|
|||||||
const [showCopyDialog, setShowCopyDialog] = useState(false);
|
const [showCopyDialog, setShowCopyDialog] = useState(false);
|
||||||
const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
|
const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
|
||||||
const [copyingChannels, setCopyingChannels] = useState(false);
|
const [copyingChannels, setCopyingChannels] = useState(false);
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(
|
|
||||||
new Set(DEFAULT_VISIBLE_COLUMNS)
|
// Initialize visible columns based on permissions
|
||||||
);
|
const initialVisibleColumns = useMemo(() => {
|
||||||
|
const columns = new Set(DEFAULT_VISIBLE_COLUMNS);
|
||||||
|
// Hide subscription-related columns if user doesn't have analytics_read permission
|
||||||
|
if (!canViewSubscriptions) {
|
||||||
|
columns.delete("subscriptions");
|
||||||
|
columns.delete("cpf");
|
||||||
|
columns.delete("conversion_24h");
|
||||||
|
columns.delete("conversion_48h");
|
||||||
|
columns.delete("conversion_total");
|
||||||
|
columns.delete("total_unsubs");
|
||||||
|
columns.delete("unsub_percent");
|
||||||
|
columns.delete("total_active");
|
||||||
|
}
|
||||||
|
// Hide views and cpm if user has no analytics permissions at all
|
||||||
|
if (!canViewAnalytics) {
|
||||||
|
columns.delete("views");
|
||||||
|
columns.delete("cpm");
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}, [canViewSubscriptions, canViewAnalytics]);
|
||||||
|
|
||||||
|
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(initialVisibleColumns);
|
||||||
const [showColumnsDialog, setShowColumnsDialog] = useState(false);
|
const [showColumnsDialog, setShowColumnsDialog] = useState(false);
|
||||||
|
|
||||||
// Creative selection dialog
|
// Creative selection dialog
|
||||||
@@ -1228,14 +1253,15 @@ export default function PurchasePlanDetailPage() {
|
|||||||
|
|
||||||
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
|
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
|
||||||
if (!placement.creative_id || isDemoMode) return;
|
if (!placement.creative_id || isDemoMode) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
await placementsApi.sendCreative(workspaceId, projectId, placement.id);
|
placementsApi.sendCreative(workspaceId, projectId, placement.id),
|
||||||
alert("Креатив успешно отправлен");
|
{
|
||||||
} catch (err) {
|
loading: "Отправка креатива...",
|
||||||
console.error("Failed to send creative:", err);
|
success: "Креатив успешно отправлен",
|
||||||
alert("Не удалось отправить креатив");
|
error: "Не удалось отправить креатив",
|
||||||
}
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectCreative = (creative: { id: string; name: string }) => {
|
const handleSelectCreative = (creative: { id: string; name: string }) => {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function PurchasePlansPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workspaceId = params?.workspaceId as string;
|
const workspaceId = params?.workspaceId as string;
|
||||||
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
|
const { projects, isLoadingProjects, currentWorkspace, canViewAnalytics, canViewSubscriptions } = useWorkspace();
|
||||||
|
|
||||||
const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({});
|
const [placementsByProject, setPlacementsByProject] = useState<Record<string, Placement[]>>({});
|
||||||
const [loadingStats, setLoadingStats] = useState(true);
|
const [loadingStats, setLoadingStats] = useState(true);
|
||||||
@@ -222,13 +222,17 @@ export default function PurchasePlansPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">Охват:</span>
|
<span className="text-sm text-muted-foreground">Охват:</span>
|
||||||
<span className="font-medium">{formatCompact(summary.total_views)}</span>
|
<span className="font-medium">
|
||||||
|
{canViewAnalytics ? formatCompact(summary.total_views) : "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">Подписок:</span>
|
<span className="text-sm text-muted-foreground">Подписок:</span>
|
||||||
<span className="font-medium">{formatCompact(summary.total_subscriptions)}</span>
|
<span className="font-medium">
|
||||||
|
{canViewSubscriptions ? formatCompact(summary.total_subscriptions) : "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t space-y-2">
|
<div className="pt-2 border-t space-y-2">
|
||||||
@@ -244,7 +248,9 @@ export default function PurchasePlansPage() {
|
|||||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">CPF</span>
|
<span className="text-sm text-muted-foreground">CPF</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium">{formatCurrency(summary.avg_cpf)}</span>
|
<span className="font-medium">
|
||||||
|
{canViewSubscriptions ? formatCurrency(summary.avg_cpf) : "—"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { toast } from "sonner";
|
||||||
import type { WorkspaceInvite } from "@/lib/types/api";
|
import type { WorkspaceInvite } from "@/lib/types/api";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -51,8 +52,6 @@ export default function InvitesPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInvites();
|
loadInvites();
|
||||||
@@ -74,27 +73,28 @@ export default function InvitesPage() {
|
|||||||
if (!username.trim()) return;
|
if (!username.trim()) return;
|
||||||
|
|
||||||
const cleanUsername = username.trim().replace(/^@/, "");
|
const cleanUsername = username.trim().replace(/^@/, "");
|
||||||
try {
|
await toast.promise(
|
||||||
setIsCreating(true);
|
(async () => {
|
||||||
setError(null);
|
const invite = await invitesApi.create(workspaceId, {
|
||||||
|
username: cleanUsername,
|
||||||
// Remove @ if present
|
});
|
||||||
|
setInvites((prev) => [invite, ...prev]);
|
||||||
const invite = await invitesApi.create(workspaceId, {
|
setShowDialog(false);
|
||||||
username: cleanUsername,
|
setUsername("");
|
||||||
});
|
return invite;
|
||||||
|
})(),
|
||||||
setInvites((prev) => [invite, ...prev]);
|
{
|
||||||
setShowDialog(false);
|
loading: "Отправка приглашения...",
|
||||||
setUsername("");
|
success: `Приглашение отправлено @${cleanUsername}`,
|
||||||
} catch (err: any) {
|
error: (err) => {
|
||||||
const errorMessage = err?.error?.message || err?.detail;
|
const errorMessage = err?.error?.message || err?.detail;
|
||||||
if (errorMessage?.includes("not found")) {
|
if (errorMessage?.includes("not found")) {
|
||||||
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
|
return `Пользователь @${cleanUsername} не зарегистрирован на платформе`;
|
||||||
} else {
|
}
|
||||||
setError(errorMessage || "Не удалось отправить приглашение");
|
return errorMessage || "Не удалось отправить приглашение";
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
|
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
|
||||||
@@ -161,27 +161,22 @@ export default function InvitesPage() {
|
|||||||
приглашения через бота
|
приглашения через бота
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="username">Telegram username</Label>
|
<Label htmlFor="username">Telegram username</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
placeholder="@username"
|
placeholder="@username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
handleCreateInvite();
|
handleCreateInvite();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
</div>
|
||||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -191,13 +186,9 @@ export default function InvitesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateInvite}
|
onClick={handleCreateInvite}
|
||||||
disabled={!username.trim() || isCreating}
|
disabled={!username.trim()}
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<Mail className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Отправить
|
Отправить
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
// Workspace Settings Page
|
// Workspace Settings Page
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock, Upload, ImageIcon } from "lucide-react";
|
import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock, Upload, ImageIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
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";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
|
import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
|
||||||
|
import type { User } from "@/lib/types/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -51,7 +54,13 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { PERMISSION_LABELS } from "@/lib/types/api";
|
import { PERMISSION_LABELS } from "@/lib/types/api";
|
||||||
|
import type { WorkspaceMember, WorkspaceInvite, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
|
||||||
import { PermissionModal } from "@/components/ui/permission-modal";
|
import { PermissionModal } from "@/components/ui/permission-modal";
|
||||||
|
import {
|
||||||
|
handleApiError,
|
||||||
|
showPermissionErrorToast,
|
||||||
|
showSuccessToast,
|
||||||
|
} from "@/lib/utils/error-handler";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
@@ -62,6 +71,8 @@ export default function SettingsPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const workspaceId = params?.workspaceId as string;
|
const workspaceId = params?.workspaceId as string;
|
||||||
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
|
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const currentUser = user;
|
||||||
|
|
||||||
const [name, setName] = useState(currentWorkspace?.name || "");
|
const [name, setName] = useState(currentWorkspace?.name || "");
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
@@ -91,6 +102,14 @@ export default function SettingsPage() {
|
|||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
|
||||||
|
const isOnlyAdmin = useMemo(() => {
|
||||||
|
if (!currentUser || !members.length) return false;
|
||||||
|
const adminMembers = members.filter((m) =>
|
||||||
|
m.permissions.some((p) => p.key === "admin_full")
|
||||||
|
);
|
||||||
|
return adminMembers.length === 1 && adminMembers[0]?.user.id === currentUser.id;
|
||||||
|
}, [currentUser, members]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentWorkspace) {
|
if (currentWorkspace) {
|
||||||
setName(currentWorkspace.name);
|
setName(currentWorkspace.name);
|
||||||
@@ -139,59 +158,142 @@ export default function SettingsPage() {
|
|||||||
wsId: string,
|
wsId: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]
|
permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]
|
||||||
) => {
|
) => {
|
||||||
await membersApi.updatePermissions(wsId, userId, { permissions });
|
await toast.promise(
|
||||||
await loadMembers();
|
membersApi.updatePermissions(wsId, memberId, { permissions }),
|
||||||
|
{
|
||||||
|
loading: "Сохранение прав доступа...",
|
||||||
|
success: () => {
|
||||||
|
loadMembers();
|
||||||
|
return "Права доступа обновлены";
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось сохранить права";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setShowPermissionModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
if (!name.trim() || name === currentWorkspace?.name) return;
|
if (!name.trim() || name === currentWorkspace?.name) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsUpdating(true);
|
workspacesApi.update(workspaceId, { name: name.trim() }),
|
||||||
await workspacesApi.update(workspaceId, { name: name.trim() });
|
{
|
||||||
await refreshWorkspaces();
|
loading: "Сохранение названия...",
|
||||||
} catch (err) {
|
success: () => {
|
||||||
console.error("Failed to update workspace:", err);
|
refreshWorkspaces();
|
||||||
} finally {
|
return "Название обновлено";
|
||||||
setIsUpdating(false);
|
},
|
||||||
}
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось сохранить название";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setIsUpdating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsUploadingAvatar(true);
|
workspacesApi.uploadAvatar(workspaceId, file),
|
||||||
const updatedWorkspace = await workspacesApi.uploadAvatar(workspaceId, file);
|
{
|
||||||
await refreshWorkspaces();
|
loading: "Загрузка аватара...",
|
||||||
if (fileInputRef.current) {
|
success: () => {
|
||||||
fileInputRef.current.value = "";
|
refreshWorkspaces();
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
setAvatarFile(null);
|
||||||
|
return "Аватар загружен";
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось загрузить аватар";
|
||||||
|
},
|
||||||
}
|
}
|
||||||
setAvatarFile(null);
|
);
|
||||||
} catch (err) {
|
setIsUploadingAvatar(false);
|
||||||
console.error("Failed to upload avatar:", err);
|
};
|
||||||
} finally {
|
|
||||||
setIsUploadingAvatar(false);
|
const handleAvatarDelete = async () => {
|
||||||
}
|
await toast.promise(
|
||||||
|
workspacesApi.deleteAvatar(workspaceId),
|
||||||
|
{
|
||||||
|
loading: "Удаление аватара...",
|
||||||
|
success: () => {
|
||||||
|
refreshWorkspaces();
|
||||||
|
return "Аватар удалён";
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось удалить аватар";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setIsDeletingAvatar(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInvite = async () => {
|
||||||
|
if (!username.trim()) return;
|
||||||
|
|
||||||
|
const cleanUsername = username.trim().replace(/^@/, "");
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
invitesApi.create(workspaceId, {
|
||||||
|
username: cleanUsername,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
loading: "Отправка приглашения...",
|
||||||
|
success: (invite) => {
|
||||||
|
setInvites((prev) => [invite, ...prev]);
|
||||||
|
setShowInviteDialog(false);
|
||||||
|
setUsername("");
|
||||||
|
return `Приглашение отправлено @${cleanUsername}`;
|
||||||
|
},
|
||||||
|
error: (err: any) => {
|
||||||
|
const errorMessage = err?.error?.message || err?.detail;
|
||||||
|
if (errorMessage?.includes("not found")) {
|
||||||
|
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе.`);
|
||||||
|
return "Пользователь не найден";
|
||||||
|
}
|
||||||
|
if (errorMessage?.includes("already exists") || errorMessage?.includes("уже")) {
|
||||||
|
return "Приглашение уже существует или пользователь уже участник";
|
||||||
|
}
|
||||||
|
handleApiError(err);
|
||||||
|
return "Не удалось отправить приглашение";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setIsCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
|
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
|
||||||
|
|
||||||
try {
|
await toast.promise(
|
||||||
setIsDeleting(true);
|
workspacesApi.delete(workspaceId),
|
||||||
await workspacesApi.delete(workspaceId);
|
{
|
||||||
router.replace("/");
|
loading: "Удаление воркспейса...",
|
||||||
} catch (err) {
|
success: () => {
|
||||||
console.error("Failed to delete workspace:", err);
|
router.replace("/");
|
||||||
} finally {
|
return "Воркспейс удалён";
|
||||||
setIsDeleting(false);
|
},
|
||||||
setShowDeleteDialog(false);
|
error: (err) => {
|
||||||
setDeleteConfirm("");
|
handleApiError(err);
|
||||||
}
|
return "Не удалось удалить воркспейс";
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setIsDeleting(false);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteConfirm("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
|
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
|
||||||
@@ -522,20 +624,20 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
member.status === "active" ? "default" : "secondary"
|
member.status === "active" ? "default" : "secondary"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.status === "active" ? "Активен" : "Приглашён"}
|
{member.status === "active" ? "Активен" : "Приглашён"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{renderPermissionBadges(member)}
|
{renderPermissionBadges(member)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -637,6 +739,8 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
onSave={handlePermissionSave}
|
onSave={handlePermissionSave}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
|
currentUserId={currentUser?.id || ""}
|
||||||
|
isOnlyAdmin={isOnlyAdmin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -20,7 +21,10 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>
|
||||||
|
{children}
|
||||||
|
<Toaster position="top-center" richColors />
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
|
|
||||||
if (isLoadingProjects) {
|
if (isLoadingProjects) {
|
||||||
return (
|
return (
|
||||||
<div className="w-[240px] h-11 rounded-lg border border-input bg-background animate-pulse" />
|
<div className="w-[240px] h-10 rounded-lg border border-input bg-background animate-pulse" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,45 +45,56 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
|
|
||||||
const allSelected = selectedProjects.length === projects.length;
|
const allSelected = selectedProjects.length === projects.length;
|
||||||
|
|
||||||
|
const getAvatarUrl = (project: typeof projects[0]) => {
|
||||||
|
return project.username
|
||||||
|
? `https://t.me/i/userpic/320/${project.username}.jpg`
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-[280px] h-auto min-h-[44px] py-2 px-3 rounded-lg justify-between"
|
className="w-[240px] h-10 px-3 rounded-lg justify-start gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-start text-left overflow-hidden">
|
{selectedProjects.length === 0 ? (
|
||||||
{selectedProjects.length === 0 ? (
|
<>
|
||||||
<span className="text-muted-foreground">Выберите проекты</span>
|
<span className="text-muted-foreground">Все проекты</span>
|
||||||
) : selectedProjects.length === 1 ? (
|
</>
|
||||||
<>
|
) : selectedProjects.length === 1 ? (
|
||||||
<span className="font-semibold text-base truncate max-w-[220px]">
|
<>
|
||||||
{selectedProjects[0].title}
|
<Avatar className="h-6 w-6 shrink-0">
|
||||||
</span>
|
<AvatarImage src={getAvatarUrl(selectedProjects[0]) || undefined} />
|
||||||
{selectedProjects[0].username && (
|
<AvatarFallback className="text-xs">
|
||||||
<span className="text-xs text-muted-foreground -mt-0.5">
|
{selectedProjects[0].title.charAt(0).toUpperCase()}
|
||||||
@{selectedProjects[0].username}
|
</AvatarFallback>
|
||||||
</span>
|
</Avatar>
|
||||||
)}
|
<span className="truncate max-w-[180px]">
|
||||||
</>
|
{selectedProjects[0].title}
|
||||||
) : allSelected ? (
|
</span>
|
||||||
<span className="font-semibold text-base">Все проекты</span>
|
</>
|
||||||
) : (
|
) : allSelected ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold text-base">
|
<span className="font-medium">Все проекты</span>
|
||||||
{selectedProjects.length} проекта
|
</>
|
||||||
</span>
|
) : (
|
||||||
<span className="text-xs text-muted-foreground -mt-0.5 truncate max-w-[220px]">
|
<>
|
||||||
{selectedProjects.map((p) => p.title).join(", ")}
|
<Avatar className="h-6 w-6 shrink-0">
|
||||||
</span>
|
<AvatarFallback className="text-xs">
|
||||||
</>
|
{selectedProjects.length}
|
||||||
)}
|
</AvatarFallback>
|
||||||
</div>
|
</Avatar>
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
<span className="truncate max-w-[180px]">
|
||||||
|
{selectedProjects.length} проекта
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-[280px] p-2" align="start">
|
<DropdownMenuContent className="w-[240px] p-2" align="start">
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<div className="flex gap-2 mb-2">
|
<div className="flex gap-2 mb-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -117,7 +129,7 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 px-2 py-2 rounded-md cursor-pointer hover:bg-accent",
|
"flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer hover:bg-accent",
|
||||||
isSelected && "bg-accent/50"
|
isSelected && "bg-accent/50"
|
||||||
)}
|
)}
|
||||||
onClick={() => !isOnlyOne && toggleProject(project)}
|
onClick={() => !isOnlyOne && toggleProject(project)}
|
||||||
@@ -128,15 +140,13 @@ export const ProjectSelector: React.FC = () => {
|
|||||||
onCheckedChange={() => !isOnlyOne && toggleProject(project)}
|
onCheckedChange={() => !isOnlyOne && toggleProject(project)}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
<Tv className="h-4 w-4 text-muted-foreground shrink-0" />
|
<Avatar className="h-5 w-5 shrink-0">
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<AvatarImage src={getAvatarUrl(project) || undefined} />
|
||||||
<span className="font-medium truncate">{project.title}</span>
|
<AvatarFallback className="text-[10px]">
|
||||||
{project.username && (
|
{project.title.charAt(0).toUpperCase()}
|
||||||
<span className="text-xs text-muted-foreground">
|
</AvatarFallback>
|
||||||
@{project.username}
|
</Avatar>
|
||||||
</span>
|
<span className="truncate text-sm">{project.title}</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useParams, useRouter, usePathname } from "next/navigation";
|
import { useParams, useRouter, usePathname } from "next/navigation";
|
||||||
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
|
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
|
||||||
@@ -57,6 +58,10 @@ interface WorkspaceContextType {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
// Check if user is admin in a specific workspace
|
// Check if user is admin in a specific workspace
|
||||||
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
||||||
|
// Analytics permission helpers (memoized values, not functions)
|
||||||
|
hasAnyAnalyticsPermission: boolean;
|
||||||
|
canViewSubscriptions: boolean;
|
||||||
|
canViewAnalytics: boolean;
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -182,6 +187,32 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
|||||||
[permissions, isAdmin]
|
[permissions, isAdmin]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Analytics Permission Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Check if user has any analytics permission
|
||||||
|
const hasAnyAnalyticsPermission = useMemo((): boolean => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
return (
|
||||||
|
hasPermission("analytics_read") ||
|
||||||
|
hasPermission("analytics_without_clicks") ||
|
||||||
|
hasPermission("analytics_own_creatives")
|
||||||
|
);
|
||||||
|
}, [hasPermission, isAdmin]);
|
||||||
|
|
||||||
|
// Check if user can view subscriptions (false if only analytics_without_clicks)
|
||||||
|
const canViewSubscriptions = useMemo((): boolean => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
return hasPermission("analytics_read");
|
||||||
|
}, [hasPermission, isAdmin]);
|
||||||
|
|
||||||
|
// Check if user can view analytics (any analytics permission)
|
||||||
|
const canViewAnalytics = useMemo((): boolean => {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
return hasAnyAnalyticsPermission;
|
||||||
|
}, [hasAnyAnalyticsPermission, isAdmin]);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Load Workspaces
|
// Load Workspaces
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -519,6 +550,9 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
|||||||
hasPermission,
|
hasPermission,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isWorkspaceAdmin,
|
isWorkspaceAdmin,
|
||||||
|
hasAnyAnalyticsPermission,
|
||||||
|
canViewSubscriptions,
|
||||||
|
canViewAnalytics,
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Loader2, Shield, Search, X, Plus, Check, Info } from "lucide-react";
|
|
||||||
import type { WorkspaceMember, Project, PermissionKey } from "@/lib/types/api";
|
|
||||||
import { PERMISSION_LABELS, PERMISSION_GROUPS, PERMISSION_ICONS } from "@/lib/types/permission";
|
|
||||||
import {
|
import {
|
||||||
hasGlobalPermission,
|
Loader2,
|
||||||
hasScopedPermission,
|
Shield,
|
||||||
getScopesForPermission,
|
Search,
|
||||||
setScopedPermission,
|
X,
|
||||||
toggleScopedPermission,
|
Check,
|
||||||
removePermission,
|
Info,
|
||||||
calculatePermissionDiff,
|
Crown,
|
||||||
} from "@/lib/types/permission";
|
FolderOpen,
|
||||||
import { Button } from "@/components/ui/button";
|
Palette,
|
||||||
import { Input } from "@/components/ui/input";
|
ClipboardList,
|
||||||
import { Badge } from "@/components/ui/badge";
|
BarChart3,
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
Users,
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
Lock,
|
||||||
|
Eye,
|
||||||
|
Pencil,
|
||||||
|
ChartArea,
|
||||||
|
UserRoundX,
|
||||||
|
FileUser,
|
||||||
|
Globe,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { WorkspaceMember, Project, PermissionKey, PermissionScope } from "@/lib/types/api";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -31,7 +36,12 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PermissionModalProps {
|
interface PermissionModalProps {
|
||||||
@@ -39,8 +49,10 @@ interface PermissionModalProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
member: WorkspaceMember;
|
member: WorkspaceMember;
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]) => Promise<void>;
|
onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]) => Promise<void>;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
currentUserId: string;
|
||||||
|
isOnlyAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionState {
|
interface PermissionState {
|
||||||
@@ -49,6 +61,27 @@ interface PermissionState {
|
|||||||
projectIds: string[];
|
projectIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PERMISSION_CONFIG: Record<PermissionKey, { icon: typeof Crown; label: string; group: string }> = {
|
||||||
|
admin_full: { icon: Crown, label: "Полный доступ", group: "admin" },
|
||||||
|
projects_read: { icon: Eye, label: "Просмотр проектов", group: "projects" },
|
||||||
|
projects_write: { icon: Pencil, label: "Управление проектами", group: "projects" },
|
||||||
|
creatives_read: { icon: Eye, label: "Просмотр креативов", group: "creatives" },
|
||||||
|
creatives_write: { icon: Pencil, label: "Создание и редактирование креативов", group: "creatives" },
|
||||||
|
placements_read: { icon: Eye, label: "Просмотр закупок", group: "placements" },
|
||||||
|
placements_write: { icon: Pencil, label: "Создание и редактирование закупок", group: "placements" },
|
||||||
|
analytics_read: { icon: ChartArea, label: "Полный доступ к статистике", group: "analytics" },
|
||||||
|
analytics_without_clicks: { icon: UserRoundX, label: "Статистика без переходов", group: "analytics" },
|
||||||
|
analytics_own_creatives: { icon: FileUser, label: "Статистика только своих креативов", group: "analytics" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUPS = [
|
||||||
|
{ key: "admin", label: "Администрирование", icon: Shield },
|
||||||
|
{ key: "projects", label: "Проекты", icon: FolderOpen },
|
||||||
|
{ key: "creatives", label: "Креативы", icon: Palette },
|
||||||
|
{ key: "placements", label: "Закупки", icon: ClipboardList },
|
||||||
|
{ key: "analytics", label: "Аналитика", icon: BarChart3 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
{ label: "Полный доступ", keys: ["admin_full"] },
|
{ label: "Полный доступ", keys: ["admin_full"] },
|
||||||
{ label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] },
|
{ label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] },
|
||||||
@@ -63,6 +96,8 @@ export function PermissionModal({
|
|||||||
projects,
|
projects,
|
||||||
onSave,
|
onSave,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
currentUserId,
|
||||||
|
isOnlyAdmin,
|
||||||
}: PermissionModalProps) {
|
}: PermissionModalProps) {
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [permissions, setPermissions] = useState<PermissionState[]>([]);
|
const [permissions, setPermissions] = useState<PermissionState[]>([]);
|
||||||
@@ -89,7 +124,18 @@ export function PermissionModal({
|
|||||||
}
|
}
|
||||||
}, [open, member]);
|
}, [open, member]);
|
||||||
|
|
||||||
|
const hasGlobalAdmin = useMemo(() => {
|
||||||
|
const admin = permissions.find((p) => p.key === "admin_full");
|
||||||
|
return admin?.isGlobal ?? false;
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
const isPermissionDisabled = (key: PermissionKey): boolean => {
|
||||||
|
return hasGlobalAdmin && key !== "admin_full";
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleGlobal = (key: PermissionKey) => {
|
const handleToggleGlobal = (key: PermissionKey) => {
|
||||||
|
if (hasGlobalAdmin && key !== "admin_full") return;
|
||||||
|
|
||||||
setPermissions((prev) => {
|
setPermissions((prev) => {
|
||||||
const existing = prev.find((p) => p.key === key);
|
const existing = prev.find((p) => p.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -102,6 +148,8 @@ export function PermissionModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleProject = (key: PermissionKey, projectId: string) => {
|
const handleToggleProject = (key: PermissionKey, projectId: string) => {
|
||||||
|
if (hasGlobalAdmin && key !== "admin_full") return;
|
||||||
|
|
||||||
setPermissions((prev) => {
|
setPermissions((prev) => {
|
||||||
const existing = prev.find((p) => p.key === key);
|
const existing = prev.find((p) => p.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -118,40 +166,6 @@ export function PermissionModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProject = (key: PermissionKey, projectId: string) => {
|
|
||||||
setPermissions((prev) => {
|
|
||||||
const existing = prev.find((p) => p.key === key);
|
|
||||||
if (existing) {
|
|
||||||
return prev.map((p) =>
|
|
||||||
p.key === key
|
|
||||||
? { ...p, isGlobal: false, projectIds: [...new Set([...p.projectIds, projectId])] }
|
|
||||||
: p
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
...prev,
|
|
||||||
{ key, isGlobal: false, projectIds: [projectId] },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveProject = (key: PermissionKey, projectId: string) => {
|
|
||||||
setPermissions((prev) => {
|
|
||||||
const existing = prev.find((p) => p.key === key);
|
|
||||||
if (!existing) return prev;
|
|
||||||
|
|
||||||
const newProjectIds = existing.projectIds.filter((id) => id !== projectId);
|
|
||||||
|
|
||||||
if (newProjectIds.length === 0) {
|
|
||||||
return prev.filter((p) => p.key !== key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return prev.map((p) =>
|
|
||||||
p.key === key ? { ...p, projectIds: newProjectIds } : p
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleInArray = (arr: string[], item: string): string[] => {
|
const toggleInArray = (arr: string[], item: string): string[] => {
|
||||||
if (arr.includes(item)) {
|
if (arr.includes(item)) {
|
||||||
return arr.filter((i) => i !== item);
|
return arr.filter((i) => i !== item);
|
||||||
@@ -255,35 +269,68 @@ export function PermissionModal({
|
|||||||
setPermissions(perms);
|
setPermissions(perms);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderPermissionRow = (key: PermissionKey, group: string) => {
|
const isEditingOwnPermissions = member.user.id === currentUserId;
|
||||||
|
|
||||||
|
const renderPermissionRow = (key: PermissionKey) => {
|
||||||
|
const config = PERMISSION_CONFIG[key];
|
||||||
const current = permissions.find((p) => p.key === key);
|
const current = permissions.find((p) => p.key === key);
|
||||||
const currentProjectIds = current?.projectIds || [];
|
const currentProjectIds = current?.projectIds || [];
|
||||||
const isGlobal = current?.isGlobal ?? false;
|
const isGlobal = current?.isGlobal ?? false;
|
||||||
|
const isDisabled = isPermissionDisabled(key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="space-y-2">
|
<div
|
||||||
<div className="flex items-center justify-between gap-4">
|
key={key}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
className={cn(
|
||||||
<span className="text-lg">{PERMISSION_ICONS[key]}</span>
|
"space-y-2 transition-opacity",
|
||||||
<span className="font-medium truncate">{PERMISSION_LABELS[key]}</span>
|
isDisabled && "opacity-50"
|
||||||
<Badge variant="outline" className="text-xs shrink-0">
|
)}
|
||||||
{group}
|
>
|
||||||
</Badge>
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{isDisabled ? (
|
||||||
|
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<config.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className={cn("font-medium text-sm truncate", isDisabled && "text-muted-foreground")}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
{isDisabled && (
|
||||||
|
<Badge variant="secondary" className="text-xs shrink-0">
|
||||||
|
Выдан полный доступ
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant={isGlobal ? "default" : "outline"}
|
variant={isGlobal ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleToggleGlobal(key)}
|
onClick={() => handleToggleGlobal(key)}
|
||||||
className="text-xs"
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
"text-xs shrink-0",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
<Globe className="h-3 w-3 mr-1" />
|
||||||
Глобально
|
Глобально
|
||||||
</Button>
|
</Button>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant={!isGlobal ? "default" : "outline"} size="sm" className="text-xs">
|
<Button
|
||||||
|
variant={!isGlobal ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
"text-xs shrink-0 min-w-[100px]",
|
||||||
|
isDisabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Search className="h-3 w-3 mr-1" />
|
<Search className="h-3 w-3 mr-1" />
|
||||||
Проекты ({currentProjectIds.length})
|
{currentProjectIds.length > 0
|
||||||
|
? `${currentProjectIds.length} проект${currentProjectIds.length === 1 ? "" : currentProjectIds.length > 4 ? "ов" : "а"}`
|
||||||
|
: "Проекты"}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80" align="end">
|
<PopoverContent className="w-80" align="end">
|
||||||
@@ -304,17 +351,23 @@ export function PermissionModal({
|
|||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
|
"flex items-center gap-2 p-2 rounded-md cursor-pointer transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "hover:bg-muted"
|
: "hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
onClick={() => handleToggleProject(key, project.id)}
|
onClick={() => handleToggleProject(key, project.id)}
|
||||||
>
|
>
|
||||||
<div className="truncate">
|
<Avatar className="h-6 w-6 shrink-0">
|
||||||
<div className="font-medium truncate">{project.title}</div>
|
<AvatarImage src={project.username ? undefined : undefined} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{project.title.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="truncate min-w-0 flex-1">
|
||||||
|
<div className="font-medium text-sm truncate">{project.title}</div>
|
||||||
{project.username && (
|
{project.username && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
@{project.username}
|
@{project.username}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -337,19 +390,42 @@ export function PermissionModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isGlobal && currentProjectIds.length > 0 && (
|
{!isGlobal && currentProjectIds.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 pl-8">
|
<div className="flex flex-wrap gap-1 ml-6">
|
||||||
{currentProjectIds.map((id) => {
|
{currentProjectIds.map((id) => {
|
||||||
const project = getProjectById(id);
|
const project = getProjectById(id);
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
key={id}
|
key={id}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="gap-1 text-xs"
|
className={cn("gap-1 text-xs", isDisabled && "opacity-50")}
|
||||||
>
|
>
|
||||||
{project?.title || "Unknown"}
|
<Avatar className="h-4 w-4">
|
||||||
|
<AvatarFallback className="text-[10px]">
|
||||||
|
{project?.title.charAt(0).toUpperCase() || "?"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="truncate max-w-[150px]">{project?.title || "Unknown"}</span>
|
||||||
<X
|
<X
|
||||||
className="h-3 w-3 cursor-pointer hover:text-destructive"
|
className={cn(
|
||||||
onClick={() => handleRemoveProject(key, id)}
|
"h-3 w-3 ml-1",
|
||||||
|
isDisabled
|
||||||
|
? "cursor-not-allowed text-muted-foreground/50"
|
||||||
|
: "cursor-pointer hover:text-destructive"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDisabled) return;
|
||||||
|
setPermissions((prev) => {
|
||||||
|
const existing = prev.find((p) => p.key === key);
|
||||||
|
if (!existing) return prev;
|
||||||
|
const newProjectIds = existing.projectIds.filter((pid) => pid !== id);
|
||||||
|
if (newProjectIds.length === 0) {
|
||||||
|
return prev.filter((p) => p.key !== key);
|
||||||
|
}
|
||||||
|
return prev.map((p) =>
|
||||||
|
p.key === key ? { ...p, projectIds: newProjectIds } : p
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -362,18 +438,30 @@ export function PermissionModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
<DialogContent className="max-w-3xl w-[98vw] max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5" />
|
<Users className="h-5 w-5" />
|
||||||
Права доступа
|
Права доступа
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Настройте права доступа для участника {member.user.username || `User ${member.user.telegram_id}`}
|
Настройте права доступа для участника{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{member.user.first_name || member.user.username || `User ${member.user.telegram_id}`}
|
||||||
|
</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-4">
|
{isEditingOwnPermissions && isOnlyAdmin && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
|
||||||
|
<Lock className="h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Вы не можете изменить свои права, так как вы единственный администратор воркспейса.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 px-1">
|
||||||
<div className="flex flex-wrap gap-2 pb-2">
|
<div className="flex flex-wrap gap-2 pb-2">
|
||||||
<span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span>
|
<span className="text-sm text-muted-foreground mr-2">Быстрые настройки:</span>
|
||||||
{PRESETS.map((preset) => (
|
{PRESETS.map((preset) => (
|
||||||
@@ -392,54 +480,47 @@ export function PermissionModal({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
{GROUPS.slice(1).map((group) => {
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
const groupPermissions = Object.entries(PERMISSION_CONFIG)
|
||||||
👑 Администрирование
|
.filter(([, config]) => config.group === group.key)
|
||||||
</h4>
|
.map(([key, config]) => ({ ...config, key: key as PermissionKey }));
|
||||||
{PERMISSION_GROUPS.admin.map((key) => renderPermissionRow(key, "admin"))}
|
|
||||||
|
if (groupPermissions.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||||
|
<group.icon className="h-4 w-4" />
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pl-6 border-l-2 border-muted ml-2">
|
||||||
|
{groupPermissions.map((perm) => renderPermissionRow(perm.key))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Администрирование
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3 pl-6 border-l-2 border-muted ml-2">
|
||||||
<Separator />
|
{Object.entries(PERMISSION_CONFIG)
|
||||||
|
.filter(([, config]) => config.group === "admin")
|
||||||
<div className="space-y-2">
|
.map(([key, config]) => ({ ...config, key: key as PermissionKey }))
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
.map((perm) => renderPermissionRow(perm.key))}
|
||||||
📁 Проекты
|
|
||||||
</h4>
|
|
||||||
{PERMISSION_GROUPS.projects.map((key) => renderPermissionRow(key, "projects"))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
🎨 Креативы
|
|
||||||
</h4>
|
|
||||||
{PERMISSION_GROUPS.creatives.map((key) => renderPermissionRow(key, "creatives"))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
📋 Закупы
|
|
||||||
</h4>
|
|
||||||
{PERMISSION_GROUPS.placements.map((key) => renderPermissionRow(key, "placements"))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
📊 Аналитика
|
|
||||||
</h4>
|
|
||||||
{PERMISSION_GROUPS.analytics.map((key) => renderPermissionRow(key, "analytics"))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
|
||||||
<Info className="h-4 w-4 shrink-0" />
|
<Info className="h-4 w-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Глобальные права применяются ко всему воркспейсу. Права на проекты применяются только к выбранным проектам.
|
<strong>Полный доступ</strong> автоматически включает все остальные права.
|
||||||
|
Остальные права можно оставить для случая, если позже потребуется убрать полный доступ.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -449,10 +530,13 @@ export function PermissionModal({
|
|||||||
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
|
{isDirty && <span className="text-primary">Есть несохранённые изменения</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleReset} disabled={isSaving}>
|
<Button variant="outline" onClick={handleReset} disabled={isSaving || (isEditingOwnPermissions && isOnlyAdmin)}>
|
||||||
Сбросить
|
Сбросить
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!isDirty || isSaving}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || isSaving || (isEditingOwnPermissions && isOnlyAdmin)}
|
||||||
|
>
|
||||||
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
{isSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,16 +20,19 @@ export const membersApi = {
|
|||||||
{ params }
|
{ params }
|
||||||
),
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update member permissions
|
* Update member permissions
|
||||||
*/
|
* @param workspaceId - workspace id
|
||||||
|
* @param workspaceUserId - workspace_user.id (not user.id!)
|
||||||
|
* @param data - permissions to update
|
||||||
|
*/
|
||||||
updatePermissions: (
|
updatePermissions: (
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
workspaceUserId: string,
|
||||||
data: UpdatePermissionsRequest
|
data: UpdatePermissionsRequest
|
||||||
) =>
|
) =>
|
||||||
api.put<WorkspaceMember>(
|
api.put<WorkspaceMember>(
|
||||||
`/workspaces/${workspaceId}/members/${userId}/permissions`,
|
`/workspaces/${workspaceId}/members/${workspaceUserId}/permissions`,
|
||||||
data
|
data
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
72
lib/utils/error-handler.ts
Normal file
72
lib/utils/error-handler.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// API Error Types & Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { ApiError } from "@/lib/types/api";
|
||||||
|
|
||||||
|
export interface PermissionErrorInfo {
|
||||||
|
isPermissionError: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPermissionError(error: ApiError): boolean {
|
||||||
|
return (
|
||||||
|
error.detail?.includes("access denied") ||
|
||||||
|
error.detail?.includes("доступ запрещён") ||
|
||||||
|
error.detail?.includes("недостаточно прав") ||
|
||||||
|
error.detail?.includes("403")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPermissionErrorMessage(error: ApiError): string {
|
||||||
|
if (isPermissionError(error)) {
|
||||||
|
return "Недостаточно прав. Обратитесь к администратору.";
|
||||||
|
}
|
||||||
|
return error.detail || "Произошла ошибка";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleApiError(error: unknown, customMessage?: string): ApiError {
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
|
||||||
|
if (isPermissionError(apiError)) {
|
||||||
|
toast.error("Недостаточно прав", {
|
||||||
|
description: "Обратитесь к администратору workspace",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(customMessage || "Ошибка", {
|
||||||
|
description: apiError.detail || "Произошла ошибка при выполнении операции",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPermissionErrorToast(): void {
|
||||||
|
toast.error("Недостаточно прав", {
|
||||||
|
description: "Обратитесь к администратору workspace",
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSuccessToast(message: string, description?: string): void {
|
||||||
|
toast.success(message, {
|
||||||
|
description,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showErrorToast(message: string, description?: string): void {
|
||||||
|
toast.error(message, {
|
||||||
|
description,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showLoadingToast(message: string): string | number {
|
||||||
|
return toast.loading(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismissToast(id: string | number): void {
|
||||||
|
toast.dismiss(id);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user