feat: update permissions
This commit is contained in:
@@ -74,6 +74,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { handleApiError } from "@/lib/utils/error-handler";
|
||||
import { BOT_USERNAME } from "@/lib/utils/constants";
|
||||
import { ProjectSelector } from "@/components/project-selector";
|
||||
import type { Creative, CreativeAnalyticsItem } from "@/lib/types/api";
|
||||
@@ -157,17 +159,21 @@ export default function CreativesPage() {
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||
|
||||
const canWrite = hasPermission("placements_write");
|
||||
// Permission checks
|
||||
const canReadCreatives = hasPermission("creatives_read");
|
||||
const canWriteCreatives = hasPermission("creatives_write");
|
||||
const canViewAnalytics = hasPermission("analytics_read") || hasPermission("analytics_without_clicks") || hasPermission("analytics_own_creatives");
|
||||
const canViewSubscriptions = hasPermission("analytics_read") || hasPermission("analytics_own_creatives");
|
||||
|
||||
useEffect(() => {
|
||||
loadCreatives();
|
||||
}, [workspaceId, projectFilterId, includeArchived]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creatives.length > 0) {
|
||||
if (creatives.length > 0 && canViewAnalytics) {
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [creatives, workspaceId, projectFilterId]);
|
||||
}, [creatives, workspaceId, projectFilterId, canViewAnalytics]);
|
||||
|
||||
const loadCreatives = async () => {
|
||||
try {
|
||||
@@ -179,13 +185,18 @@ export default function CreativesPage() {
|
||||
});
|
||||
setCreatives(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load creatives:", err);
|
||||
handleApiError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
if (!canViewAnalytics) {
|
||||
setAnalyticsData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingAnalytics(true);
|
||||
const response = await demoAwareAnalyticsApi.creatives(workspaceId, {
|
||||
@@ -209,24 +220,42 @@ export default function CreativesPage() {
|
||||
}, [creatives, analyticsData]);
|
||||
|
||||
const handleArchive = async (creative: Creative) => {
|
||||
try {
|
||||
const newStatus = creative.status === "active" ? "archived" : "active";
|
||||
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
|
||||
loadCreatives();
|
||||
} catch (err) {
|
||||
console.error("Failed to archive creative:", err);
|
||||
}
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const newStatus = creative.status === "active" ? "archived" : "active";
|
||||
await creativesApi.update(workspaceId, creative.id, { status: newStatus });
|
||||
await loadCreatives();
|
||||
})(),
|
||||
{
|
||||
loading: creative.status === "active" ? "Архивирование..." : "Восстановление...",
|
||||
success: () => {
|
||||
return creative.status === "active"
|
||||
? `Креатив «${creative.name}» архивирован`
|
||||
: `Креатив «${creative.name}» восстановлен`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось изменить статус";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async (creativeId: string) => {
|
||||
if (!confirm("Удалить креатив? Это действие необратимо.")) return;
|
||||
|
||||
try {
|
||||
await creativesApi.delete(workspaceId, creativeId);
|
||||
loadCreatives();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete creative:", err);
|
||||
}
|
||||
const handleDelete = async (creativeId: string, creativeName: string) => {
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
await creativesApi.delete(workspaceId, creativeId);
|
||||
await loadCreatives();
|
||||
})(),
|
||||
{
|
||||
loading: "Удаление креатива...",
|
||||
success: () => `Креатив «${creativeName}» удалён`,
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось удалить креатив";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -254,14 +283,16 @@ export default function CreativesPage() {
|
||||
|
||||
// Totals
|
||||
const totals = useMemo(() => {
|
||||
const data = filteredCreatives.filter((c) => c.analytics);
|
||||
const data = filteredCreatives.filter((c) => canViewAnalytics && c.analytics);
|
||||
return {
|
||||
placements: data.reduce((sum, c) => sum + (c.analytics?.placements_count || 0), 0),
|
||||
cost: data.reduce((sum, c) => sum + (c.analytics?.total_cost || 0), 0),
|
||||
subscriptions: data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0),
|
||||
subscriptions: canViewSubscriptions
|
||||
? data.reduce((sum, c) => sum + (c.analytics?.total_subscriptions || 0), 0)
|
||||
: 0,
|
||||
views: data.reduce((sum, c) => sum + (c.analytics?.total_views || 0), 0),
|
||||
};
|
||||
}, [filteredCreatives]);
|
||||
}, [filteredCreatives, canViewAnalytics, canViewSubscriptions]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -300,7 +331,7 @@ export default function CreativesPage() {
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{canWrite && (
|
||||
{canWriteCreatives && (
|
||||
<Button onClick={handleCreateCreativeClick}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать креатив
|
||||
@@ -346,7 +377,7 @@ export default function CreativesPage() {
|
||||
? "Ничего не найдено по вашему запросу"
|
||||
: "Создайте первый креатив для начала работы"}
|
||||
</p>
|
||||
{canWrite && !search && (
|
||||
{canWriteCreatives && !search && (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать креатив
|
||||
@@ -354,7 +385,7 @@ export default function CreativesPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === "table" ? (
|
||||
) : viewMode === "table" ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -387,19 +418,21 @@ export default function CreativesPage() {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Подписчики
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Привлеченные подписчики
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
{canViewSubscriptions && (
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Подписчики
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Привлеченные подписчики
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
Сред. CPS
|
||||
@@ -454,28 +487,44 @@ export default function CreativesPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary">
|
||||
{creative.analytics?.placements_count ?? creative.placements_count}
|
||||
{canViewAnalytics
|
||||
? creative.analytics?.placements_count ?? creative.placements_count
|
||||
: creative.placements_count}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
{canViewAnalytics ? (
|
||||
loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatCurrency(creative.analytics?.total_cost)
|
||||
)
|
||||
) : (
|
||||
formatCurrency(creative.analytics?.total_cost)
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
{canViewSubscriptions && (
|
||||
<TableCell className="text-right">
|
||||
{canViewAnalytics ? (
|
||||
loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatNumber(creative.analytics?.total_subscriptions)
|
||||
)
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-right">
|
||||
{loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
{canViewAnalytics ? (
|
||||
loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatCurrency(creative.analytics?.avg_cpf)
|
||||
)
|
||||
) : (
|
||||
formatNumber(creative.analytics?.total_subscriptions)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{loadingAnalytics ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin inline" />
|
||||
) : (
|
||||
formatCurrency(creative.analytics?.avg_cpf)
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -487,20 +536,20 @@ export default function CreativesPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{canWrite && creative.status === "active" && (
|
||||
{canWriteCreatives && creative.status === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
|
||||
title="Создать размещение"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/purchase-plans/${creative.project_id}?openPlacement=true&creative_id=${creative.id}`}
|
||||
title="Создать размещение"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -510,32 +559,28 @@ export default function CreativesPage() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${creative.id}`}
|
||||
>
|
||||
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}`}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Просмотр
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Аналитика
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{canViewAnalytics && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/dashboard/${workspaceId}/analytics/placements?creative_id=${creative.id}`}>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Аналитика
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => handleCopyText(creative.text)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Копировать текст
|
||||
</DropdownMenuItem>
|
||||
{canWrite && (
|
||||
{canWriteCreatives && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}
|
||||
>
|
||||
<Link href={`/dashboard/${workspaceId}/creatives/${creative.id}?edit=true`}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Редактировать
|
||||
</Link>
|
||||
@@ -546,7 +591,7 @@ export default function CreativesPage() {
|
||||
</DropdownMenuItem>
|
||||
{creative.placements_count === 0 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(creative.id)}
|
||||
onClick={() => handleDelete(creative.id, creative.name)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
@@ -594,7 +639,7 @@ export default function CreativesPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats */}
|
||||
{creative.analytics && (
|
||||
{canViewAnalytics && creative.analytics && (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -605,15 +650,17 @@ export default function CreativesPage() {
|
||||
<div className="text-xs text-muted-foreground">Потрачено</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(creative.analytics.total_subscriptions)}
|
||||
{canViewSubscriptions && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{formatNumber(creative.analytics.total_subscriptions)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Подписчиков</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Подписчиков</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 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 Link from "next/link";
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Info,
|
||||
Bot,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
FolderKanban,
|
||||
Archive,
|
||||
RotateCcw,
|
||||
@@ -60,6 +61,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -86,6 +88,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
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 type { Project, SpendingAnalytics } from "@/lib/types/api";
|
||||
|
||||
@@ -143,48 +147,62 @@ const formatStatus = (status: string): string => {
|
||||
export default function ProjectsPage() {
|
||||
const params = useParams();
|
||||
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 [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
|
||||
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 [deleteConfirmationText, setDeleteConfirmationText] = useState("");
|
||||
|
||||
// Move project state
|
||||
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
||||
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = 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.
|
||||
const refreshProjectsRef = useRef(refreshProjects);
|
||||
// Load projects with include_archived support
|
||||
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(() => {
|
||||
refreshProjectsRef.current = refreshProjects;
|
||||
}, [refreshProjects]);
|
||||
|
||||
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]);
|
||||
loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
const handleOpenSettings = (project: Project) => {
|
||||
setSettingsProject(project);
|
||||
@@ -194,69 +212,97 @@ export default function ProjectsPage() {
|
||||
const handleSaveSettings = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsUpdatingSettings(true);
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
|
||||
purchase_invite_type_default: inviteLinkType,
|
||||
});
|
||||
await refreshProjects();
|
||||
setSettingsProject(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to update settings:", err);
|
||||
} finally {
|
||||
setIsUpdatingSettings(false);
|
||||
}
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
|
||||
purchase_invite_type_default: inviteLinkType,
|
||||
});
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Сохранение настроек...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
return "Настройки сохранены";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось сохранить настройки";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsArchiving(true);
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.archive(workspaceId, settingsProject.id);
|
||||
await refreshProjects();
|
||||
setSettingsProject(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to archive project:", err);
|
||||
} finally {
|
||||
setIsArchiving(false);
|
||||
}
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.archive(workspaceId, settingsProject.id);
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Архивирование проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
return `Проект «${settingsProject.title}» архивирован`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось архивировать проект";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnarchive = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsUnarchiving(true);
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.unarchive(workspaceId, settingsProject.id);
|
||||
await refreshProjects();
|
||||
setSettingsProject(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to unarchive project:", err);
|
||||
} finally {
|
||||
setIsUnarchiving(false);
|
||||
}
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.unarchive(workspaceId, settingsProject.id);
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Восстановление проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
return `Проект «${settingsProject.title}» восстановлен`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось восстановить проект";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!settingsProject) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.delete(workspaceId, settingsProject.id);
|
||||
await refreshProjects();
|
||||
setSettingsProject(null);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirmationText("");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete project:", err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.delete(workspaceId, settingsProject.id);
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Удаление проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirmationText("");
|
||||
return `Проект «${settingsProject.title}» удалён`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось удалить проект";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenDeleteDialog = () => {
|
||||
@@ -312,20 +358,29 @@ export default function ProjectsPage() {
|
||||
const handleMoveProject = async () => {
|
||||
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
|
||||
|
||||
try {
|
||||
setIsMoving(true);
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
|
||||
await refreshProjects();
|
||||
setSettingsProject(null);
|
||||
setShowMoveDialog(false);
|
||||
setTargetWorkspaceId("");
|
||||
setTargetWorkspaceAdmin(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to move project:", err);
|
||||
} finally {
|
||||
setIsMoving(false);
|
||||
}
|
||||
const targetWorkspace = workspaces.find(w => w.id === targetWorkspaceId);
|
||||
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
|
||||
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
|
||||
await loadProjects();
|
||||
})(),
|
||||
{
|
||||
loading: "Перемещение проекта...",
|
||||
success: () => {
|
||||
setSettingsProject(null);
|
||||
setShowMoveDialog(false);
|
||||
setTargetWorkspaceId("");
|
||||
setTargetWorkspaceAdmin(false);
|
||||
return `Проект перемещён в «${targetWorkspace?.name}»`;
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось переместить проект";
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getChannelAvatar = (project: Project) => {
|
||||
@@ -335,17 +390,30 @@ export default function ProjectsPage() {
|
||||
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
|
||||
useEffect(() => {
|
||||
if (projects.length === 0) {
|
||||
if (localProjects.length === 0) {
|
||||
setProjectsWithStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canViewAnalytics) {
|
||||
setProjectsWithStats(localProjects.map(p => ({ ...p, stats: undefined })));
|
||||
setLoadingStats(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
const statsPromises = projects.map(async (project) => {
|
||||
const statsPromises = localProjects.map(async (project) => {
|
||||
try {
|
||||
const spending = await demoAwareAnalyticsApi.spending(workspaceId, {
|
||||
project_id: project.id,
|
||||
@@ -376,17 +444,23 @@ export default function ProjectsPage() {
|
||||
setProjectsWithStats(results);
|
||||
} catch (err) {
|
||||
console.error("Failed to load project stats:", err);
|
||||
setProjectsWithStats(projects);
|
||||
setProjectsWithStats(localProjects);
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
}, [projects, workspaceId]);
|
||||
}, [localProjects, workspaceId, canViewAnalytics]);
|
||||
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<DashboardHeader
|
||||
@@ -467,11 +541,33 @@ export default function ProjectsPage() {
|
||||
</AlertDescription>
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projectsWithStats.length === 0 ? (
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
@@ -533,7 +629,7 @@ export default function ProjectsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projectsWithStats.map((project) => (
|
||||
{filteredProjects.map((project) => (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -617,6 +713,7 @@ export default function ProjectsPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenSettings(project)}
|
||||
disabled={!canEditProjects}
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -629,7 +726,7 @@ export default function ProjectsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<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">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -747,6 +844,7 @@ export default function ProjectsPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenSettings(project)}
|
||||
disabled={!canEditProjects}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -792,47 +890,29 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUnarchive}
|
||||
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
|
||||
disabled={!canEditProjects || isDemoMode}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
{isUnarchiving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Восстановление...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Восстановить из архива
|
||||
</>
|
||||
)}
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Восстановить из архива
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleArchive}
|
||||
disabled={isArchiving || isDeleting || isUpdatingSettings}
|
||||
disabled={!canEditProjects || isDemoMode}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
{isArchiving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Архивирование...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Архивировать проект
|
||||
</>
|
||||
)}
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Архивировать проект
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenMoveDialog}
|
||||
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
|
||||
disabled={!isAdmin || !canEditProjects || isDemoMode}
|
||||
className="w-full justify-start"
|
||||
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
|
||||
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : !canEditProjects ? "Нет прав на редактирование проектов" : undefined}
|
||||
>
|
||||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||||
Переместить в другой воркспейс
|
||||
@@ -840,7 +920,7 @@ export default function ProjectsPage() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleOpenDeleteDialog}
|
||||
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
|
||||
disabled={!canEditProjects || isDemoMode || settingsProject?.status === "archived"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
@@ -858,16 +938,9 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
|
||||
disabled={!canEditProjects || isDemoMode}
|
||||
>
|
||||
{isUpdatingSettings ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
"Сохранить"
|
||||
)}
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -897,17 +970,10 @@ export default function ProjectsPage() {
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
|
||||
disabled={deleteConfirmationText !== `Удалить ${settingsProject?.title}` || isDemoMode}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Удаление...
|
||||
</>
|
||||
) : (
|
||||
"Удалить"
|
||||
)}
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -978,19 +1044,10 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleMoveProject}
|
||||
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
|
||||
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isDemoMode}
|
||||
>
|
||||
{isMoving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Перемещение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||||
Переместить
|
||||
</>
|
||||
)}
|
||||
<ArrowRightFromLine className="h-4 w-4 mr-2" />
|
||||
Переместить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -83,6 +83,7 @@ import { PlacementStatusSelector } from "@/components/placement-status-selector"
|
||||
import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector";
|
||||
import { PlacementWizard } from "@/components/placement-wizard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
Project,
|
||||
Placement,
|
||||
@@ -742,9 +743,12 @@ function CellRenderer({
|
||||
);
|
||||
|
||||
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 (
|
||||
<td key={column.id} className="py-2 px-3 text-muted-foreground text-xs">
|
||||
{formatNumber(placement.placement_post?.subscriptions_count)}
|
||||
{showSubs}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -1076,7 +1080,7 @@ export default function PurchasePlanDetailPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const projectId = params?.projectId as string;
|
||||
const { projects, hasPermission } = useWorkspace();
|
||||
const { projects, hasPermission, canViewAnalytics, canViewSubscriptions } = useWorkspace();
|
||||
|
||||
const [placements, setPlacements] = useState<Placement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1098,9 +1102,30 @@ export default function PurchasePlanDetailPage() {
|
||||
const [showCopyDialog, setShowCopyDialog] = useState(false);
|
||||
const [targetProjectId, setTargetProjectId] = useState<string | null>(null);
|
||||
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);
|
||||
|
||||
// Creative selection dialog
|
||||
@@ -1228,14 +1253,15 @@ export default function PurchasePlanDetailPage() {
|
||||
|
||||
const handleOpenCreativeSend = async (placement: PlacementWithStats) => {
|
||||
if (!placement.creative_id || isDemoMode) return;
|
||||
|
||||
try {
|
||||
await placementsApi.sendCreative(workspaceId, projectId, placement.id);
|
||||
alert("Креатив успешно отправлен");
|
||||
} catch (err) {
|
||||
console.error("Failed to send creative:", err);
|
||||
alert("Не удалось отправить креатив");
|
||||
}
|
||||
|
||||
await toast.promise(
|
||||
placementsApi.sendCreative(workspaceId, projectId, placement.id),
|
||||
{
|
||||
loading: "Отправка креатива...",
|
||||
success: "Креатив успешно отправлен",
|
||||
error: "Не удалось отправить креатив",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectCreative = (creative: { id: string; name: string }) => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function PurchasePlansPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
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 [loadingStats, setLoadingStats] = useState(true);
|
||||
@@ -222,13 +222,17 @@ export default function PurchasePlansPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
<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 className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<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 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" />
|
||||
<span className="text-sm text-muted-foreground">CPF</span>
|
||||
</div>
|
||||
<span className="font-medium">{formatCurrency(summary.avg_cpf)}</span>
|
||||
<span className="font-medium">
|
||||
{canViewSubscriptions ? formatCurrency(summary.avg_cpf) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { WorkspaceInvite } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
@@ -51,8 +52,6 @@ export default function InvitesPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadInvites();
|
||||
@@ -74,27 +73,28 @@ export default function InvitesPage() {
|
||||
if (!username.trim()) return;
|
||||
|
||||
const cleanUsername = username.trim().replace(/^@/, "");
|
||||
try {
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
// Remove @ if present
|
||||
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
});
|
||||
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowDialog(false);
|
||||
setUsername("");
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.message || err?.detail;
|
||||
if (errorMessage?.includes("not found")) {
|
||||
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
|
||||
} else {
|
||||
setError(errorMessage || "Не удалось отправить приглашение");
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
const invite = await invitesApi.create(workspaceId, {
|
||||
username: cleanUsername,
|
||||
});
|
||||
setInvites((prev) => [invite, ...prev]);
|
||||
setShowDialog(false);
|
||||
setUsername("");
|
||||
return invite;
|
||||
})(),
|
||||
{
|
||||
loading: "Отправка приглашения...",
|
||||
success: `Приглашение отправлено @${cleanUsername}`,
|
||||
error: (err) => {
|
||||
const errorMessage = err?.error?.message || err?.detail;
|
||||
if (errorMessage?.includes("not found")) {
|
||||
return `Пользователь @${cleanUsername} не зарегистрирован на платформе`;
|
||||
}
|
||||
return errorMessage || "Не удалось отправить приглашение";
|
||||
},
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
|
||||
@@ -161,27 +161,22 @@ export default function InvitesPage() {
|
||||
приглашения через бота
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Telegram username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="@username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateInvite();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Telegram username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="@username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateInvite();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -191,13 +186,9 @@ export default function InvitesPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateInvite}
|
||||
disabled={!username.trim() || isCreating}
|
||||
disabled={!username.trim()}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
// Workspace Settings Page
|
||||
// ============================================================================
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
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 { useWorkspace } from "@/components/providers/workspace-provider";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { workspacesApi, membersApi, invitesApi } from "@/lib/api";
|
||||
import type { User } from "@/lib/types/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -51,7 +54,13 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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 {
|
||||
handleApiError,
|
||||
showPermissionErrorToast,
|
||||
showSuccessToast,
|
||||
} from "@/lib/utils/error-handler";
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
@@ -62,6 +71,8 @@ export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const workspaceId = params?.workspaceId as string;
|
||||
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
|
||||
const { user } = useAuth();
|
||||
const currentUser = user;
|
||||
|
||||
const [name, setName] = useState(currentWorkspace?.name || "");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
@@ -91,6 +102,14 @@ export default function SettingsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
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(() => {
|
||||
if (currentWorkspace) {
|
||||
setName(currentWorkspace.name);
|
||||
@@ -139,59 +158,142 @@ export default function SettingsPage() {
|
||||
wsId: string,
|
||||
memberId: string,
|
||||
userId: string,
|
||||
permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]
|
||||
permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]
|
||||
) => {
|
||||
await membersApi.updatePermissions(wsId, userId, { permissions });
|
||||
await loadMembers();
|
||||
await toast.promise(
|
||||
membersApi.updatePermissions(wsId, memberId, { permissions }),
|
||||
{
|
||||
loading: "Сохранение прав доступа...",
|
||||
success: () => {
|
||||
loadMembers();
|
||||
return "Права доступа обновлены";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось сохранить права";
|
||||
},
|
||||
}
|
||||
);
|
||||
setShowPermissionModal(false);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!name.trim() || name === currentWorkspace?.name) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await workspacesApi.update(workspaceId, { name: name.trim() });
|
||||
await refreshWorkspaces();
|
||||
} catch (err) {
|
||||
console.error("Failed to update workspace:", err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
await toast.promise(
|
||||
workspacesApi.update(workspaceId, { name: name.trim() }),
|
||||
{
|
||||
loading: "Сохранение названия...",
|
||||
success: () => {
|
||||
refreshWorkspaces();
|
||||
return "Название обновлено";
|
||||
},
|
||||
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];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsUploadingAvatar(true);
|
||||
const updatedWorkspace = await workspacesApi.uploadAvatar(workspaceId, file);
|
||||
await refreshWorkspaces();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
await toast.promise(
|
||||
workspacesApi.uploadAvatar(workspaceId, file),
|
||||
{
|
||||
loading: "Загрузка аватара...",
|
||||
success: () => {
|
||||
refreshWorkspaces();
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setAvatarFile(null);
|
||||
return "Аватар загружен";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось загрузить аватар";
|
||||
},
|
||||
}
|
||||
setAvatarFile(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to upload avatar:", err);
|
||||
} finally {
|
||||
setIsUploadingAvatar(false);
|
||||
}
|
||||
);
|
||||
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 () => {
|
||||
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await workspacesApi.delete(workspaceId);
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete workspace:", err);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirm("");
|
||||
}
|
||||
await toast.promise(
|
||||
workspacesApi.delete(workspaceId),
|
||||
{
|
||||
loading: "Удаление воркспейса...",
|
||||
success: () => {
|
||||
router.replace("/");
|
||||
return "Воркспейс удалён";
|
||||
},
|
||||
error: (err) => {
|
||||
handleApiError(err);
|
||||
return "Не удалось удалить воркспейс";
|
||||
},
|
||||
}
|
||||
);
|
||||
setIsDeleting(false);
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirm("");
|
||||
};
|
||||
|
||||
const hasPermission = (member: WorkspaceMember, key: PermissionKey) => {
|
||||
@@ -522,20 +624,20 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
member.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{member.status === "active" ? "Активен" : "Приглашён"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderPermissionBadges(member)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
member.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{member.status === "active" ? "Активен" : "Приглашён"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderPermissionBadges(member)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -637,6 +739,8 @@ const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
projects={projects}
|
||||
onSave={handlePermissionSave}
|
||||
workspaceId={workspaceId}
|
||||
currentUserId={currentUser?.id || ""}
|
||||
isOnlyAdmin={isOnlyAdmin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import React from "react";
|
||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
@@ -20,7 +21,10 @@ export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<Toaster position="top-center" richColors />
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user