Files
tgex-frontend/app/dashboard/[workspaceId]/settings/page.tsx
2026-02-02 21:29:06 +03:00

688 lines
26 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 } 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 { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
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 { 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 {
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,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api";
import { PERMISSION_LABELS, ALL_PERMISSIONS } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function SettingsPage() {
const params = useParams();
const router = useRouter();
const workspaceId = params?.workspaceId as string;
const { currentWorkspace, refreshWorkspaces } = useWorkspace();
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);
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;
try {
setIsUpdating(true);
await workspacesApi.update(workspaceId, { name: name.trim() });
await refreshWorkspaces();
} catch (err) {
console.error("Failed to update workspace:", err);
} finally {
setIsUpdating(false);
}
};
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 = "";
}
setAvatarFile(null);
} catch (err) {
console.error("Failed to upload avatar:", err);
} finally {
setIsUploadingAvatar(false);
}
};
const handleAvatarDelete = async () => {
try {
setIsDeletingAvatar(true);
await workspacesApi.deleteAvatar(workspaceId);
await refreshWorkspaces();
} catch (err) {
console.error("Failed to delete avatar:", err);
} finally {
setIsDeletingAvatar(false);
}
};
const handleCreateInvite = async () => {
if (!username.trim()) return;
const cleanUsername = username.trim().replace(/^@/, "");
try {
setIsCreating(true);
setError(null);
const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
});
setInvites((prev) => [invite, ...prev]);
setShowInviteDialog(false);
setUsername("");
} catch (err: any) {
const errorMessage = err?.error?.message || err?.detail;
if (errorMessage?.includes("not found")) {
setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`);
} else {
setError(errorMessage || "Не удалось отправить приглашение");
}
}
};
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);
router.replace("/");
} catch (err) {
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 (
<>
<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>
<div className="flex flex-wrap gap-1">
{hasPermission(member, "admin_full") ? (
<Badge variant="destructive" className="gap-1">
<Shield className="h-3 w-3" />
Админ
</Badge>
) : (
member.permissions.map((p) => (
<Badge key={p.key} variant="outline">
{PERMISSION_LABELS[p.key]}
</Badge>
))
)}
{member.permissions.length === 0 && (
<span className="text-sm text-muted-foreground">
Нет прав
</span>
)}
</div>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={updating === member.id}
>
{updating === member.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Права
<ChevronDown className="h-4 w-4 ml-1" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Права доступа</DropdownMenuLabel>
<DropdownMenuSeparator />
{ALL_PERMISSIONS.map((perm) => (
<DropdownMenuCheckboxItem
key={perm}
checked={hasPermission(member, perm)}
onCheckedChange={(checked) =>
handlePermissionChange(
member.id,
member.user.id,
perm,
checked
)
}
>
{PERMISSION_LABELS[perm]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</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>
</div>
</>
);
}