feat: ui updates

This commit is contained in:
ivannoskov
2026-01-07 17:54:58 +03:00
parent 646e97df3e
commit 52a4425751
11 changed files with 950 additions and 442 deletions

View File

@@ -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<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 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<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;
@@ -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() {
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-2xl">
<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">
@@ -133,25 +300,228 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* Team Management Links */}
{/* Team Management */}
<Card>
<CardHeader>
<CardTitle>Команда</CardTitle>
<CardDescription>
Управление участниками и приглашениями
</CardDescription>
<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 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>
{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>
@@ -164,7 +534,7 @@ export default function SettingsPage() {
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" />
@@ -179,17 +549,43 @@ export default function SettingsPage() {
удалены, включая проекты, креативы, размещения и аналитику.
</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>Отмена</AlertDialogCancel>
<AlertDialogCancel onClick={() => {
setShowDeleteDialog(false);
setDeleteConfirm("");
}}>
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
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" />
) : null}
Удалить
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -200,4 +596,3 @@ export default function SettingsPage() {
</>
);
}