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

@@ -35,7 +35,6 @@ type DateRangeType = {
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo"; import { demoAwareAnalyticsApi } from "@/lib/demo";
import { ProjectSelector } from "@/components/project-selector";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
ChartContainer, ChartContainer,
@@ -109,9 +108,6 @@ export default function DashboardPage() {
const { const {
currentWorkspace, currentWorkspace,
isLoading: workspaceLoading, isLoading: workspaceLoading,
selectedProjects,
projects,
getProjectFilterId,
} = useWorkspace(); } = useWorkspace();
const [overview, setOverview] = useState<OverviewAnalytics | null>(null); const [overview, setOverview] = useState<OverviewAnalytics | null>(null);
@@ -149,8 +145,6 @@ export default function DashboardPage() {
return "За всё время"; return "За всё время";
}, [dateRange]); }, [dateRange]);
const projectFilterId = getProjectFilterId();
useEffect(() => { useEffect(() => {
if (!currentWorkspace) { if (!currentWorkspace) {
setLoading(false); setLoading(false);
@@ -161,7 +155,6 @@ export default function DashboardPage() {
try { try {
setLoading(true); setLoading(true);
const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, { const data = await demoAwareAnalyticsApi.overview(currentWorkspace.id, {
project_id: projectFilterId,
date_from: dateFromIso, date_from: dateFromIso,
date_to: dateToIso, date_to: dateToIso,
}); });
@@ -175,7 +168,7 @@ export default function DashboardPage() {
}; };
loadData(); loadData();
}, [currentWorkspace?.id, projectFilterId, dateFromIso, dateToIso]); }, [currentWorkspace?.id, dateFromIso, dateToIso]);
const stats = useMemo( const stats = useMemo(
() => [ () => [
@@ -302,7 +295,6 @@ export default function DashboardPage() {
<p className="text-base font-semibold">{dateRangeLabel}</p> <p className="text-base font-semibold">{dateRangeLabel}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProjectSelector />
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button

View File

@@ -18,10 +18,43 @@ import {
Eye, Eye,
ShoppingCart, ShoppingCart,
Info, Info,
Bot,
Settings,
Folder,
Archive,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header"; import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo"; import { demoAwareAnalyticsApi, demoAwareProjectsApi } from "@/lib/demo";
import { projectsApi } from "@/lib/api";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { import {
Card, Card,
CardContent, CardContent,
@@ -31,6 +64,7 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { import {
Table, Table,
@@ -58,7 +92,7 @@ interface ProjectWithStats extends Project {
total_cost: number; total_cost: number;
total_subscriptions: number; total_subscriptions: number;
total_views: number; total_views: number;
avg_cps: number | null; avg_cpf: number | null;
}; };
} }
@@ -88,11 +122,93 @@ const formatCurrency = (value: number | null | undefined): string => {
export default function ProjectsPage() { export default function ProjectsPage() {
const params = useParams(); const params = useParams();
const workspaceId = params?.workspaceId as string; const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace(); const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]); const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false); const [loadingStats, setLoadingStats] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
const { isDemoMode } = useWorkspace();
const handleOpenSettings = (project: Project) => {
setSettingsProject(project);
setInviteLinkType(project.purchase_invite_type_default || "public");
};
const handleSaveSettings = async () => {
if (!settingsProject) return;
try {
setIsUpdatingSettings(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to update settings:", err);
} finally {
setIsUpdatingSettings(false);
}
};
const handleArchive = async () => {
if (!settingsProject) return;
try {
setIsArchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to archive project:", err);
} finally {
setIsArchiving(false);
}
};
const handleDelete = async () => {
if (!settingsProject) return;
try {
setIsDeleting(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
} catch (err) {
console.error("Failed to delete project:", err);
} finally {
setIsDeleting(false);
}
};
const handleOpenDeleteDialog = () => {
setShowDeleteDialog(true);
};
const handleCloseDeleteDialog = () => {
setShowDeleteDialog(false);
setDeleteConfirmationText("");
};
const getChannelAvatar = (project: Project) => {
// Telegram channel avatar URL format
if (project.username) {
return `https://t.me/i/userpic/320/${project.username}.jpg`;
}
return null;
};
// Load statistics for each project // Load statistics for each project
useEffect(() => { useEffect(() => {
@@ -115,7 +231,7 @@ export default function ProjectsPage() {
total_cost: spending.total_cost, total_cost: spending.total_cost,
total_subscriptions: spending.total_subscriptions, total_subscriptions: spending.total_subscriptions,
total_views: spending.total_views, total_views: spending.total_views,
avg_cps: avg_cpf:
spending.total_subscriptions > 0 spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions ? spending.total_cost / spending.total_subscriptions
: null, : null,
@@ -180,18 +296,34 @@ export default function ProjectsPage() {
<Alert> <Alert>
<Tv className="h-4 w-4" /> <Tv className="h-4 w-4" />
<AlertDescription> <AlertDescription className="flex items-center justify-between flex-wrap gap-3">
Чтобы добавить новый проект, добавьте бота{" "} <span>
<a Чтобы добавить новый проект, добавьте бота{" "}
href={`https://t.me/${BOT_USERNAME}`} <a
target="_blank" href={`https://t.me/${BOT_USERNAME}`}
rel="noopener noreferrer" target="_blank"
className="font-mono text-primary hover:underline inline-flex items-center gap-1" rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
администратором в ваш Telegram канал.
</span>
<Button
asChild
size="sm"
className="shrink-0"
> >
@{BOT_USERNAME} <a
<ExternalLink className="h-3 w-3" /> href={`tg://resolve?domain=${BOT_USERNAME}&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages`}
</a>{" "} target="_blank"
администратором в ваш Telegram канал. rel="noopener noreferrer"
>
<Bot className="h-4 w-4 mr-2" />
Добавить бота
</a>
</Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -220,20 +352,20 @@ export default function ProjectsPage() {
<TableHead>Статус</TableHead> <TableHead>Статус</TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Потрачено Подписчиков
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
Сумма всех размещений по проекту Количество подписчиков в канале
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Подписчики Привлечено подписчиков
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
@@ -246,7 +378,7 @@ export default function ProjectsPage() {
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Просмотры Суммарный охват
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
@@ -259,18 +391,18 @@ export default function ProjectsPage() {
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
Сред. CPS CPF
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" /> <Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
Cost Per Subscription средняя стоимость подписчика Cost Per Follower средняя стоимость подписчика
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</TableHead> </TableHead>
<TableHead className="w-[140px]"></TableHead> <TableHead className="w-[200px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -278,9 +410,12 @@ export default function ProjectsPage() {
<TableRow key={project.id}> <TableRow key={project.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <Avatar className="h-9 w-9">
<Tv className="h-4 w-4 text-primary" /> <AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
</div> <AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-4 w-4 text-primary" />
</AvatarFallback>
</Avatar>
<div> <div>
<div className="font-medium">{project.title}</div> <div className="font-medium">{project.title}</div>
{project.username && ( {project.username && (
@@ -308,8 +443,8 @@ export default function ProjectsPage() {
: "Архив"} : "Архив"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right font-medium"> <TableCell className="text-right">
{formatCurrency(project.stats?.total_cost)} {formatNumber(project.subscribers_count)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatNumber(project.stats?.total_subscriptions)} {formatNumber(project.stats?.total_subscriptions)}
@@ -318,16 +453,34 @@ export default function ProjectsPage() {
{formatNumber(project.stats?.total_views)} {formatNumber(project.stats?.total_views)}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatCurrency(project.stats?.avg_cps)} {formatCurrency(project.stats?.avg_cpf)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button variant="outline" size="sm" asChild> <div className="flex gap-1 flex-wrap">
<Link <Button variant="outline" size="sm" asChild>
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`} <Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупов
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Folder className="h-3 w-3 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
> >
План закупа <Settings className="h-3 w-3 mr-1" />
</Link> Настройки
</Button> </Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -342,9 +495,12 @@ export default function ProjectsPage() {
<CardHeader> <CardHeader>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10"> <Avatar className="h-10 w-10">
<Tv className="h-5 w-5 text-primary" /> <AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
</div> <AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div> <div>
<CardTitle className="text-base">{project.title}</CardTitle> <CardTitle className="text-base">{project.title}</CardTitle>
{project.username && ( {project.username && (
@@ -375,82 +531,222 @@ export default function ProjectsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Stats Grid */} {/* Stats Grid */}
{project.stats && ( <div className="grid grid-cols-2 gap-3 text-sm">
<div className="grid grid-cols-2 gap-3 text-sm"> {project.subscribers_count !== undefined && (
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.total_cost)}
</div>
<div className="text-xs text-muted-foreground">
Потрачено
</div>
</div>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
<div> <div>
<div className="font-medium"> <div className="font-medium">
{formatNumber(project.stats.total_subscriptions)} {formatNumber(project.subscribers_count)}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Подписчиков Подписчиков
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> )}
<Eye className="h-4 w-4 text-muted-foreground" /> {project.stats && (
<div> <>
<div className="font-medium"> <div className="flex items-center gap-2">
{formatNumber(project.stats.total_views)} <Users className="h-4 w-4 text-muted-foreground" />
</div> <div>
<div className="text-xs text-muted-foreground"> <div className="font-medium">
Просмотров {formatNumber(project.stats.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Привлечено подписчиков
</div>
</div> </div>
</div> </div>
</div> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Eye className="h-4 w-4 text-muted-foreground" />
<TrendingUp className="h-4 w-4 text-muted-foreground" /> <div>
<div> <div className="font-medium">
<div className="font-medium"> {formatNumber(project.stats.total_views)}
{formatCurrency(project.stats.avg_cps)} </div>
</div> <div className="text-xs text-muted-foreground">
<div className="text-xs text-muted-foreground"> Суммарный охват
Сред. CPS </div>
</div> </div>
</div> </div>
</div> <div className="flex items-center gap-2">
</div> <TrendingUp className="h-4 w-4 text-muted-foreground" />
)} <div>
<div className="font-medium">
{formatCurrency(project.stats.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div>
</div>
</>
)}
</div>
{/* Actions */} {/* Actions */}
<div className="flex gap-2"> <div className="flex gap-2 flex-wrap">
{project.username && (
<Button variant="outline" size="sm" asChild>
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-1" />
Канал
</a>
</Button>
)}
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link <Link
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`} href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
> >
План закупа План закупов
</Link> </Link>
</Button> </Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Folder className="h-4 w-4 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-4 w-4 mr-1" />
Настройки
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
{/* Settings Dialog */}
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Настройки проекта</DialogTitle>
<DialogDescription>
{settingsProject?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="invite-link-type">Тип ссылки по умолчанию</Label>
<Select
value={inviteLinkType}
onValueChange={(value) => setInviteLinkType(value as "public" | "approval")}
>
<SelectTrigger id="invite-link-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Открытая</SelectItem>
<SelectItem value="approval">С заявками</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта
</p>
</div>
{/* Danger Zone */}
<div className="space-y-3 pt-4 border-t">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-destructive">Опасная зона</h4>
<div className="flex flex-col gap-2">
{settingsProject?.status !== "archived" && (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button>
)}
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
</Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSettingsProject(null)}
>
Отмена
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting}
>
{isUpdatingSettings ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить проект "{settingsProject?.title}"?</AlertDialogTitle>
<AlertDialogDescription>
Это действие необратимо. Все данные проекта будут удалены, включая креативы, размещения и аналитику.
<br /><br />
Для подтверждения введите <strong>Удалить {settingsProject?.title}</strong> ниже:
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={`Удалить ${settingsProject?.title}`}
value={deleteConfirmationText}
onChange={(e) => setDeleteConfirmationText(e.target.value)}
className="mt-4"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCloseDeleteDialog}>
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
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>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -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<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: "Участники" },
]}
/>
<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

@@ -4,15 +4,16 @@
// Workspace Settings Page // Workspace Settings Page
// ============================================================================ // ============================================================================
import { useState } from "react"; import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation"; 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 { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
@@ -20,6 +21,31 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -31,6 +57,33 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } 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() { export default function SettingsPage() {
const params = useParams(); const params = useParams();
@@ -41,6 +94,44 @@ export default function SettingsPage() {
const [name, setName] = useState(currentWorkspace?.name || ""); const [name, setName] = useState(currentWorkspace?.name || "");
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const [isDeleting, setIsDeleting] = 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 () => { const handleUpdate = async () => {
if (!name.trim() || name === currentWorkspace?.name) return; 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 () => { const handleDelete = async () => {
if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return;
try { try {
setIsDeleting(true); setIsDeleting(true);
await workspacesApi.delete(workspaceId); await workspacesApi.delete(workspaceId);
@@ -65,9 +219,22 @@ export default function SettingsPage() {
console.error("Failed to delete workspace:", err); console.error("Failed to delete workspace:", err);
} finally { } finally {
setIsDeleting(false); 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) { if (!currentWorkspace) {
return ( 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> <div>
<h1 className="text-2xl font-bold tracking-tight">Настройки воркспейса</h1> <h1 className="text-2xl font-bold tracking-tight">Настройки воркспейса</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -133,25 +300,228 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Team Management Links */} {/* Team Management */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Команда</CardTitle> <div className="flex items-center justify-between">
<CardDescription> <div>
Управление участниками и приглашениями <CardTitle>Участники</CardTitle>
</CardDescription> <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> </CardHeader>
<CardContent className="space-y-2"> <CardContent>
<Button variant="outline" className="w-full justify-start" asChild> {loadingMembers ? (
<a href={`/dashboard/${workspaceId}/settings/members`}> <div className="flex items-center justify-center py-12">
Участники воркспейса <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</a> </div>
</Button> ) : allPeople.length === 0 ? (
<Button variant="outline" className="w-full justify-start" asChild> <div className="flex flex-col items-center justify-center py-12">
<a href={`/dashboard/${workspaceId}/settings/invites`}> <Users className="h-12 w-12 text-muted-foreground mb-4" />
Приглашения <h3 className="text-lg font-semibold mb-2">Нет участников</h3>
</a> <p className="text-muted-foreground text-center">
</Button> Пригласите участников в воркспейс
</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> </CardContent>
</Card> </Card>
@@ -164,7 +534,7 @@ export default function SettingsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AlertDialog> <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive"> <Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
@@ -179,17 +549,43 @@ export default function SettingsPage() {
удалены, включая проекты, креативы, размещения и аналитику. удалены, включая проекты, креативы, размещения и аналитику.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </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> <AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel> <AlertDialogCancel onClick={() => {
setShowDeleteDialog(false);
setDeleteConfirm("");
}}>
Отмена
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} disabled={
isDeleting ||
deleteConfirm !== `Удалить ${currentWorkspace.name}`
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeleting ? ( {isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" /> <>
) : null} <Loader2 className="h-4 w-4 animate-spin mr-2" />
Удалить Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -200,4 +596,3 @@ export default function SettingsPage() {
</> </>
); );
} }

View File

@@ -155,13 +155,14 @@ const TRAINING_STEPS: TrainingStep[] = [
], ],
}, },
{ {
title: "Что происходит сейчас", title: "Обзор показателей",
description: description:
"Лента событий показывает новые заявки, комментарии и дедлайны. Так команда не пропускает задачи.", "На главной панели отображаются ключевые метрики эффективности рекламных кампаний: общие расходы, охват, количество размещений и подписчиков, а также средние показатели CPM и CPF.",
icon: CalendarClock, icon: CalendarClock,
points: [ points: [
"Фильтр по типу событий помогает отделить, например, комментарии от обновлений статусов.", "Метрики показывают динамику с процентным изменением относительно предыдущего периода.",
"Кнопка «Создать» рядом ведёт к быстрому созданию проектов и размещений.", "Выбор периода позволяет анализировать данные за любой временной интервал.",
"Фильтр по проектам позволяет просматривать статистику по выбранным каналам или всем сразу.",
], ],
}, },
], ],
@@ -435,11 +436,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}); });
} }
// 8. Доступы (без подразделов, админ) // 8. Настройки (без подразделов, админ)
if (isAdmin) { if (isAdmin) {
items.push({ items.push({
title: "Доступы", title: "Настройки",
url: buildRoute.members(workspaceId), url: buildRoute.settings(workspaceId),
icon: Settings, icon: Settings,
requiredPermission: "admin_full", requiredPermission: "admin_full",
}); });
@@ -512,7 +513,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
() => [ () => [
{ {
title: "Наш канал", title: "Наш канал",
href: "https://t.me/tgex_news", href: "https://t.me/+xqHlRelJ2etkMGIy",
icon: Megaphone, icon: Megaphone,
external: true, external: true,
}, },
@@ -766,25 +767,40 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarSeparator /> <SidebarSeparator />
{/* User */} {/* User */}
{user && ( {user && (() => {
<NavUser // Формируем полное имя из first_name и last_name
user={{ const fullName = [user.first_name, user.last_name]
name: user.username || `User ${user.telegram_id}`, .filter(Boolean)
email: user.username .join(" ") || user.username || `User ${user.telegram_id}`;
? `@${user.username}`
: `ID: ${user.telegram_id}`, // Для аватара используем полное имя или username
avatar: user.username const avatarName = [user.first_name, user.last_name]
? `https://ui-avatars.com/api/?name=${user.username}` .filter(Boolean)
: `https://ui-avatars.com/api/?name=User`, .join(" ") || user.username || "User";
}}
workspaceSwitcher={{ // Telegram avatar URL
workspaces, const avatarUrl = user.username
currentWorkspaceId: currentWorkspace?.id, ? `https://t.me/i/userpic/320/${user.username}.jpg`
onSelect: (workspace) => handleWorkspaceSelect(workspace.id), : `https://ui-avatars.com/api/?name=${encodeURIComponent(avatarName)}`;
onCreate: isDemoMode ? undefined : () => setShowCreateDialog(true),
}} return (
/> <NavUser
)} user={{
name: fullName,
email: user.username
? `@${user.username}`
: `ID: ${user.telegram_id}`,
avatar: avatarUrl,
}}
workspaceSwitcher={{
workspaces,
currentWorkspaceId: currentWorkspace?.id,
onSelect: (workspace) => handleWorkspaceSelect(workspace.id),
onCreate: isDemoMode ? undefined : () => setShowCreateDialog(true),
}}
/>
);
})()}
</SidebarFooter> </SidebarFooter>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>

View File

@@ -84,8 +84,8 @@ export function NavMain({
<item.icon <item.icon
className={cn( className={cn(
"h-4 w-4", "h-4 w-4",
(isActive || hasActiveSubmenu) && // (isActive || hasActiveSubmenu) &&
"text-[#2B1D49] dark:text-[#5F31F4]" // "text-[#2B1D49] dark:text-[#5F31F4]"
)} )}
/> />
)} )}
@@ -114,8 +114,8 @@ export function NavMain({
<subItem.icon <subItem.icon
className={cn( className={cn(
"h-4 w-4", "h-4 w-4",
isSubActive && // isSubActive &&
"text-[#2B1D49] dark:text-[#5F31F4]" // "text-[#2B1D49] dark:text-[#5F31F4]"
)} )}
/> />
)} )}
@@ -153,8 +153,8 @@ export function NavMain({
<item.icon <item.icon
className={cn( className={cn(
"h-4 w-4", "h-4 w-4",
(isActive || hasActiveSubmenu) && // (isActive || hasActiveSubmenu) &&
"text-[#2B1D49] dark:text-[#5F31F4]" // "text-[#2B1D49] dark:text-[#5F31F4]"
)} )}
/> />
)} )}
@@ -177,8 +177,8 @@ export function NavMain({
<subItem.icon <subItem.icon
className={cn( className={cn(
"h-4 w-4", "h-4 w-4",
isSubActive && // isSubActive &&
"text-[#2B1D49] dark:text-[#5F31F4]" // "text-[#2B1D49] dark:text-[#5F31F4]"
)} )}
/> />
)} )}
@@ -203,8 +203,8 @@ export function NavMain({
<item.icon <item.icon
className={cn( className={cn(
"h-4 w-4", "h-4 w-4",
isActive && // isActive &&
"text-[#2B1D49] dark:text-[#5F31F4]" // "text-[#2B1D49] dark:text-[#5F31F4]"
)} )}
/> />
)} )}

View File

@@ -5,15 +5,46 @@
import { api } from "./client"; import { api } from "./client";
import type { Project, PaginatedResponse, PaginationParams } from "@/lib/types/api"; import type { Project, PaginatedResponse, PaginationParams } from "@/lib/types/api";
export interface UpdateProjectInviteLinkTypeRequest {
purchase_invite_type_default: "public" | "approval";
}
export const projectsApi = { export const projectsApi = {
/** /**
* Get list of projects for workspace * Get list of projects for workspace
* Projects are created via Telegram bot (add/remove channel) * Projects are created via Telegram bot (add/remove channel)
*/ */
list: (workspaceId: string, params?: PaginationParams) => list: (workspaceId: string, params?: PaginationParams & { include_archived?: boolean }) =>
api.get<PaginatedResponse<Project>>( api.get<PaginatedResponse<Project>>(
`/workspaces/${workspaceId}/projects`, `/workspaces/${workspaceId}/projects`,
{ params } { params }
), ),
/**
* Update project invite link type default
*/
updateInviteLinkType: (
workspaceId: string,
projectId: string,
data: UpdateProjectInviteLinkTypeRequest
) =>
api.patch<Project>(
`/workspaces/${workspaceId}/projects/${projectId}/invite-link-type`,
data
),
/**
* Archive project
*/
archive: (workspaceId: string, projectId: string) =>
api.post<Project>(
`/workspaces/${workspaceId}/projects/${projectId}/archive`
),
/**
* Delete project (soft delete)
*/
delete: (workspaceId: string, projectId: string) =>
api.delete(`/workspaces/${workspaceId}/projects/${projectId}`),
}; };

View File

@@ -15,6 +15,8 @@ export const DEMO_USER = {
id: "demo-user-0000-0000-000000000000", id: "demo-user-0000-0000-000000000000",
telegram_id: 123456789, telegram_id: 123456789,
username: "demo_user", username: "demo_user",
first_name: "Демо",
last_name: "Пользователь",
}; };
// Demo workspace // Demo workspace

View File

@@ -83,13 +83,55 @@ export const demoAwareWorkspacesApi = {
export const demoAwareProjectsApi = { export const demoAwareProjectsApi = {
list: async ( list: async (
workspaceId: string, workspaceId: string,
params?: { page?: number; size?: number } params?: { page?: number; size?: number; include_archived?: boolean }
): Promise<PaginatedResponse<Project>> => { ): Promise<PaginatedResponse<Project>> => {
if (isDemoWorkspace(workspaceId)) { if (isDemoWorkspace(workspaceId)) {
return paginate(demoProjects, params?.page, params?.size); let items = demoProjects;
if (!params?.include_archived) {
items = items.filter(p => p.status !== "archived");
}
return paginate(items, params?.page, params?.size);
} }
return projectsApi.list(workspaceId, params); return projectsApi.list(workspaceId, params);
}, },
updateInviteLinkType: async (
workspaceId: string,
projectId: string,
data: { purchase_invite_type_default: "public" | "approval" }
): Promise<Project> => {
if (isDemoWorkspace(workspaceId)) {
// In demo mode, just return the project (mock update)
const project = demoProjects.find(p => p.id === projectId);
if (!project) throw new Error("Project not found");
return { ...project, purchase_invite_type_default: data.purchase_invite_type_default };
}
return projectsApi.updateInviteLinkType(workspaceId, projectId, data);
},
archive: async (
workspaceId: string,
projectId: string
): Promise<Project> => {
if (isDemoWorkspace(workspaceId)) {
const project = demoProjects.find(p => p.id === projectId);
if (!project) throw new Error("Project not found");
return { ...project, status: "archived" };
}
return projectsApi.archive(workspaceId, projectId);
},
delete: async (
workspaceId: string,
projectId: string
): Promise<void> => {
if (isDemoWorkspace(workspaceId)) {
// In demo mode, just mark as archived (soft delete)
const project = demoProjects.find(p => p.id === projectId);
if (project) {
project.status = "archived";
}
return;
}
return projectsApi.delete(workspaceId, projectId);
},
}; };
// ============================================================================ // ============================================================================

View File

@@ -33,6 +33,8 @@ export const demoMember: WorkspaceMember = {
id: DEMO_USER.id, id: DEMO_USER.id,
telegram_id: DEMO_USER.telegram_id, telegram_id: DEMO_USER.telegram_id,
username: DEMO_USER.username, username: DEMO_USER.username,
first_name: DEMO_USER.first_name,
last_name: DEMO_USER.last_name,
}, },
status: "active", status: "active",
permissions: [{ key: "admin_full" }], permissions: [{ key: "admin_full" }],
@@ -49,6 +51,8 @@ export const demoProjects: Project[] = [
title: "Крипто Новости", title: "Крипто Новости",
username: "crypto_news_demo", username: "crypto_news_demo",
status: "active", status: "active",
subscribers_count: 125000,
purchase_invite_type_default: "public",
}, },
{ {
id: "proj-0002-0000-0000-000000000002", id: "proj-0002-0000-0000-000000000002",
@@ -56,6 +60,8 @@ export const demoProjects: Project[] = [
title: "IT Вакансии", title: "IT Вакансии",
username: "it_jobs_demo", username: "it_jobs_demo",
status: "active", status: "active",
subscribers_count: 89000,
purchase_invite_type_default: "approval",
}, },
{ {
id: "proj-0003-0000-0000-000000000003", id: "proj-0003-0000-0000-000000000003",
@@ -63,6 +69,8 @@ export const demoProjects: Project[] = [
title: "Стартапы и Бизнес", title: "Стартапы и Бизнес",
username: "startups_demo", username: "startups_demo",
status: "active", status: "active",
subscribers_count: 67000,
purchase_invite_type_default: "public",
}, },
]; ];

View File

@@ -10,6 +10,8 @@ export interface User {
id: string; // UUID id: string; // UUID
telegram_id: number; telegram_id: number;
username: string | null; username: string | null;
first_name: string | null;
last_name: string | null;
} }
export interface AuthCompleteResponse { export interface AuthCompleteResponse {
@@ -80,6 +82,8 @@ export interface WorkspaceMemberUser {
id: string; // user_id id: string; // user_id
telegram_id: number; telegram_id: number;
username: string | null; username: string | null;
first_name: string | null;
last_name: string | null;
} }
export interface WorkspaceMember { export interface WorkspaceMember {
@@ -122,6 +126,8 @@ export interface Project {
title: string; title: string;
username: string | null; username: string | null;
status: ProjectStatus; status: ProjectStatus;
subscribers_count?: number;
purchase_invite_type_default?: "public" | "approval";
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------