diff --git a/app/dashboard/[workspaceId]/page.tsx b/app/dashboard/[workspaceId]/page.tsx index 72dffdb..0fedd57 100644 --- a/app/dashboard/[workspaceId]/page.tsx +++ b/app/dashboard/[workspaceId]/page.tsx @@ -35,7 +35,6 @@ type DateRangeType = { import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; import { demoAwareAnalyticsApi } from "@/lib/demo"; -import { ProjectSelector } from "@/components/project-selector"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartContainer, @@ -109,9 +108,6 @@ export default function DashboardPage() { const { currentWorkspace, isLoading: workspaceLoading, - selectedProjects, - projects, - getProjectFilterId, } = useWorkspace(); const [overview, setOverview] = useState(null); @@ -149,8 +145,6 @@ export default function DashboardPage() { return "За всё время"; }, [dateRange]); - const projectFilterId = getProjectFilterId(); - useEffect(() => { if (!currentWorkspace) { setLoading(false); @@ -161,7 +155,6 @@ export default function DashboardPage() { try { setLoading(true); const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, { - project_id: projectFilterId, date_from: dateFromIso, date_to: dateToIso, }); @@ -175,7 +168,7 @@ export default function DashboardPage() { }; loadData(); - }, [currentWorkspace?.id, projectFilterId, dateFromIso, dateToIso]); + }, [currentWorkspace?.id, dateFromIso, dateToIso]); const stats = useMemo( () => [ @@ -302,7 +295,6 @@ export default function DashboardPage() {

{dateRangeLabel}

- @@ -220,20 +352,20 @@ export default function ProjectsPage() { Статус
- Потрачено + Подписчиков - Сумма всех размещений по проекту + Количество подписчиков в канале
- Подписчики + Привлечено подписчиков @@ -246,7 +378,7 @@ export default function ProjectsPage() {
- Просмотры + Суммарный охват @@ -259,18 +391,18 @@ export default function ProjectsPage() {
- Сред. CPS + CPF - Cost Per Subscription — средняя стоимость подписчика + Cost Per Follower — средняя стоимость подписчика
- + @@ -278,9 +410,12 @@ export default function ProjectsPage() {
-
- -
+ + + + + +
{project.title}
{project.username && ( @@ -308,8 +443,8 @@ export default function ProjectsPage() { : "Архив"} - - {formatCurrency(project.stats?.total_cost)} + + {formatNumber(project.subscribers_count)} {formatNumber(project.stats?.total_subscriptions)} @@ -318,16 +453,34 @@ export default function ProjectsPage() { {formatNumber(project.stats?.total_views)} - {formatCurrency(project.stats?.avg_cps)} + {formatCurrency(project.stats?.avg_cpf)} - + + + + Настройки + +
))} @@ -342,9 +495,12 @@ export default function ProjectsPage() {
-
- -
+ + + + + +
{project.title} {project.username && ( @@ -375,82 +531,222 @@ export default function ProjectsPage() { {/* Stats Grid */} - {project.stats && ( -
-
- -
-
- {formatCurrency(project.stats.total_cost)} -
-
- Потрачено -
-
-
+
+ {project.subscribers_count !== undefined && (
- {formatNumber(project.stats.total_subscriptions)} + {formatNumber(project.subscribers_count)}
Подписчиков
-
- -
-
- {formatNumber(project.stats.total_views)} -
-
- Просмотров + )} + {project.stats && ( + <> +
+ +
+
+ {formatNumber(project.stats.total_subscriptions)} +
+
+ Привлечено подписчиков +
-
-
- -
-
- {formatCurrency(project.stats.avg_cps)} -
-
- Сред. CPS +
+ +
+
+ {formatNumber(project.stats.total_views)} +
+
+ Суммарный охват +
-
-
- )} +
+ +
+
+ {formatCurrency(project.stats.avg_cpf)} +
+
+ CPF +
+
+
+ + )} +
{/* Actions */} -
- {project.username && ( - - )} +
+ +
))}
)} + + {/* Settings Dialog */} + !open && setSettingsProject(null)}> + + + Настройки проекта + + {settingsProject?.title} + + +
+
+ + +

+ Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта +

+
+ + {/* Danger Zone */} +
+
+

Опасная зона

+
+ {settingsProject?.status !== "archived" && ( + + )} + +
+
+
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + Удалить проект "{settingsProject?.title}"? + + Это действие необратимо. Все данные проекта будут удалены, включая креативы, размещения и аналитику. +

+ Для подтверждения введите Удалить {settingsProject?.title} ниже: +
+
+
+ setDeleteConfirmationText(e.target.value)} + className="mt-4" + /> +
+ + + Отмена + + + {isDeleting ? ( + <> + + Удаление... + + ) : ( + "Удалить" + )} + + +
+
); diff --git a/app/dashboard/[workspaceId]/settings/members/page.tsx b/app/dashboard/[workspaceId]/settings/members/page.tsx deleted file mode 100644 index c2bd0cd..0000000 --- a/app/dashboard/[workspaceId]/settings/members/page.tsx +++ /dev/null @@ -1,280 +0,0 @@ -"use client"; - -// ============================================================================ -// Workspace Members Page -// ============================================================================ - -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; -import { Loader2, Users, Shield, ChevronDown } from "lucide-react"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { membersApi } from "@/lib/api"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, -} from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuCheckboxItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { WorkspaceMember, PermissionKey } from "@/lib/types/api"; - -// ============================================================================ -// Constants -// ============================================================================ - -const PERMISSION_LABELS: Record = { - admin_full: "Полный доступ", - projects_read: "Просмотр проектов", - projects_write: "Редактирование проектов", - placements_read: "Просмотр размещений", - placements_write: "Редактирование размещений", - analytics_read: "Просмотр аналитики", -}; - -const ALL_PERMISSIONS: PermissionKey[] = [ - "admin_full", - "projects_read", - "projects_write", - "placements_read", - "placements_write", - "analytics_read", -]; - -// ============================================================================ -// Component -// ============================================================================ - -export default function MembersPage() { - const params = useParams(); - const workspaceId = params?.workspaceId as string; - - const [members, setMembers] = useState([]); - const [loading, setLoading] = useState(true); - const [updating, setUpdating] = useState(null); - - useEffect(() => { - const loadMembers = async () => { - try { - setLoading(true); - const response = await membersApi.list(workspaceId, { size: 100 }); - setMembers(response.items); - } catch (err) { - console.error("Failed to load members:", err); - } finally { - setLoading(false); - } - }; - - loadMembers(); - }, [workspaceId]); - - const handlePermissionChange = async ( - memberId: string, - userId: string, - permission: PermissionKey, - enabled: boolean - ) => { - try { - setUpdating(memberId); - - const member = members.find((m) => m.id === memberId); - if (!member) return; - - let newPermissions = [...member.permissions]; - - if (enabled) { - // Add permission - if (!newPermissions.some((p) => p.key === permission)) { - newPermissions.push({ key: permission }); - } - } else { - // Remove permission - newPermissions = newPermissions.filter((p) => p.key !== permission); - } - - await membersApi.updatePermissions(workspaceId, userId, { - permissions: newPermissions, - }); - - // Update local state - setMembers((prev) => - prev.map((m) => - m.id === memberId ? { ...m, permissions: newPermissions } : m - ) - ); - } catch (err) { - console.error("Failed to update permissions:", err); - } finally { - setUpdating(null); - } - }; - - const hasPermission = (member: WorkspaceMember, key: PermissionKey) => { - return member.permissions.some((p) => p.key === key); - }; - - return ( - <> - - -
-
-
-

Участники

-

- Управление участниками и правами доступа -

-
- -
- - {loading ? ( -
- -
- ) : members.length === 0 ? ( - - - -

Нет участников

-

- Пригласите участников в воркспейс -

-
-
- ) : ( - - - - - Пользователь - Статус - Права доступа - - - - - {members.map((member) => ( - - -
-
- -
-
-
- {member.user.username || `User ${member.user.telegram_id}`} -
- {member.user.username && ( -
- @{member.user.username} -
- )} -
-
-
- - - {member.status === "active" ? "Активен" : "Приглашён"} - - - -
- {hasPermission(member, "admin_full") ? ( - - - Админ - - ) : ( - member.permissions.map((p) => ( - - {PERMISSION_LABELS[p.key]} - - )) - )} - {member.permissions.length === 0 && ( - - Нет прав - - )} -
-
- - - - - - - Права доступа - - {ALL_PERMISSIONS.map((perm) => ( - - handlePermissionChange( - member.id, - member.user.id, - perm, - checked - ) - } - > - {PERMISSION_LABELS[perm]} - - ))} - - - -
- ))} -
-
-
- )} -
- - ); -} diff --git a/app/dashboard/[workspaceId]/settings/page.tsx b/app/dashboard/[workspaceId]/settings/page.tsx index e0bbe04..ae7ae0c 100644 --- a/app/dashboard/[workspaceId]/settings/page.tsx +++ b/app/dashboard/[workspaceId]/settings/page.tsx @@ -4,15 +4,16 @@ // Workspace Settings Page // ============================================================================ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; -import { Loader2, Settings, Pencil, Trash2 } from "lucide-react"; +import { Loader2, Settings, Pencil, Trash2, Users, Shield, ChevronDown, UserPlus, Mail, Clock } from "lucide-react"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; -import { workspacesApi } from "@/lib/api"; +import { workspacesApi, membersApi, invitesApi } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { Card, CardContent, @@ -20,6 +21,31 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, @@ -31,6 +57,33 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api"; + +// ============================================================================ +// Constants +// ============================================================================ + +const PERMISSION_LABELS: Record = { + admin_full: "Полный доступ", + projects_read: "Просмотр проектов", + projects_write: "Редактирование проектов", + placements_read: "Просмотр размещений", + placements_write: "Редактирование размещений", + analytics_read: "Просмотр аналитики", +}; + +const ALL_PERMISSIONS: PermissionKey[] = [ + "admin_full", + "projects_read", + "projects_write", + "placements_read", + "placements_write", + "analytics_read", +]; + +// ============================================================================ +// Component +// ============================================================================ export default function SettingsPage() { const params = useParams(); @@ -41,6 +94,44 @@ export default function SettingsPage() { const [name, setName] = useState(currentWorkspace?.name || ""); const [isUpdating, setIsUpdating] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(""); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + // Members and invites + const [members, setMembers] = useState([]); + const [invites, setInvites] = useState([]); + const [loadingMembers, setLoadingMembers] = useState(true); + const [updating, setUpdating] = useState(null); + const [showInviteDialog, setShowInviteDialog] = useState(false); + const [username, setUsername] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (currentWorkspace) { + setName(currentWorkspace.name); + } + }, [currentWorkspace]); + + useEffect(() => { + loadMembers(); + }, [workspaceId]); + + const loadMembers = async () => { + try { + setLoadingMembers(true); + const [membersResponse, invitesResponse] = await Promise.all([ + membersApi.list(workspaceId, { size: 100 }), + invitesApi.list(workspaceId, { size: 100 }), + ]); + setMembers(membersResponse.items); + setInvites(invitesResponse.items); + } catch (err) { + console.error("Failed to load data:", err); + } finally { + setLoadingMembers(false); + } + }; const handleUpdate = async () => { if (!name.trim() || name === currentWorkspace?.name) return; @@ -56,7 +147,70 @@ export default function SettingsPage() { } }; + const handleCreateInvite = async () => { + if (!username.trim()) return; + + try { + setIsCreating(true); + setError(null); + + const cleanUsername = username.trim().replace(/^@/, ""); + + const invite = await invitesApi.create(workspaceId, { + username: cleanUsername, + }); + + setInvites((prev) => [invite, ...prev]); + setShowInviteDialog(false); + setUsername(""); + } catch (err: any) { + setError(err?.error?.message || "Не удалось отправить приглашение"); + } finally { + setIsCreating(false); + } + }; + + const handlePermissionChange = async ( + memberId: string, + userId: string, + permission: PermissionKey, + enabled: boolean + ) => { + try { + setUpdating(memberId); + + const member = members.find((m) => m.id === memberId); + if (!member) return; + + let newPermissions = [...member.permissions]; + + if (enabled) { + if (!newPermissions.some((p) => p.key === permission)) { + newPermissions.push({ key: permission }); + } + } else { + newPermissions = newPermissions.filter((p) => p.key !== permission); + } + + await membersApi.updatePermissions(workspaceId, userId, { + permissions: newPermissions, + }); + + setMembers((prev) => + prev.map((m) => + m.id === memberId ? { ...m, permissions: newPermissions } : m + ) + ); + } catch (err) { + console.error("Failed to update permissions:", err); + } finally { + setUpdating(null); + } + }; + const handleDelete = async () => { + if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return; + try { setIsDeleting(true); await workspacesApi.delete(workspaceId); @@ -65,9 +219,22 @@ export default function SettingsPage() { console.error("Failed to delete workspace:", err); } finally { setIsDeleting(false); + setShowDeleteDialog(false); + setDeleteConfirm(""); } }; + const hasPermission = (member: WorkspaceMember, key: PermissionKey) => { + return member.permissions.some((p) => p.key === key); + }; + + // Combine members and pending invites + const pendingInvites = invites.filter((inv) => inv.status === "pending"); + const allPeople = [ + ...members.map((m) => ({ type: "member" as const, data: m })), + ...pendingInvites.map((inv) => ({ type: "invite" as const, data: inv })), + ]; + if (!currentWorkspace) { return ( <> @@ -88,7 +255,7 @@ export default function SettingsPage() { ]} /> -
+

Настройки воркспейса

@@ -133,25 +300,228 @@ export default function SettingsPage() { - {/* Team Management Links */} + {/* Team Management */} - Команда - - Управление участниками и приглашениями - +

+
+ Участники + + Управление участниками воркспейса + +
+ + + + + + + Пригласить участника + + Введите username Telegram пользователя для отправки + приглашения через бота + + +
+
+ + setUsername(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateInvite(); + } + }} + /> +
+ {error && ( +
+ {error} +
+ )} +
+ + + + +
+
+
- - - + + {loadingMembers ? ( +
+ +
+ ) : allPeople.length === 0 ? ( +
+ +

Нет участников

+

+ Пригласите участников в воркспейс +

+
+ ) : ( + + + + Пользователь + Статус + Права доступа + + + + + {allPeople.map((item) => { + if (item.type === "invite") { + const invite = item.data; + return ( + + +
+
+ +
+
+
@{invite.username}
+
+
+
+ + + + Ожидает принятия + + + + + — + + + +
+ ); + } else { + const member = item.data; + return ( + + +
+
+ +
+
+
+ {[member.user.first_name, member.user.last_name] + .filter(Boolean) + .join(" ") || member.user.username || `User ${member.user.telegram_id}`} +
+ {member.user.username && ( +
+ @{member.user.username} +
+ )} +
+
+
+ + + {member.status === "active" ? "Активен" : "Приглашён"} + + + +
+ {hasPermission(member, "admin_full") ? ( + + + Админ + + ) : ( + member.permissions.map((p) => ( + + {PERMISSION_LABELS[p.key]} + + )) + )} + {member.permissions.length === 0 && ( + + Нет прав + + )} +
+
+ + + + + + + Права доступа + + {ALL_PERMISSIONS.map((perm) => ( + + handlePermissionChange( + member.id, + member.user.id, + perm, + checked + ) + } + > + {PERMISSION_LABELS[perm]} + + ))} + + + +
+ ); + } + })} +
+
+ )}
@@ -164,7 +534,7 @@ export default function SettingsPage() { - +