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

@@ -1,181 +0,0 @@
"use client";
import React from "react";
import { CartesianGrid, Line, LineChart, XAxis, Bar, BarChart } from "recharts";
import { CardContent } from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
} from "@/components/ui/chart";
import { formatCurrency, formatNumber } from "@/lib/utils/format";
import type { SpendingDataPoint, AnalyticsMetric } from "@/lib/types/api";
interface AnalyticsChartProps {
data: SpendingDataPoint[];
metrics: AnalyticsMetric[];
chartType: "line" | "bar";
showDataLabels?: boolean;
}
const METRIC_LABELS: Record<AnalyticsMetric, string> = {
cost: "Расходы",
subscriptions: "Подписки",
views: "Просмотры",
cpf: "CPF",
cpm: "CPM",
};
export const AnalyticsChart: React.FC<AnalyticsChartProps> = ({
data,
metrics,
chartType,
showDataLabels = false,
}) => {
// Создаем конфигурацию для chart
const chartConfig: ChartConfig = metrics.reduce((acc, metric, index) => {
acc[metric] = {
label: METRIC_LABELS[metric],
color: `var(--chart-${index + 1})`,
};
return acc;
}, {} as ChartConfig);
const formatValue = (value: number | null, metric: AnalyticsMetric) => {
if (value === null) return "—";
if (metric === "cost" || metric === "cpf" || metric === "cpm") {
return formatCurrency(value);
}
return formatNumber(value);
};
if (chartType === "bar") {
return (
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[350px] w-full"
>
<BarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
top: showDataLabels ? 20 : 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) => `Период: ${value}`}
formatter={(value, name) => [
formatValue(
value as number,
name as AnalyticsMetric
),
chartConfig[name as AnalyticsMetric]?.label || name,
]}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{metrics.map((metric) => (
<Bar
key={metric}
dataKey={metric}
fill={chartConfig[metric]?.color}
radius={4}
label={
showDataLabels
? {
position: "top",
formatter: (value: number) =>
formatValue(value, metric),
fontSize: 10,
}
: undefined
}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
);
}
return (
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[350px] w-full"
>
<LineChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
top: showDataLabels ? 20 : 5,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) => `Период: ${value}`}
formatter={(value, name) => [
formatValue(
value as number,
name as AnalyticsMetric
),
chartConfig[name as AnalyticsMetric]?.label || name,
]}
/>
}
/>
<ChartLegend content={<ChartLegendContent />} />
{metrics.map((metric) => (
<Line
key={metric}
dataKey={metric}
type="monotone"
stroke={chartConfig[metric]?.color}
strokeWidth={2}
dot={showDataLabels}
label={
showDataLabels
? {
position: "top",
formatter: (value: number) =>
formatValue(value, metric),
fontSize: 10,
}
: undefined
}
/>
))}
</LineChart>
</ChartContainer>
</CardContent>
);
};

View File

@@ -1,438 +0,0 @@
"use client";
import React, { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import {
CalendarIcon,
X,
TrendingUp,
BarChart3,
LineChart,
GripVertical,
Maximize2,
Columns2,
} from "lucide-react";
import type {
TargetChannel,
AnalyticsMetric,
DateGrouping,
} from "@/lib/types/api";
import { AnalyticsChart } from "./analytics-chart";
import type { SpendingAnalytics } from "@/lib/types/api";
interface ChartConfigPanelProps {
channels: TargetChannel[];
data: SpendingAnalytics | null;
loading: boolean;
width: "full" | "half";
onBuild: (config: {
targetChannelIds: string[];
metrics: AnalyticsMetric[];
dateFrom: Date | null;
dateTo: Date | null;
grouping: DateGrouping;
}) => void;
onRemove?: () => void;
onWidthChange?: (width: "full" | "half") => void;
showRemoveButton?: boolean;
chartNumber?: number;
dragHandleProps?: any;
}
const METRICS: { value: AnalyticsMetric; label: string }[] = [
{ value: "cost", label: "Расходы" },
{ value: "subscriptions", label: "Подписки" },
{ value: "views", label: "Просмотры" },
{ value: "cpf", label: "CPF (Cost Per Follow)" },
{ value: "cpm", label: "CPM (Cost Per Mille)" },
];
const GROUPINGS: { value: DateGrouping; label: string }[] = [
{ value: "day", label: "По дням" },
{ value: "week", label: "По неделям" },
{ value: "month", label: "По месяцам" },
{ value: "quarter", label: "По кварталам" },
{ value: "year", label: "По годам" },
];
export const ChartConfigPanel: React.FC<ChartConfigPanelProps> = ({
channels,
data,
loading,
width,
onBuild,
onRemove,
onWidthChange,
showRemoveButton = false,
chartNumber = 1,
dragHandleProps,
}) => {
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedMetrics, setSelectedMetrics] = useState<AnalyticsMetric[]>([]);
const [dateFrom, setDateFrom] = useState<Date | undefined>();
const [dateTo, setDateTo] = useState<Date | undefined>();
const [grouping, setGrouping] = useState<DateGrouping>("day");
const [chartType, setChartType] = useState<"line" | "bar">("line");
const [showDataLabels, setShowDataLabels] = useState(false);
// Отслеживание изменений настроек
const [lastBuiltConfig, setLastBuiltConfig] = useState<string | null>(null);
const [isBuilt, setIsBuilt] = useState(false);
// Текущая конфигурация как строка для сравнения
const currentConfigString = JSON.stringify({
selectedChannelIds,
selectedMetrics,
dateFrom: dateFrom?.toISOString(),
dateTo: dateTo?.toISOString(),
grouping,
});
// Проверяем, изменились ли настройки
const hasChanges = isBuilt && lastBuiltConfig !== currentConfigString;
const toggleChannel = (channelId: string) => {
setSelectedChannelIds((prev) =>
prev.includes(channelId)
? prev.filter((id) => id !== channelId)
: [...prev, channelId]
);
};
const toggleMetric = (metric: AnalyticsMetric) => {
setSelectedMetrics((prev) =>
prev.includes(metric)
? prev.filter((m) => m !== metric)
: [...prev, metric]
);
};
const handleBuild = () => {
if (selectedChannelIds.length === 0 || selectedMetrics.length === 0) {
alert("Выберите хотя бы один канал и одну метрику");
return;
}
onBuild({
targetChannelIds: selectedChannelIds,
metrics: selectedMetrics,
dateFrom: dateFrom || null,
dateTo: dateTo || null,
grouping,
});
setLastBuiltConfig(currentConfigString);
setIsBuilt(true);
};
const getButtonText = () => {
if (loading) return "Загрузка...";
if (!isBuilt) return "Построить график";
if (hasChanges) return "Обновить график";
return "График построен";
};
const isButtonDisabled = loading || (isBuilt && !hasChanges);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{/* Drag handle */}
<div
{...dragHandleProps}
className="cursor-grab active:cursor-grabbing touch-none"
>
<GripVertical className="h-5 w-5 text-muted-foreground hover:text-foreground" />
</div>
<div>
<CardTitle>График #{chartNumber}</CardTitle>
<CardDescription className="mt-1">
Настройте параметры и постройте график
</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
{/* Width toggle */}
{onWidthChange && (
<div className="flex gap-1 border rounded-md mr-2">
<Button
variant={width === "full" ? "default" : "ghost"}
size="sm"
onClick={() => onWidthChange("full")}
className="rounded-r-none"
title="Полная ширина"
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant={width === "half" ? "default" : "ghost"}
size="sm"
onClick={() => onWidthChange("half")}
className="rounded-l-none"
title="Половина ширины"
>
<Columns2 className="h-4 w-4" />
</Button>
</div>
)}
{/* Remove button */}
{showRemoveButton && onRemove && (
<Button variant="ghost" size="sm" onClick={onRemove}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Настройки */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Выбор каналов */}
<div className="space-y-2">
<Label>Целевые каналы</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start">
{selectedChannelIds.length === 0
? "Выберите каналы"
: `Выбрано: ${selectedChannelIds.length}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-2">
{channels.map((channel) => (
<div
key={channel.id}
className="flex items-center space-x-2"
>
<Checkbox
id={`channel-${channel.id}`}
checked={selectedChannelIds.includes(channel.id)}
onCheckedChange={() => toggleChannel(channel.id)}
/>
<label
htmlFor={`channel-${channel.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{channel.title}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Выбор метрик */}
<div className="space-y-2">
<Label>Метрики</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start">
{selectedMetrics.length === 0
? "Выберите метрики"
: `Выбрано: ${selectedMetrics.length}`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="start">
<div className="space-y-2">
{METRICS.map((metric) => (
<div
key={metric.value}
className="flex items-center space-x-2"
>
<Checkbox
id={`metric-${metric.value}`}
checked={selectedMetrics.includes(metric.value)}
onCheckedChange={() => toggleMetric(metric.value)}
/>
<label
htmlFor={`metric-${metric.value}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{metric.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Период от */}
<div className="space-y-2">
<Label>Дата от</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateFrom && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateFrom ? (
format(dateFrom, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateFrom}
onSelect={setDateFrom}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{/* Период до */}
<div className="space-y-2">
<Label>Дата до</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!dateTo && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateTo ? (
format(dateTo, "d MMM yyyy", { locale: ru })
) : (
<span>Выберите</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateTo}
onSelect={setDateTo}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex items-center gap-4">
{/* Группировка */}
<div className="flex-1 space-y-2">
<Label>Группировка</Label>
<Select
value={grouping}
onValueChange={(v) => setGrouping(v as DateGrouping)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{GROUPINGS.map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Тип графика */}
<div className="space-y-2">
<Label>Тип</Label>
<div className="flex gap-1 border rounded-md">
<Button
variant={chartType === "line" ? "default" : "ghost"}
size="sm"
onClick={() => setChartType("line")}
className="rounded-r-none"
>
<LineChart className="h-4 w-4" />
</Button>
<Button
variant={chartType === "bar" ? "default" : "ghost"}
size="sm"
onClick={() => setChartType("bar")}
className="rounded-l-none"
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Подписи значений */}
<div className="space-y-2">
<Label>Подписи</Label>
<div className="flex items-center space-x-2 h-9">
<Switch
id="show-data-labels"
checked={showDataLabels}
onCheckedChange={setShowDataLabels}
/>
<Label htmlFor="show-data-labels" className="cursor-pointer">
{showDataLabels ? "Вкл" : "Выкл"}
</Label>
</div>
</div>
{/* Кнопка построения */}
<div className="space-y-2">
<Label className="invisible">Построить</Label>
<Button onClick={handleBuild} disabled={isButtonDisabled}>
<TrendingUp
className={cn("mr-2 h-4 w-4", loading && "animate-pulse")}
/>
{getButtonText()}
</Button>
</div>
</div>
{/* График */}
{data && (
<AnalyticsChart
data={data.chart_data}
metrics={selectedMetrics}
chartType={chartType}
showDataLabels={showDataLabels}
/>
)}
</CardContent>
</Card>
);
};

View File

@@ -5,8 +5,9 @@
// ============================================================================
import React, { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useParams } from "next/navigation";
import { useAuth } from "./auth-provider";
import { isDemoWorkspace } from "@/lib/demo";
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -15,12 +16,25 @@ interface ProtectedRouteProps {
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
const router = useRouter();
const params = useParams();
// Check if this is demo mode
const workspaceId = params?.workspaceId as string | undefined;
const isDemo = isDemoWorkspace(workspaceId);
useEffect(() => {
// Skip auth check for demo mode
if (isDemo) return;
if (!loading && !user) {
router.push("/login");
}
}, [user, loading, router]);
}, [user, loading, router, isDemo]);
// Demo mode - skip auth entirely
if (isDemo) {
return <>{children}</>;
}
if (loading) {
return (

View File

@@ -0,0 +1,59 @@
"use client";
// ============================================================================
// Demo Banner Component
// ============================================================================
import Link from "next/link";
import { Sparkles, ArrowRight, X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { BOT_USERNAME } from "@/lib/utils/constants";
interface DemoBannerProps {
className?: string;
}
export const DemoBanner: React.FC<DemoBannerProps> = ({ className }) => {
const [isVisible, setIsVisible] = useState(true);
if (!isVisible) return null;
return (
<div
className={`
relative w-full bg-gradient-to-r from-violet-600 via-fuchsia-500 to-pink-500
text-white py-2.5 px-4 text-center text-sm font-medium
shadow-lg shadow-fuchsia-500/20
${className || ""}
`}
>
<div className="flex items-center justify-center gap-2 flex-wrap">
<Sparkles className="h-4 w-4 animate-pulse" />
<span>Вы просматриваете демо-версию платформы</span>
<span className="hidden sm:inline text-white/80"></span>
<a
href={`https://t.me/${BOT_USERNAME}?start=register`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-white/90 transition-colors"
>
Создать свой кабинет
<ArrowRight className="h-3.5 w-3.5" />
</a>
<span className="text-white/60 hidden md:inline">
и опробовать всю мощь платформы!
</span>
</div>
<button
onClick={() => setIsVisible(false)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-white/20 transition-colors"
aria-label="Закрыть баннер"
>
<X className="h-4 w-4" />
</button>
</div>
);
};

View File

@@ -5,15 +5,25 @@
// ============================================================================
import * as React from "react";
import { useParams } from "next/navigation";
import {
BarChart3,
Building2,
Check,
ChevronsUpDown,
ClipboardList,
ExternalLink,
Folder,
HelpCircle,
LayoutDashboard,
Megaphone,
MessageCircle,
Plus,
Settings,
ShoppingCart,
TrendingUp,
Settings2,
ExternalLink,
Tv,
BookOpen,
type LucideIcon,
} from "lucide-react";
import { NavMain } from "@/components/nav-main";
@@ -23,126 +33,419 @@ import {
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
SidebarGroup,
SidebarGroupContent,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/components/auth/auth-provider";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { buildRoute, BOT_USERNAME } from "@/lib/utils/constants";
const data = {
navMain: [
{
title: "Главная",
url: "/",
icon: LayoutDashboard,
isActive: true,
},
{
title: "Внешние каналы",
url: "/external-channels",
icon: ExternalLink,
items: [
{
title: "Каталог",
url: "/external-channels",
},
{
title: "Импорт",
url: "/external-channels/import",
},
],
},
{
title: "Креативы",
url: "/creatives",
icon: Folder,
items: [
{
title: "Все креативы",
url: "/creatives",
},
{
title: "Создать",
url: "/creatives/new",
},
],
},
{
title: "Закупы",
url: "/purchases",
icon: ShoppingCart,
items: [
{
title: "Все закупы",
url: "/purchases",
},
{
title: "Создать",
url: "/purchases/new",
},
],
},
{
title: "Аналитика",
url: "/analytics",
icon: BarChart3,
items: [
{
title: "Обзор",
url: "/analytics",
},
{
title: "Затраты",
url: "/analytics/costs",
},
{
title: "Целевые каналы",
url: "/analytics/target-channels",
},
{
title: "Креативы",
url: "/analytics/creatives",
},
],
},
],
};
// ============================================================================
// Types
// ============================================================================
interface NavItem {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
items?: {
title: string;
url: string;
}[];
}
interface FooterLink {
title: string;
url: string;
icon: LucideIcon;
external?: boolean;
}
// ============================================================================
// Footer Links Configuration
// ============================================================================
const footerLinks: FooterLink[] = [
{
title: "Поддержка",
url: `https://t.me/${BOT_USERNAME}`,
icon: MessageCircle,
external: true,
},
{
title: "Информационный канал",
url: "https://t.me/tgex_news",
icon: Megaphone,
external: true,
},
{
title: "База знаний",
url: "https://docs.tgex.io",
icon: BookOpen,
external: true,
},
];
// ============================================================================
// Component
// ============================================================================
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { user } = useAuth();
const {
workspaces,
currentWorkspace,
setCurrentWorkspace,
hasPermission,
isAdmin,
createWorkspace,
} = useWorkspace();
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
const [isCreating, setIsCreating] = React.useState(false);
// Build navigation items based on permissions
const navItems: NavItem[] = React.useMemo(() => {
if (!workspaceId) return [];
const items: NavItem[] = [
{
title: "Главная",
url: buildRoute.dashboard(workspaceId),
icon: LayoutDashboard,
isActive: true,
},
];
// Projects - always visible (contains instructions how to add projects via bot)
items.push({
title: "Проекты",
url: buildRoute.projects(workspaceId),
icon: Tv,
});
// Channels catalog (available to all)
items.push({
title: "Каталог каналов",
url: buildRoute.channels(workspaceId),
icon: Building2,
});
// Purchase Plans (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Планы закупок",
url: buildRoute.purchasePlans(workspaceId),
icon: ClipboardList,
requiredPermission: "placements_read",
});
}
// Creatives (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Креативы",
url: buildRoute.creatives(workspaceId),
icon: Folder,
requiredPermission: "placements_read",
items: [
{
title: "Все креативы",
url: buildRoute.creatives(workspaceId),
},
...(hasPermission("placements_write")
? [
{
title: "Создать",
url: buildRoute.creativeNew(workspaceId),
},
]
: []),
],
});
}
// Placements (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Размещения",
url: buildRoute.placements(workspaceId),
icon: ShoppingCart,
requiredPermission: "placements_read",
items: [
{
title: "Все размещения",
url: buildRoute.placements(workspaceId),
},
...(hasPermission("placements_write")
? [
{
title: "Создать",
url: buildRoute.placementNew(workspaceId),
},
]
: []),
],
});
}
// Analytics (requires analytics_read or admin)
if (hasPermission("analytics_read")) {
items.push({
title: "Аналитика",
url: buildRoute.analytics(workspaceId),
icon: BarChart3,
requiredPermission: "analytics_read",
items: [
{
title: "Обзор",
url: buildRoute.analytics(workspaceId),
},
{
title: "Расходы",
url: buildRoute.analyticsSpending(workspaceId),
},
{
title: "Каналы",
url: buildRoute.analyticsChannels(workspaceId),
},
{
title: "Креативы",
url: buildRoute.analyticsCreatives(workspaceId),
},
],
});
}
// Settings (admin only)
if (isAdmin) {
items.push({
title: "Настройки",
url: buildRoute.settings(workspaceId),
icon: Settings,
requiredPermission: "admin_full",
items: [
{
title: "Участники",
url: buildRoute.members(workspaceId),
},
{
title: "Приглашения",
url: buildRoute.invites(workspaceId),
},
],
});
}
return items;
}, [workspaceId, hasPermission, isAdmin]);
const handleCreateWorkspace = async () => {
if (!newWorkspaceName.trim()) return;
try {
setIsCreating(true);
const workspace = await createWorkspace(newWorkspaceName.trim());
setShowCreateDialog(false);
setNewWorkspaceName("");
setCurrentWorkspace(workspace);
} catch (err) {
console.error("Failed to create workspace:", err);
} finally {
setIsCreating(false);
}
};
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Megaphone className="h-4 w-4" />
<>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
{/* Workspace Switcher */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Megaphone className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentWorkspace?.name || "TGEX"}
</span>
<span className="truncate text-xs text-muted-foreground">
{workspaces.length} воркспейс{workspaces.length === 1 ? "" : workspaces.length < 5 ? "а" : "ов"}
</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side="bottom"
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Воркспейсы
</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => setCurrentWorkspace(workspace)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Building2 className="size-4 shrink-0" />
</div>
<span className="flex-1 truncate">{workspace.name}</span>
{currentWorkspace?.id === workspace.id && (
<Check className="size-4 ml-auto" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowCreateDialog(true)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<span className="text-muted-foreground">
Создать воркспейс
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
{/* Footer Links */}
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{footerLinks.map((link) => (
<SidebarMenuItem key={link.title}>
<SidebarMenuButton asChild size="sm">
<a
href={link.url}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<link.icon className="size-4" />
<span>{link.title}</span>
{link.external && (
<ExternalLink className="size-3 ml-auto opacity-50" />
)}
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* User */}
{user && (
<NavUser
user={{
name: user.username || `User ${user.telegram_id}`,
email: user.username
? `@${user.username}`
: `ID: ${user.telegram_id}`,
avatar: user.username
? `https://ui-avatars.com/api/?name=${user.username}`
: `https://ui-avatars.com/api/?name=User`,
}}
/>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
{/* Create Workspace Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Создать воркспейс</DialogTitle>
<DialogDescription>
Воркспейс это изолированное пространство для вашей команды
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="workspace-name">Название</Label>
<Input
id="workspace-name"
placeholder="Например: Моя команда"
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateWorkspace();
}
}}
/>
</div>
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">TGEX</span>
<span className="truncate text-xs text-muted-foreground">
Управление закупками
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
</SidebarContent>
<SidebarFooter>
{user && (
<NavUser
user={{
name: user.username || `User ${user.telegram_id}`,
email: user.username
? `@${user.username}`
: `ID: ${user.telegram_id}`,
avatar: user.username
? `https://ui-avatars.com/api/?name=${user.username}`
: `https://ui-avatars.com/api/?name=User`,
}}
/>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Отмена
</Button>
<Button
onClick={handleCreateWorkspace}
disabled={!newWorkspaceName.trim() || isCreating}
>
{isCreating ? "Создание..." : "Создать"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -15,20 +15,22 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ThemeToggle } from "@/components/theme-toggle";
import { TargetChannelSelector } from "@/components/target-channel-selector";
import { ProjectSelector } from "@/components/project-selector";
import React from "react";
interface BreadcrumbItem {
interface BreadcrumbItemType {
label: string;
href?: string;
}
interface DashboardHeaderProps {
breadcrumbs?: BreadcrumbItem[];
breadcrumbs?: BreadcrumbItemType[];
showProjectSelector?: boolean;
}
export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
breadcrumbs = [],
showProjectSelector = true,
}) => {
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
@@ -42,9 +44,7 @@ export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{item.href ? (
<BreadcrumbLink href={item.href}>
{item.label}
</BreadcrumbLink>
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
) : (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
)}
@@ -55,7 +55,7 @@ export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
</Breadcrumb>
)}
<div className="ml-auto flex items-center gap-2">
<TargetChannelSelector />
{showProjectSelector && <ProjectSelector />}
<ThemeToggle />
</div>
</header>

View File

@@ -0,0 +1,26 @@
"use client";
// ============================================================================
// Dashboard Layout Content
// ============================================================================
import { useWorkspace } from "@/components/providers/workspace-provider";
import { DemoBanner } from "@/components/demo-banner";
interface DashboardLayoutContentProps {
children: React.ReactNode;
}
export const DashboardLayoutContent: React.FC<DashboardLayoutContentProps> = ({
children,
}) => {
const { isDemoMode } = useWorkspace();
return (
<div className="flex flex-col h-full">
{isDemoMode && <DemoBanner />}
<div className="flex-1 overflow-auto">{children}</div>
</div>
);
};

View File

@@ -0,0 +1,147 @@
"use client";
// ============================================================================
// Project Selector (Multi-select)
// ============================================================================
import * as React from "react";
import { Check, ChevronsUpDown, Tv, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { cn } from "@/lib/utils";
export const ProjectSelector: React.FC = () => {
const {
projects,
selectedProjects,
toggleProject,
selectAllProjects,
clearProjectSelection,
isLoadingProjects,
} = useWorkspace();
if (isLoadingProjects) {
return (
<div className="w-[240px] h-11 rounded-lg border border-input bg-background animate-pulse" />
);
}
if (projects.length === 0) {
return (
<div className="text-sm text-muted-foreground px-3 py-2">
Нет проектов
</div>
);
}
const allSelected = selectedProjects.length === projects.length;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[280px] h-auto min-h-[44px] py-2 px-3 rounded-lg justify-between"
>
<div className="flex flex-col items-start text-left overflow-hidden">
{selectedProjects.length === 0 ? (
<span className="text-muted-foreground">Выберите проекты</span>
) : selectedProjects.length === 1 ? (
<>
<span className="font-semibold text-base truncate max-w-[220px]">
{selectedProjects[0].title}
</span>
{selectedProjects[0].username && (
<span className="text-xs text-muted-foreground -mt-0.5">
@{selectedProjects[0].username}
</span>
)}
</>
) : allSelected ? (
<span className="font-semibold text-base">Все проекты</span>
) : (
<>
<span className="font-semibold text-base">
{selectedProjects.length} проекта
</span>
<span className="text-xs text-muted-foreground -mt-0.5 truncate max-w-[220px]">
{selectedProjects.map((p) => p.title).join(", ")}
</span>
</>
)}
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[280px] p-2" align="start">
{/* Quick actions */}
<div className="flex gap-2 mb-2">
<Button
variant="ghost"
size="sm"
className="flex-1 text-xs"
onClick={selectAllProjects}
disabled={allSelected}
>
Выбрать все
</Button>
<Button
variant="ghost"
size="sm"
className="flex-1 text-xs"
onClick={clearProjectSelection}
disabled={selectedProjects.length <= 1}
>
Сбросить
</Button>
</div>
<DropdownMenuSeparator />
{/* Projects list */}
<div className="max-h-[300px] overflow-y-auto">
{projects.map((project) => {
const isSelected = selectedProjects.some((p) => p.id === project.id);
const isOnlyOne = selectedProjects.length === 1 && isSelected;
return (
<div
key={project.id}
className={cn(
"flex items-center gap-3 px-2 py-2 rounded-md cursor-pointer hover:bg-accent",
isSelected && "bg-accent/50"
)}
onClick={() => !isOnlyOne && toggleProject(project)}
>
<Checkbox
checked={isSelected}
disabled={isOnlyOne}
onCheckedChange={() => !isOnlyOne && toggleProject(project)}
className="shrink-0"
/>
<Tv className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex flex-col min-w-0 flex-1">
<span className="font-medium truncate">{project.title}</span>
{project.username && (
<span className="text-xs text-muted-foreground">
@{project.username}
</span>
)}
</div>
</div>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -2,5 +2,4 @@
// Providers Export
// ============================================================================
export { TargetChannelProvider, useTargetChannel } from "./target-channel-provider";
export { WorkspaceProvider, useWorkspace } from "./workspace-provider";

View File

@@ -1,101 +0,0 @@
"use client";
// ============================================================================
// Target Channel Provider
// ============================================================================
import React, { createContext, useContext, useState, useEffect } from "react";
import { channelsApi } from "@/lib/api";
import type { TargetChannel } from "@/lib/types/api";
interface TargetChannelContextType {
channels: TargetChannel[];
selectedChannel: TargetChannel | null;
setSelectedChannel: (channel: TargetChannel | null) => void;
isLoading: boolean;
error: string | null;
refreshChannels: () => Promise<void>;
}
const TargetChannelContext = createContext<TargetChannelContextType | undefined>(
undefined
);
export const useTargetChannel = () => {
const context = useContext(TargetChannelContext);
if (!context) {
throw new Error(
"useTargetChannel must be used within TargetChannelProvider"
);
}
return context;
};
export const TargetChannelProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [channels, setChannels] = useState<TargetChannel[]>([]);
const [selectedChannel, setSelectedChannelState] = useState<TargetChannel | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadChannels = async () => {
try {
setIsLoading(true);
setError(null);
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter((c) => c.is_active);
setChannels(activeChannels);
// Загружаем сохраненный канал из localStorage
const savedChannelId = localStorage.getItem("selected_channel_id");
if (savedChannelId && activeChannels.length > 0) {
const savedChannel = activeChannels.find((c) => c.id === savedChannelId);
if (savedChannel) {
setSelectedChannelState(savedChannel);
} else {
// Если сохраненный канал не найден, выбираем первый
setSelectedChannelState(activeChannels[0]);
localStorage.setItem("selected_channel_id", activeChannels[0].id);
}
} else if (activeChannels.length > 0) {
// Если нет сохраненного канала, выбираем первый
setSelectedChannelState(activeChannels[0]);
localStorage.setItem("selected_channel_id", activeChannels[0].id);
}
} catch (err: any) {
setError(err?.error?.message || "Ошибка загрузки каналов");
} finally {
setIsLoading(false);
}
};
const setSelectedChannel = (channel: TargetChannel | null) => {
setSelectedChannelState(channel);
if (channel) {
localStorage.setItem("selected_channel_id", channel.id);
} else {
localStorage.removeItem("selected_channel_id");
}
};
useEffect(() => {
loadChannels();
}, []);
return (
<TargetChannelContext.Provider
value={{
channels,
selectedChannel,
setSelectedChannel,
isLoading,
error,
refreshChannels: loadChannels,
}}
>
{children}
</TargetChannelContext.Provider>
);
};

View File

@@ -0,0 +1,468 @@
"use client";
// ============================================================================
// Workspace Provider
// ============================================================================
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { useParams, useRouter, usePathname } from "next/navigation";
import { workspacesApi, projectsApi, membersApi, authApi } from "@/lib/api";
import type {
Workspace,
Project,
Permission,
PermissionKey,
} from "@/lib/types/api";
import { STORAGE_KEYS } from "@/lib/utils/constants";
import { isDemoWorkspace, demoApi, demoWorkspace, demoProjects } from "@/lib/demo";
// ============================================================================
// Types
// ============================================================================
interface WorkspaceContextType {
// Workspaces
workspaces: Workspace[];
currentWorkspace: Workspace | null;
setCurrentWorkspace: (workspace: Workspace) => void;
// Projects (Target Channels)
projects: Project[];
selectedProjects: Project[];
currentProject: Project | null; // First selected or null (for backward compatibility)
setSelectedProjects: (projects: Project[]) => void;
toggleProject: (project: Project) => void;
selectAllProjects: () => void;
clearProjectSelection: () => void;
// Helper for API filtering
// Returns project_id only if single project selected, undefined otherwise
getProjectFilterId: () => string | undefined;
// Returns true if all projects are selected
allProjectsSelected: boolean;
// Demo mode
isDemoMode: boolean;
// Permissions
permissions: Permission[];
hasPermission: (key: PermissionKey, projectId?: string) => boolean;
isAdmin: boolean;
// Loading states
isLoading: boolean;
isLoadingProjects: boolean;
error: string | null;
// Actions
refreshWorkspaces: () => Promise<Workspace[]>;
refreshProjects: () => Promise<void>;
createWorkspace: (name: string) => Promise<Workspace>;
}
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
undefined
);
// ============================================================================
// Hook
// ============================================================================
export const useWorkspace = () => {
const context = useContext(WorkspaceContext);
if (!context) {
throw new Error("useWorkspace must be used within WorkspaceProvider");
}
return context;
};
// ============================================================================
// Provider
// ============================================================================
interface WorkspaceProviderProps {
children: React.ReactNode;
}
export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
children,
}) => {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
// State
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProjects, setSelectedProjectsState] = useState<Project[]>([]);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [error, setError] = useState<string | null>(null);
// Get workspaceId from URL params
const workspaceIdFromUrl = params?.workspaceId as string | undefined;
// Check if demo mode
const isDemoMode = isDemoWorkspace(workspaceIdFromUrl);
// Current project (first selected for backward compatibility)
const currentProject = selectedProjects.length > 0 ? selectedProjects[0] : null;
// Check if all projects are selected
const allProjectsSelected =
projects.length > 0 && selectedProjects.length === projects.length;
// Get project_id for API filtering
// Returns undefined if multiple projects selected (to get workspace-wide data)
const getProjectFilterId = useCallback(() => {
if (selectedProjects.length === 1) {
return selectedProjects[0].id;
}
// Multiple projects selected - return undefined to get all data
return undefined;
}, [selectedProjects]);
// ============================================================================
// Permission Helpers
// ============================================================================
const isAdmin = permissions.some((p) => p.key === "admin_full");
const hasPermission = useCallback(
(key: PermissionKey, projectId?: string): boolean => {
// Admin has all permissions
if (isAdmin) return true;
// Find permission by key
const permission = permissions.find((p) => p.key === key);
if (!permission) return false;
// If no scopes defined, permission applies globally
if (!permission.scopes || permission.scopes.length === 0) return true;
// If projectId provided, check if it's in scopes
if (projectId) {
return permission.scopes.some(
(s) => s.type === "project" && s.id === projectId
);
}
// Permission exists but is scoped - need projectId to verify
return true;
},
[permissions, isAdmin]
);
// ============================================================================
// Load Workspaces
// ============================================================================
const loadWorkspaces = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
// Demo mode - use mock data
if (isDemoMode) {
setWorkspaces([demoWorkspace]);
setCurrentWorkspaceState(demoWorkspace);
setIsLoading(false);
return [demoWorkspace];
}
const response = await workspacesApi.list({ size: 100 });
setWorkspaces(response.items);
// If we have workspaceId in URL, select that workspace
if (workspaceIdFromUrl && response.items.length > 0) {
const workspace = response.items.find((w) => w.id === workspaceIdFromUrl);
if (workspace) {
setCurrentWorkspaceState(workspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
} else {
// Workspace from URL not found, redirect to first available
const firstWorkspace = response.items[0];
setCurrentWorkspaceState(firstWorkspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, firstWorkspace.id);
// Redirect to correct workspace
const newPath = pathname.replace(
`/dashboard/${workspaceIdFromUrl}`,
`/dashboard/${firstWorkspace.id}`
);
router.replace(newPath);
}
} else if (response.items.length > 0) {
// Try to restore from localStorage or use first
const savedId = localStorage.getItem(STORAGE_KEYS.SELECTED_WORKSPACE);
const saved = savedId
? response.items.find((w) => w.id === savedId)
: null;
const workspace = saved || response.items[0];
setCurrentWorkspaceState(workspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
}
return response.items;
} catch (err: any) {
console.error("Failed to load workspaces:", err);
setError(err?.error?.message || "Ошибка загрузки воркспейсов");
return [];
} finally {
setIsLoading(false);
}
}, [workspaceIdFromUrl, pathname, router, isDemoMode]);
// ============================================================================
// Load Projects & Permissions
// ============================================================================
const loadProjectsAndPermissions = useCallback(async (workspaceId: string) => {
try {
setIsLoadingProjects(true);
// Demo mode - use mock data
if (isDemoMode) {
const activeProjects = demoProjects.filter((p) => p.status === "active");
setProjects(activeProjects);
setPermissions([{ key: "admin_full" }]);
setSelectedProjectsState(activeProjects.length > 0 ? [activeProjects[0]] : []);
setIsLoadingProjects(false);
return;
}
// Load projects
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
const activeProjects = projectsResponse.items.filter(
(p) => p.status === "active"
);
setProjects(activeProjects);
// Load current user's permissions
const me = await authApi.me();
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
// Find member by user.id (not by member record id)
const currentMember = membersResponse.items.find((m) => m.user.id === me.id);
if (currentMember) {
// If member found but no permissions, they're likely the creator - grant admin
if (currentMember.permissions.length === 0) {
setPermissions([{ key: "admin_full" }]);
} else {
setPermissions(currentMember.permissions);
}
} else {
// User not in members list - might be creator, check if only workspace
// Grant admin access by default for workspace creators
if (membersResponse.items.length === 0) {
setPermissions([{ key: "admin_full" }]);
} else {
setPermissions([]);
}
}
// Restore selected projects from localStorage
const savedProjectIds = localStorage.getItem(
`${STORAGE_KEYS.SELECTED_PROJECT}_${workspaceId}`
);
if (savedProjectIds && activeProjects.length > 0) {
try {
const ids = JSON.parse(savedProjectIds) as string[];
const savedProjects = activeProjects.filter((p) => ids.includes(p.id));
if (savedProjects.length > 0) {
setSelectedProjectsState(savedProjects);
} else if (activeProjects.length > 0) {
setSelectedProjectsState([activeProjects[0]]);
}
} catch {
// Old format (single id), migrate to array
const savedProject = activeProjects.find((p) => p.id === savedProjectIds);
if (savedProject) {
setSelectedProjectsState([savedProject]);
} else if (activeProjects.length > 0) {
setSelectedProjectsState([activeProjects[0]]);
}
}
} else if (activeProjects.length > 0) {
setSelectedProjectsState([activeProjects[0]]);
} else {
setSelectedProjectsState([]);
}
} catch (err: any) {
console.error("Failed to load projects/permissions:", err);
} finally {
setIsLoadingProjects(false);
}
}, [isDemoMode]);
// ============================================================================
// Set Current Workspace
// ============================================================================
const setCurrentWorkspace = useCallback(
(workspace: Workspace) => {
setCurrentWorkspaceState(workspace);
localStorage.setItem(STORAGE_KEYS.SELECTED_WORKSPACE, workspace.id);
// Update URL if we're in dashboard
if (pathname.startsWith("/dashboard/")) {
const currentWorkspaceId = workspaceIdFromUrl;
if (currentWorkspaceId && currentWorkspaceId !== workspace.id) {
const newPath = pathname.replace(
`/dashboard/${currentWorkspaceId}`,
`/dashboard/${workspace.id}`
);
router.push(newPath);
}
} else if (pathname === "/dashboard" || pathname === "/") {
router.push(`/dashboard/${workspace.id}`);
}
// Load projects for new workspace
loadProjectsAndPermissions(workspace.id);
},
[pathname, router, workspaceIdFromUrl, loadProjectsAndPermissions]
);
// ============================================================================
// Project Selection Methods
// ============================================================================
const saveSelectedProjects = useCallback(
(selected: Project[]) => {
if (currentWorkspace) {
localStorage.setItem(
`${STORAGE_KEYS.SELECTED_PROJECT}_${currentWorkspace.id}`,
JSON.stringify(selected.map((p) => p.id))
);
}
},
[currentWorkspace]
);
const setSelectedProjects = useCallback(
(selected: Project[]) => {
setSelectedProjectsState(selected);
saveSelectedProjects(selected);
},
[saveSelectedProjects]
);
const toggleProject = useCallback(
(project: Project) => {
setSelectedProjectsState((prev) => {
const isSelected = prev.some((p) => p.id === project.id);
let newSelection: Project[];
if (isSelected) {
// Don't allow deselecting if it's the only one
if (prev.length <= 1) {
return prev;
}
newSelection = prev.filter((p) => p.id !== project.id);
} else {
newSelection = [...prev, project];
}
saveSelectedProjects(newSelection);
return newSelection;
});
},
[saveSelectedProjects]
);
const selectAllProjects = useCallback(() => {
setSelectedProjectsState(projects);
saveSelectedProjects(projects);
}, [projects, saveSelectedProjects]);
const clearProjectSelection = useCallback(() => {
if (projects.length > 0) {
const firstProject = [projects[0]];
setSelectedProjectsState(firstProject);
saveSelectedProjects(firstProject);
}
}, [projects, saveSelectedProjects]);
// ============================================================================
// Create Workspace
// ============================================================================
const createWorkspace = useCallback(async (name: string): Promise<Workspace> => {
const workspace = await workspacesApi.create({ name });
setWorkspaces((prev) => [...prev, workspace]);
return workspace;
}, []);
// ============================================================================
// Effects
// ============================================================================
// Initial load
useEffect(() => {
loadWorkspaces();
}, [loadWorkspaces]);
// Load projects when workspace changes
useEffect(() => {
if (currentWorkspace) {
loadProjectsAndPermissions(currentWorkspace.id);
}
}, [currentWorkspace?.id, loadProjectsAndPermissions]);
// ============================================================================
// Render
// ============================================================================
return (
<WorkspaceContext.Provider
value={{
// Workspaces
workspaces,
currentWorkspace,
setCurrentWorkspace,
// Projects
projects,
selectedProjects,
currentProject,
setSelectedProjects,
toggleProject,
selectAllProjects,
clearProjectSelection,
getProjectFilterId,
allProjectsSelected,
// Demo mode
isDemoMode,
// Permissions
permissions,
hasPermission,
isAdmin,
// Loading states
isLoading,
isLoadingProjects,
error,
// Actions
refreshWorkspaces: loadWorkspaces,
refreshProjects: () =>
currentWorkspace
? loadProjectsAndPermissions(currentWorkspace.id)
: Promise.resolve(),
createWorkspace,
}}
>
{children}
</WorkspaceContext.Provider>
);
};

View File

@@ -1,276 +0,0 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTargetChannel } from "@/components/providers/target-channel-provider";
import {
Loader2,
AlertCircle,
Plus,
ExternalLink,
CheckCircle2,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { BOT_USERNAME } from "@/lib/utils/constants";
import { channelsApi } from "@/lib/api";
export const TargetChannelSelector: React.FC = () => {
const { channels, selectedChannel, setSelectedChannel, isLoading, error } =
useTargetChannel();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [initialChannelCount, setInitialChannelCount] = useState(0);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Очистка интервала при размонтировании
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
// Автоматический запуск polling при открытии диалога
useEffect(() => {
if (isAddDialogOpen && !isSuccess && initialChannelCount >= 0) {
pollingIntervalRef.current = setInterval(() => {
checkForNewChannels();
}, 1000);
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}
}, [isAddDialogOpen, isSuccess, initialChannelCount]);
const checkForNewChannels = async () => {
try {
const response = await channelsApi.list();
const activeChannels = response.target_channels.filter(
(c) => c.is_active
);
if (activeChannels.length > initialChannelCount) {
setIsSuccess(true);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setTimeout(() => {
setIsAddDialogOpen(false);
setIsSuccess(false);
window.location.reload();
}, 2000);
}
} catch (err) {
console.error("Error polling channels:", err);
}
};
const handleAddChannel = () => {
setInitialChannelCount(channels.length);
setIsSuccess(false);
setIsAddDialogOpen(true);
};
const handleDialogOpenChange = (open: boolean) => {
if (!open) {
// Разрешаем закрытие всегда (в том числе через кнопку крестик)
setIsAddDialogOpen(false);
setIsSuccess(false);
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
}
};
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка каналов...
</div>
);
}
if (error) {
return (
<Alert variant="destructive" className="p-2 h-auto">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">Ошибка: {error}</AlertDescription>
</Alert>
);
}
if (channels.length === 0) {
return (
<Button variant="outline" size="sm" onClick={handleAddChannel}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
);
}
return (
<>
<Select
value={selectedChannel?.id || "add-new"}
onValueChange={(value) => {
if (value === "add-new") {
handleAddChannel();
} else {
const channel = channels.find((c) => c.id === value);
if (channel) {
setSelectedChannel(channel);
}
}
}}
>
<SelectTrigger className="w-[260px] h-auto min-h-[52px] py-2 rounded-lg">
<SelectValue placeholder="Выберите канал">
{selectedChannel && (
<div className="flex flex-col items-start gap-0.5">
<span className="font-semibold text-base">
{selectedChannel.title}
</span>
{selectedChannel.username && (
<span className="text-xs text-muted-foreground -mt-1">
@{selectedChannel.username}
</span>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{channels.map((channel) => (
<SelectItem
key={channel.id}
value={channel.id}
className="cursor-pointer py-3"
>
<div className="flex items-center justify-between w-full gap-2">
<div className="flex flex-col items-start flex-1 min-w-0 gap-0.5">
<span className="font-semibold text-base truncate w-full">
{channel.title}
</span>
{channel.username && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
@{channel.username}
</span>
)}
</div>
</div>
</SelectItem>
))}
<SelectItem
value="add-new"
className="cursor-pointer border-t mt-1 py-3"
>
<div className="flex items-center gap-2 text-primary">
<Plus className="h-4 w-4" />
<span className="font-medium">Добавить канал</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Dialog open={isAddDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => !isSuccess && e.preventDefault()}
onEscapeKeyDown={(e) => !isSuccess && e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Добавить целевой канал</DialogTitle>
<DialogDescription>
Следуйте инструкциям ниже, чтобы подключить новый канал
</DialogDescription>
</DialogHeader>
{isSuccess ? (
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="rounded-full bg-green-100 dark:bg-green-900 p-3">
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold">
Канал успешно добавлен!
</h3>
<p className="text-sm text-muted-foreground mt-1">
Страница обновится автоматически...
</p>
</div>
</div>
) : (
<div className="space-y-4">
<Alert>
<AlertDescription className="space-y-2">
<p className="font-medium">Шаги для добавления канала:</p>
<ol className="list-decimal list-inside space-y-1.5 text-sm">
<li>Откройте свой Telegram канал</li>
<li>Перейдите в настройки канала Администраторы</li>
<li>
Добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
tabIndex={0}
aria-label={`Открыть бота @${BOT_USERNAME} в Telegram`}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
как администратора
</li>
<li>
Предоставьте боту права:{" "}
<span className="font-medium">
Публикация сообщений, Удаление сообщений
</span>
</li>
</ol>
</AlertDescription>
</Alert>
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground text-center">
Ждем добавления бота...
<br />
<span className="text-xs">
Как только вы добавите бота, канал автоматически появится
</span>
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,142 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}