Files
tgex-frontend/app/dashboard/[workspaceId]/settings/page.tsx
2026-02-10 17:15:38 +03:00

750 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Workspace Settings Page
// ============================================================================
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";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
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
// ============================================================================
export default function SettingsPage() {
const params = useParams();
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);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState("");
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Avatar upload
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [isDeletingAvatar, setIsDeletingAvatar] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Members and invites
const [members, setMembers] = useState<WorkspaceMember[]>([]);
const [invites, setInvites] = useState<WorkspaceInvite[]>([]);
const [loadingMembers, setLoadingMembers] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
const [showInviteDialog, setShowInviteDialog] = useState(false);
const [username, setUsername] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Permission modal
const [showPermissionModal, setShowPermissionModal] = useState(false);
const [selectedMember, setSelectedMember] = useState<WorkspaceMember | null>(null);
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);
}
}, [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 loadProjects = async () => {
try {
setLoadingProjects(true);
const response = await workspacesApi.getProjects(workspaceId, { size: 100 });
setProjects(response.items);
} catch (err) {
console.error("Failed to load projects:", err);
} finally {
setLoadingProjects(false);
}
};
const openPermissionModal = async (member: WorkspaceMember) => {
setSelectedMember(member);
await loadProjects();
setShowPermissionModal(true);
};
const handlePermissionSave = async (
wsId: string,
memberId: string,
userId: string,
permissions: { key: PermissionKey; scopes?: PermissionScope[] }[]
) => {
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;
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 file = e.target.files?.[0];
if (!file) return;
await toast.promise(
workspacesApi.uploadAvatar(workspaceId, file),
{
loading: "Загрузка аватара...",
success: () => {
refreshWorkspaces();
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setAvatarFile(null);
return "Аватар загружен";
},
error: (err) => {
handleApiError(err);
return "Не удалось загрузить аватар";
},
}
);
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;
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) => {
return member.permissions.some((p) => p.key === key);
};
const renderPermissionBadges = (member: WorkspaceMember) => {
if (member.permissions.length === 0) {
return <span className="text-sm text-muted-foreground">Нет прав</span>;
}
const adminPerm = member.permissions.find((p) => p.key === "admin_full");
if (adminPerm) {
const isGlobal = !adminPerm.scopes || adminPerm.scopes.length === 0;
return (
<div className="flex flex-wrap gap-1">
<Badge variant="destructive" className="gap-1">
<Shield className="h-3 w-3" />
Админ {isGlobal ? "" : "(ограничен)"}
</Badge>
</div>
);
}
return (
<div className="flex flex-wrap gap-1">
{member.permissions.map((p) => {
const isGlobal = !p.scopes || p.scopes.length === 0;
const projectCount = p.scopes
? p.scopes.filter((s) => s.type === "project").length
: 0;
return (
<Badge key={p.key} variant="outline" className="gap-1">
{PERMISSION_LABELS[p.key]}
{!isGlobal && projectCount > 0 && (
<span className="text-muted-foreground">({projectCount})</span>
)}
</Badge>
);
})}
</div>
);
};
// 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 (
<>
<DashboardHeader breadcrumbs={[{ label: "Настройки" }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Настройки" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Настройки воркспейса</h1>
<p className="text-muted-foreground">
Управление настройками воркспейса
</p>
</div>
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>Основные настройки</CardTitle>
<CardDescription>
Название и аватар воркспейса
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar */}
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
<AvatarImage src={currentWorkspace.avatar_url || undefined} alt={currentWorkspace.name} />
<AvatarFallback className="text-2xl">
{currentWorkspace.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingAvatar}
>
{isUploadingAvatar ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Загрузить
</Button>
{currentWorkspace.avatar_url && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isDeletingAvatar}
>
{isDeletingAvatar ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить аватар</AlertDialogTitle>
<AlertDialogDescription>
Вы уверены, что хотите удалить аватар воркспейса?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onClick={handleAvatarDelete}>
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG или GIF. Максимум 2 МБ.
</p>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label htmlFor="workspace-name">Название</Label>
<div className="flex gap-2">
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Название воркспейса"
/>
<Button
onClick={handleUpdate}
disabled={
!name.trim() ||
name === currentWorkspace.name ||
isUpdating
}
>
{isUpdating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Pencil className="h-4 w-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Team Management */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Участники</CardTitle>
<CardDescription>
Управление участниками воркспейса
</CardDescription>
</div>
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
Пригласить участника
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Пригласить участника</DialogTitle>
<DialogDescription>
Введите username Telegram пользователя для отправки
приглашения через бота
</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>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowInviteDialog(false)}
>
Отмена
</Button>
<Button
onClick={handleCreateInvite}
disabled={!username.trim() || isCreating}
>
{isCreating ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Mail className="h-4 w-4 mr-2" />
)}
Отправить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{loadingMembers ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : allPeople.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет участников</h3>
<p className="text-muted-foreground text-center">
Пригласите участников в воркспейс
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Права доступа</TableHead>
<TableHead className="w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allPeople.map((item) => {
if (item.type === "invite") {
const invite = item.data;
return (
<TableRow key={`invite-${invite.id}`}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
<Users className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="font-medium">@{invite.username}</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="gap-1">
<Clock className="h-3 w-3" />
Ожидает принятия
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
</span>
</TableCell>
<TableCell></TableCell>
</TableRow>
);
} else {
const member = item.data;
return (
<TableRow key={member.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div>
<div className="font-medium">
{[member.user.first_name, member.user.last_name]
.filter(Boolean)
.join(" ") || member.user.username || `User ${member.user.telegram_id}`}
</div>
{member.user.username && (
<div className="text-xs text-muted-foreground">
@{member.user.username}
</div>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
member.status === "active" ? "default" : "secondary"
}
>
{member.status === "active" ? "Активен" : "Приглашён"}
</Badge>
</TableCell>
<TableCell>
{renderPermissionBadges(member)}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => openPermissionModal(member)}
disabled={updating === member.id}
>
{updating === member.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Настроить
</>
)}
</Button>
</TableCell>
</TableRow>
);
}
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Опасная зона</CardTitle>
<CardDescription>
Необратимые действия с воркспейсом
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" />
Удалить воркспейс
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить воркспейс?</AlertDialogTitle>
<AlertDialogDescription>
Это действие необратимо. Все данные воркспейса будут
удалены, включая проекты, креативы, размещения и аналитику.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="delete-confirm">
Введите <strong>Удалить {currentWorkspace.name}</strong> для подтверждения:
</Label>
<Input
id="delete-confirm"
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder={`Удалить ${currentWorkspace.name}`}
className="font-mono"
/>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setShowDeleteDialog(false);
setDeleteConfirm("");
}}>
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={
isDeleting ||
deleteConfirm !== `Удалить ${currentWorkspace.name}`
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
{selectedMember && (
<PermissionModal
open={showPermissionModal}
onOpenChange={setShowPermissionModal}
member={selectedMember}
projects={projects}
onSave={handlePermissionSave}
workspaceId={workspaceId}
currentUserId={currentUser?.id || ""}
isOnlyAdmin={isOnlyAdmin}
/>
)}
</div>
</>
);
}