feat: global platform v2 update

This commit is contained in:
ivannoskov
2025-12-29 10:12:13 +03:00
parent 8e6a1fa83f
commit f64cf02100
84 changed files with 11023 additions and 11486 deletions

View File

@@ -0,0 +1,267 @@
"use client";
// ============================================================================
// Workspace Invites Page
// ============================================================================
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Loader2, UserPlus, Mail, Check, X, Clock } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { invitesApi } from "@/lib/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 type { WorkspaceInvite } from "@/lib/types/api";
// ============================================================================
// Component
// ============================================================================
export default function InvitesPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const [invites, setInvites] = useState<WorkspaceInvite[]>([]);
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();
}, [workspaceId]);
const loadInvites = async () => {
try {
setLoading(true);
const response = await invitesApi.list(workspaceId, { size: 100 });
setInvites(response.items);
} catch (err) {
console.error("Failed to load invites:", err);
} finally {
setLoading(false);
}
};
const handleCreateInvite = async () => {
if (!username.trim()) return;
try {
setIsCreating(true);
setError(null);
// Remove @ if present
const cleanUsername = username.trim().replace(/^@/, "");
const invite = await invitesApi.create(workspaceId, {
username: cleanUsername,
});
setInvites((prev) => [invite, ...prev]);
setShowDialog(false);
setUsername("");
} catch (err: any) {
setError(err?.error?.message || "Не удалось отправить приглашение");
} finally {
setIsCreating(false);
}
};
const getStatusIcon = (status: WorkspaceInvite["status"]) => {
switch (status) {
case "pending":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "accepted":
return <Check className="h-4 w-4 text-green-500" />;
case "rejected":
return <X className="h-4 w-4 text-red-500" />;
}
};
const getStatusText = (status: WorkspaceInvite["status"]) => {
switch (status) {
case "pending":
return "Ожидает";
case "accepted":
return "Принято";
case "rejected":
return "Отклонено";
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
year: "numeric",
});
};
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
{ label: "Приглашения" },
]}
showProjectSelector={false}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Приглашения</h1>
<p className="text-muted-foreground">
Приглашения в воркспейс через Telegram
</p>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<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={() => setShowDialog(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>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : invites.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mail 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">
Пригласите участников в воркспейс через Telegram
</p>
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Дата</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((invite) => (
<TableRow key={invite.id}>
<TableCell>
<div className="flex items-center gap-2">
<UserPlus className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">@{invite.username}</span>
</div>
</TableCell>
<TableCell>
<Badge
variant={
invite.status === "pending"
? "outline"
: invite.status === "accepted"
? "default"
: "destructive"
}
className="gap-1"
>
{getStatusIcon(invite.status)}
{getStatusText(invite.status)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{formatDate(invite.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,281 @@
"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<PermissionKey, string> = {
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<WorkspaceMember[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(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 (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Настройки", href: `/dashboard/${workspaceId}/settings` },
{ label: "Участники" },
]}
showProjectSelector={false}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Участники</h1>
<p className="text-muted-foreground">
Управление участниками и правами доступа
</p>
</div>
<Button asChild>
<a href={`/dashboard/${workspaceId}/settings/invites`}>
Пригласить участника
</a>
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : members.length === 0 ? (
<Card>
<CardContent 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>
</CardContent>
</Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Пользователь</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Права доступа</TableHead>
<TableHead className="w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<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.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>
</Card>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
// ============================================================================
// Workspace Settings Page
// ============================================================================
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2, Settings, Pencil, Trash2 } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { workspacesApi } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
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 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 handleDelete = async () => {
try {
setIsDeleting(true);
await workspacesApi.delete(workspaceId);
router.replace("/");
} catch (err) {
console.error("Failed to delete workspace:", err);
} finally {
setIsDeleting(false);
}
};
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: "Настройки" },
]}
showProjectSelector={false}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
<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-4">
<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 Links */}
<Card>
<CardHeader>
<CardTitle>Команда</CardTitle>
<CardDescription>
Управление участниками и приглашениями
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start" asChild>
<a href={`/dashboard/${workspaceId}/settings/members`}>
Участники воркспейса
</a>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<a href={`/dashboard/${workspaceId}/settings/invites`}>
Приглашения
</a>
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Опасная зона</CardTitle>
<CardDescription>
Необратимые действия с воркспейсом
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" />
Удалить воркспейс
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить воркспейс?</AlertDialogTitle>
<AlertDialogDescription>
Это действие необратимо. Все данные воркспейса будут
удалены, включая проекты, креативы, размещения и аналитику.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Удалить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
</>
);
}