feat: global platform v2 update
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
59
components/demo-banner.tsx
Normal file
59
components/demo-banner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
components/layout/dashboard-layout-content.tsx
Normal file
26
components/layout/dashboard-layout-content.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
147
components/project-selector.tsx
Normal file
147
components/project-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -2,5 +2,4 @@
|
||||
// Providers Export
|
||||
// ============================================================================
|
||||
|
||||
export { TargetChannelProvider, useTargetChannel } from "./target-channel-provider";
|
||||
|
||||
export { WorkspaceProvider, useWorkspace } from "./workspace-provider";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
468
components/providers/workspace-provider.tsx
Normal file
468
components/providers/workspace-provider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
142
components/ui/alert-dialog.tsx
Normal file
142
components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user