feat: global ui & functional update
This commit is contained in:
199
components/dialog-add-channel.tsx
Normal file
199
components/dialog-add-channel.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseChannelInput } from "@/lib/utils/channel-parser";
|
||||
import { channelsApi, placementsApi } from "@/lib/api";
|
||||
import type { Placement } from "@/lib/types/api";
|
||||
|
||||
interface AddChannelDialogProps {
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
existingPlacements: Placement[];
|
||||
onSuccess: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AddChannelDialog({
|
||||
workspaceId,
|
||||
projectId,
|
||||
existingPlacements,
|
||||
onSuccess,
|
||||
children,
|
||||
}: AddChannelDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const existingUsernames = new Set(
|
||||
existingPlacements
|
||||
.map((p) => p.channel.username?.toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
const username = parseChannelInput(inputValue);
|
||||
|
||||
if (!username) {
|
||||
setError("Не удалось распознать username из ссылки");
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingUsernames.has(username.toLowerCase())) {
|
||||
setError("Этот канал уже добавлен в проект");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Step 1: Verify/add channel via channels API
|
||||
await channelsApi.list({ username });
|
||||
|
||||
// Step 2: Create empty placement
|
||||
await placementsApi.createBulk(workspaceId, projectId, {
|
||||
channels: [
|
||||
{
|
||||
username,
|
||||
status: "Без статуса",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setInputValue("");
|
||||
|
||||
// Close dialog after short delay
|
||||
setTimeout(() => {
|
||||
setOpen(false);
|
||||
setSuccess(false);
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to add channel:", err);
|
||||
setError(err.detail || "Не удалось добавить канал");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setInputValue("");
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
};
|
||||
|
||||
const examples = [
|
||||
"t.me/rian_ru",
|
||||
"@rian_ru",
|
||||
"telemetr.me/content/rian_ru",
|
||||
"tgstat.ru/channel/@rian_ru",
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Добавить канал</DialogTitle>
|
||||
<DialogDescription>
|
||||
Введите ссылку или username канала для проверки и добавления в проект
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-input">Ссылка или username канала</Label>
|
||||
<Input
|
||||
id="channel-input"
|
||||
placeholder="https://t.me/rian_ru или @rian_ru"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Поддерживаются: t.me, telemetr.me, tgstat.ru, а также username с @
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||
Канал успешно добавлен!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Примеры:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{examples.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
onClick={() => setInputValue(example)}
|
||||
className="text-xs px-2 py-1 rounded-md bg-muted hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !inputValue.trim()}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Проверка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Проверить и добавить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
454
components/dialog-create-placements.tsx
Normal file
454
components/dialog-create-placements.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Loader2, Plus, Check, ChevronRight, X, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseChannelInput } from "@/lib/utils/channel-parser";
|
||||
import { placementsApi, creativesApi } from "@/lib/api";
|
||||
import type { Placement, Creative } from "@/lib/types/api";
|
||||
|
||||
interface ChannelInput {
|
||||
username: string;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
interface CreatePlacementsDialogProps {
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
existingPlacements: Placement[];
|
||||
onSuccess: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS = "Согласование условий";
|
||||
const DEFAULT_PLACEMENT_TYPE = "standard";
|
||||
|
||||
export function CreatePlacementsDialog({
|
||||
workspaceId,
|
||||
projectId,
|
||||
existingPlacements,
|
||||
onSuccess,
|
||||
children,
|
||||
}: CreatePlacementsDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Step 1: Channels
|
||||
const [newChannelInput, setNewChannelInput] = useState("");
|
||||
const [selectedChannels, setSelectedChannels] = useState<ChannelInput[]>([]);
|
||||
const [existingChannels, setExistingChannels] = useState<string[]>([]);
|
||||
|
||||
// Step 2: Details
|
||||
const [creativeId, setCreativeId] = useState<string | null>(null);
|
||||
const [format, setFormat] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
const [placementDate, setPlacementDate] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
|
||||
// Step 3: Confirm
|
||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createdCount, setCreatedCount] = useState<number | null>(null);
|
||||
|
||||
const existingUsernames = new Set(
|
||||
existingPlacements
|
||||
.map((p) => p.channel.username?.toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
setOpen(isOpen);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setStep(1);
|
||||
setNewChannelInput("");
|
||||
setSelectedChannels([]);
|
||||
setExistingChannels([]);
|
||||
setCreativeId(null);
|
||||
setFormat("");
|
||||
setCost("");
|
||||
setPlacementDate("");
|
||||
setComment("");
|
||||
setError(null);
|
||||
setCreatedCount(null);
|
||||
};
|
||||
|
||||
const loadCreatives = async () => {
|
||||
try {
|
||||
const response = await creativesApi.listByProject(workspaceId, projectId);
|
||||
setCreatives(response.items);
|
||||
} catch (err) {
|
||||
console.error("Failed to load creatives:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
const username = parseChannelInput(newChannelInput);
|
||||
if (!username) {
|
||||
setError("Не удалось распознать username");
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerUsername = username.toLowerCase();
|
||||
if (existingUsernames.has(lowerUsername)) {
|
||||
setError("Этот канал уже добавлен в проект");
|
||||
return;
|
||||
}
|
||||
if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) {
|
||||
setError("Канал уже добавлен в список");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedChannels([...selectedChannels, { username, isNew: true }]);
|
||||
setNewChannelInput("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const removeChannel = (username: string) => {
|
||||
setSelectedChannels(selectedChannels.filter((c) => c.username !== username));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addChannel();
|
||||
}
|
||||
};
|
||||
|
||||
const canProceedStep1 = selectedChannels.length > 0;
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
const details: any = {};
|
||||
details.placement_type = DEFAULT_PLACEMENT_TYPE;
|
||||
if (format) details.format = format;
|
||||
if (cost) details.cost = { type: "fixed", value: parseFloat(cost) };
|
||||
if (placementDate) details.placement_at = new Date(placementDate).toISOString();
|
||||
if (comment) details.comment = comment;
|
||||
|
||||
await placementsApi.createBulk(workspaceId, projectId, {
|
||||
creative_id: creativeId || undefined,
|
||||
channels: selectedChannels.map((c) => ({
|
||||
username: c.username,
|
||||
status: DEFAULT_STATUS,
|
||||
})),
|
||||
details: Object.keys(details).length > 0 ? details : undefined,
|
||||
});
|
||||
|
||||
setCreatedCount(selectedChannels.length);
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to create placements:", err);
|
||||
setError(err.detail || "Не удалось создать размещения");
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const selectedCreative = creatives.find((c) => c.id === creativeId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild onClick={handleTriggerClick}>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Создать размещения</DialogTitle>
|
||||
<DialogDescription>
|
||||
Шаг {step} из 3:{" "}
|
||||
{step === 1 && "Выберите каналы"}
|
||||
{step === 2 && "Укажите параметры"}
|
||||
{step === 3 && "Подтвердите создание"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{createdCount !== null ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="inline-flex items-center justify-center h-16 w-16 rounded-full bg-emerald-100 text-emerald-600 mb-4">
|
||||
<Check className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Размещения созданы!
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Создано {createdCount} размещений
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
s < 3 && "mr-4"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center h-8 w-8 rounded-full text-sm font-medium",
|
||||
step === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: step > s
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step > s ? <Check className="h-4 w-4" /> : s}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm mb-4">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Channels */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Добавить каналы</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="t.me/username или @username"
|
||||
value={newChannelInput}
|
||||
onChange={(e) => setNewChannelInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button type="button" onClick={addChannel} disabled={!newChannelInput.trim()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Поддерживаются: t.me, telemetr.me, tgstat.ru, username с @
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedChannels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Выбранные каналы ({selectedChannels.length})</Label>
|
||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||
{selectedChannels.map((channel) => (
|
||||
<div
|
||||
key={channel.username}
|
||||
className="flex items-center justify-between p-2 rounded-md bg-muted"
|
||||
>
|
||||
<span className="text-sm">@{channel.username}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeChannel(channel.username)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Details */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Креатив</Label>
|
||||
<Select value={creativeId || ""} onValueChange={setCreativeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите креатив" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{creatives.map((creative) => (
|
||||
<SelectItem key={creative.id} value={creative.id}>
|
||||
{creative.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Формат</Label>
|
||||
<Input
|
||||
placeholder="Стандарт"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Стоимость (₽)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="5000"
|
||||
value={cost}
|
||||
onChange={(e) => setCost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Дата размещения</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={placementDate}
|
||||
onChange={(e) => setPlacementDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Комментарий</Label>
|
||||
<Textarea
|
||||
placeholder="Дополнительные заметки..."
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Каналы ({selectedChannels.length})</Label>
|
||||
<div className="max-h-32 overflow-y-auto p-3 rounded-md bg-muted text-sm space-y-1">
|
||||
{selectedChannels.map((c) => (
|
||||
<div key={c.username}>@{c.username}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Параметры</Label>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm p-3 rounded-md bg-muted">
|
||||
{selectedCreative && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Креатив:</span>{" "}
|
||||
{selectedCreative.name}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Тип:</span>{" "}
|
||||
Стандартный
|
||||
</div>
|
||||
{format && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Формат:</span>{" "}
|
||||
{format}
|
||||
</div>
|
||||
)}
|
||||
{cost && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Стоимость:</span>{" "}
|
||||
{parseFloat(cost).toLocaleString("ru-RU")} ₽
|
||||
</div>
|
||||
)}
|
||||
{placementDate && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Дата:</span>{" "}
|
||||
{new Date(placementDate).toLocaleDateString("ru-RU")}
|
||||
</div>
|
||||
)}
|
||||
{comment && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Комментарий:</span>{" "}
|
||||
{comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<DialogFooter>
|
||||
{step > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setStep(step - 1)}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (step === 1) {
|
||||
loadCreatives();
|
||||
}
|
||||
setStep(step + 1);
|
||||
}}
|
||||
disabled={step === 1 && !canProceedStep1}
|
||||
>
|
||||
Далее
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Создать {selectedChannels.length} размещений
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
523
components/filters-modal.tsx
Normal file
523
components/filters-modal.tsx
Normal file
@@ -0,0 +1,523 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Filter, ArrowUpDown, X, Check, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Placement, PlacementStatus } from "@/lib/types/api";
|
||||
|
||||
type FilterCondition = "contains" | "not_contains" | "equals";
|
||||
|
||||
interface FilterField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date";
|
||||
}
|
||||
|
||||
interface SortField {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SortOption {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
interface FiltersModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onApply: (filters: PlacementFilters) => void;
|
||||
existingPlacements: Placement[];
|
||||
initialFilters: PlacementFilters;
|
||||
}
|
||||
|
||||
export interface PlacementFilters {
|
||||
channelFilters: {
|
||||
name: { value: string; condition: FilterCondition };
|
||||
username: { value: string; condition: FilterCondition };
|
||||
subscribersMin: number | null;
|
||||
subscribersMax: number | null;
|
||||
};
|
||||
placementFilters: {
|
||||
statuses: PlacementStatus[];
|
||||
costMin: number | null;
|
||||
costMax: number | null;
|
||||
dateFrom: string | null;
|
||||
dateTo: string | null;
|
||||
hasPlacement: boolean | null;
|
||||
};
|
||||
sort: SortOption | null;
|
||||
}
|
||||
|
||||
const CHANNEL_FILTER_FIELDS: FilterField[] = [
|
||||
{ key: "name", label: "Название канала", type: "text" },
|
||||
{ key: "username", label: "Username", type: "text" },
|
||||
];
|
||||
|
||||
const SORT_FIELDS: SortField[] = [
|
||||
{ key: "title", label: "Название канала" },
|
||||
{ key: "subscribers", label: "Подписчики" },
|
||||
{ key: "cost", label: "Стоимость" },
|
||||
{ key: "date", label: "Дата размещения" },
|
||||
{ key: "cpm", label: "CPM" },
|
||||
{ key: "cpf", label: "CPF" },
|
||||
];
|
||||
|
||||
const CONDITION_OPTIONS: { value: FilterCondition; label: string }[] = [
|
||||
{ value: "contains", label: "содержит" },
|
||||
{ value: "not_contains", label: "не содержит" },
|
||||
{ value: "equals", label: "равно" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS: PlacementStatus[] = [
|
||||
"Без статуса",
|
||||
"Написать",
|
||||
"Ждём ответа",
|
||||
"Согласование условий",
|
||||
"Оплатить",
|
||||
"Оплачено",
|
||||
"Отмена",
|
||||
"Не подходит цена",
|
||||
"Неактуально",
|
||||
"Не отвечает",
|
||||
];
|
||||
|
||||
const CONDITION_LABELS: Record<FilterCondition, string> = {
|
||||
contains: "содержит",
|
||||
not_contains: "не содержит",
|
||||
equals: "равно",
|
||||
};
|
||||
|
||||
export function FiltersModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onApply,
|
||||
existingPlacements,
|
||||
initialFilters,
|
||||
}: FiltersModalProps) {
|
||||
const [filters, setFilters] = useState<PlacementFilters>(initialFilters);
|
||||
const [activeTab, setActiveTab] = useState<"channels" | "placements" | "sort">("channels");
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(filters);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultFilters: PlacementFilters = {
|
||||
channelFilters: {
|
||||
name: { value: "", condition: "contains" },
|
||||
username: { value: "", condition: "contains" },
|
||||
subscribersMin: null,
|
||||
subscribersMax: null,
|
||||
},
|
||||
placementFilters: {
|
||||
statuses: [],
|
||||
costMin: null,
|
||||
costMax: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
hasPlacement: null,
|
||||
},
|
||||
sort: null,
|
||||
};
|
||||
setFilters(defaultFilters);
|
||||
};
|
||||
|
||||
const toggleStatus = (status: PlacementStatus) => {
|
||||
const currentStatuses = filters.placementFilters.statuses;
|
||||
const newStatuses = currentStatuses.includes(status)
|
||||
? currentStatuses.filter((s) => s !== status)
|
||||
: [...currentStatuses, status];
|
||||
setFilters({
|
||||
...filters,
|
||||
placementFilters: { ...filters.placementFilters, statuses: newStatuses },
|
||||
});
|
||||
};
|
||||
|
||||
const updateChannelFilter = (
|
||||
key: "name" | "username",
|
||||
updates: { value?: string; condition?: FilterCondition }
|
||||
) => {
|
||||
setFilters({
|
||||
...filters,
|
||||
channelFilters: {
|
||||
...filters.channelFilters,
|
||||
[key]: { ...filters.channelFilters[key], ...updates },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.channelFilters.name.value ||
|
||||
filters.channelFilters.username.value ||
|
||||
filters.channelFilters.subscribersMin !== null ||
|
||||
filters.channelFilters.subscribersMax !== null ||
|
||||
filters.placementFilters.statuses.length > 0 ||
|
||||
filters.placementFilters.costMin !== null ||
|
||||
filters.placementFilters.costMax !== null ||
|
||||
filters.placementFilters.dateFrom ||
|
||||
filters.placementFilters.dateTo ||
|
||||
filters.sort;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
Фильтры и сортировка
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Настройте фильтры для каналов и размещений
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab("channels")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === "channels"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Каналы
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("placements")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === "placements"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Размещения
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("sort")}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-1",
|
||||
activeTab === "sort"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
Сортировка
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === "channels" && (
|
||||
<div className="space-y-4">
|
||||
{CHANNEL_FILTER_FIELDS.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{field.label}
|
||||
<Select
|
||||
value={filters.channelFilters[field.key as "name" | "username"].condition}
|
||||
onValueChange={(value: FilterCondition) =>
|
||||
updateChannelFilter(field.key as "name" | "username", { condition: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-36 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITION_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder={
|
||||
filters.channelFilters[field.key as "name" | "username"].condition === "equals"
|
||||
? "Точное значение"
|
||||
: "Например: новости"
|
||||
}
|
||||
value={filters.channelFilters[field.key as "name" | "username"].value}
|
||||
onChange={(e) =>
|
||||
updateChannelFilter(field.key as "name" | "username", { value: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Мин. подписчиков</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={filters.channelFilters.subscribersMin || ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
channelFilters: {
|
||||
...filters.channelFilters,
|
||||
subscribersMin: e.target.value ? parseInt(e.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Макс. подписчиков</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000000"
|
||||
value={filters.channelFilters.subscribersMax || ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
channelFilters: {
|
||||
...filters.channelFilters,
|
||||
subscribersMax: e.target.value ? parseInt(e.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "placements" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Статусы размещений</Label>
|
||||
<div className="flex flex-wrap gap-2 max-h-40 overflow-y-auto">
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<label
|
||||
key={status}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 rounded-md border cursor-pointer transition-colors text-sm",
|
||||
filters.placementFilters.statuses.includes(status)
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={filters.placementFilters.statuses.includes(status)}
|
||||
onCheckedChange={() => toggleStatus(status)}
|
||||
/>
|
||||
{status}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Мин. стоимость (₽)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={filters.placementFilters.costMin || ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
placementFilters: {
|
||||
...filters.placementFilters,
|
||||
costMin: e.target.value ? parseFloat(e.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Макс. стоимость (₽)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="100000"
|
||||
value={filters.placementFilters.costMax || ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
placementFilters: {
|
||||
...filters.placementFilters,
|
||||
costMax: e.target.value ? parseFloat(e.target.value) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Дата размещения от</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.placementFilters.dateFrom || ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
placementFilters: {
|
||||
...filters.placementFilters,
|
||||
dateFrom: e.target.value || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Дата размещения до</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filters.placementFilters.dateTo || ""}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
placementFilters: {
|
||||
...filters.placementFilters,
|
||||
dateTo: e.target.value || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "sort" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label>Сортировать по</Label>
|
||||
<Select
|
||||
value={filters.sort?.field || ""}
|
||||
onValueChange={(field) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: filters.sort
|
||||
? { ...filters.sort, field }
|
||||
: { field, direction: "desc" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите поле" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_FIELDS.map((field) => (
|
||||
<SelectItem key={field.key} value={field.key}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Направление</Label>
|
||||
<div className="flex gap-1 p-1 border rounded-md">
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: filters.sort
|
||||
? { ...filters.sort, direction: "asc" }
|
||||
: { field: "title", direction: "asc" },
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
|
||||
filters.sort?.direction === "asc"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
title="По возрастанию"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<span className="ml-1 text-xs">A-Z</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: filters.sort
|
||||
? { ...filters.sort, direction: "desc" }
|
||||
: { field: "title", direction: "desc" },
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center py-2 rounded-sm transition-colors",
|
||||
filters.sort?.direction === "desc"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
title="По убыванию"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<span className="ml-1 text-xs">Z-A</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filters.sort && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Текущая сортировка:</span>
|
||||
<span className="font-medium">
|
||||
{SORT_FIELDS.find((f) => f.key === filters.sort?.field)?.label ||
|
||||
filters.sort?.field}
|
||||
</span>
|
||||
<span>
|
||||
{filters.sort.direction === "asc" ? "↑" : "↓"} (
|
||||
{filters.sort.direction === "asc" ? "A-Z" : "Z-A"})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-between">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleApply}>
|
||||
Применить
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
Filter,
|
||||
Folder,
|
||||
FolderKanban,
|
||||
Gauge,
|
||||
KanbanSquare,
|
||||
LayoutDashboard,
|
||||
@@ -27,12 +27,15 @@ import {
|
||||
MessageCircle,
|
||||
MonitorPlay,
|
||||
NotebookPen,
|
||||
Palette,
|
||||
PlayCircle,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Tv,
|
||||
TrendingUp,
|
||||
Upload,
|
||||
BookOpen,
|
||||
ChevronRight,
|
||||
X,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
|
||||
import { type NavGuideState, NavMain } from "@/components/nav-main";
|
||||
import { NavUser } from "@/components/nav-user";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -97,10 +101,12 @@ interface NavItem {
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
|
||||
tooltip?: string;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
tooltip?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -338,7 +344,10 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
|
||||
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
|
||||
const [newWorkspaceAvatar, setNewWorkspaceAvatar] = React.useState<File | null>(null);
|
||||
const [newWorkspaceAvatarPreview, setNewWorkspaceAvatarPreview] = React.useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
const newWorkspaceAvatarInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const handleWorkspaceSelect = React.useCallback(
|
||||
(workspaceId: string) => {
|
||||
if (currentWorkspace?.id === workspaceId) return;
|
||||
@@ -362,13 +371,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
url: buildRoute.dashboard(workspaceId),
|
||||
icon: LayoutDashboard,
|
||||
isActive: true,
|
||||
tooltip: "Обзор ключевых метрик: расходы, охват, подписчики, CPF и CPM",
|
||||
});
|
||||
|
||||
// 2. Проекты (всегда доступны)
|
||||
items.push({
|
||||
title: "Проекты",
|
||||
url: buildRoute.projects(workspaceId),
|
||||
icon: Tv,
|
||||
icon: FolderKanban,
|
||||
tooltip: "Управление рекламными кампаниями: статус, бюджет, ответственные",
|
||||
});
|
||||
|
||||
// 3. Креативы (только с доступом к размещениям)
|
||||
@@ -376,67 +387,62 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
items.push({
|
||||
title: "Креативы",
|
||||
url: buildRoute.creatives(workspaceId),
|
||||
icon: Folder,
|
||||
icon: Palette,
|
||||
requiredPermission: "placements_read",
|
||||
tooltip: "Шаблоны рекламных сообщений для закупов",
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Каталог каналов (всегда доступен)
|
||||
items.push({
|
||||
title: "Каталог каналов",
|
||||
url: buildRoute.channels(workspaceId),
|
||||
icon: Building2,
|
||||
});
|
||||
|
||||
// 5. Планы закупов (правка названия)
|
||||
// 4. Размещения (ранее "Планы закупов")
|
||||
if (hasPermission("placements_read")) {
|
||||
items.push({
|
||||
title: "Планы закупов",
|
||||
url: buildRoute.purchasePlans(workspaceId),
|
||||
icon: ClipboardList,
|
||||
requiredPermission: "placements_read",
|
||||
tooltip: "Черновики кампаний: сбор каналов, фиксация ставок, согласование бюджета",
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Размещения
|
||||
if (hasPermission("placements_read")) {
|
||||
items.push({
|
||||
title: "Размещения",
|
||||
url: buildRoute.placements(workspaceId),
|
||||
icon: ShoppingCart,
|
||||
requiredPermission: "placements_read",
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Аналитика — только два подраздела
|
||||
// 5. Аналитика — только подразделы
|
||||
if (hasPermission("analytics_read")) {
|
||||
items.push({
|
||||
title: "Аналитика",
|
||||
url: buildRoute.analytics(workspaceId),
|
||||
icon: BarChart3,
|
||||
requiredPermission: "analytics_read",
|
||||
tooltip: "Сводные отчеты: эффективность по проектам и креативам",
|
||||
items: [
|
||||
{
|
||||
title: "Все размещения",
|
||||
url: buildRoute.analyticsPlacements(workspaceId),
|
||||
icon: ShoppingCart,
|
||||
tooltip: "Сводная таблица по всем закупкам",
|
||||
},
|
||||
{
|
||||
title: "По проектам",
|
||||
url: buildRoute.analyticsProjects(workspaceId),
|
||||
icon: Tv,
|
||||
tooltip: "Метрики эффективности по каждому проекту",
|
||||
},
|
||||
{
|
||||
title: "По креативам",
|
||||
url: buildRoute.analyticsCreatives(workspaceId),
|
||||
icon: Folder,
|
||||
icon: Palette,
|
||||
tooltip: "Анализ результатов по рекламным шаблонам",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Настройки (без подразделов, админ)
|
||||
// 6. Настройки (без подразделов, админ)
|
||||
if (isAdmin) {
|
||||
items.push({
|
||||
title: "Настройки",
|
||||
url: buildRoute.settings(workspaceId),
|
||||
icon: Settings,
|
||||
requiredPermission: "admin_full",
|
||||
tooltip: "Управление доступом и параметрами рабочего пространства",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -503,7 +509,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}, [isTrainingOpen, currentTrainingStep]);
|
||||
const isTrainingVisible = isTrainingOpen && Boolean(currentTrainingStep);
|
||||
|
||||
const channelLink = React.useMemo<SupportLink>(
|
||||
type SimpleLink = { title: string; href: string; icon: LucideIcon; external?: boolean };
|
||||
|
||||
const channelLink = React.useMemo<SimpleLink>(
|
||||
() => ({
|
||||
title: "Наш канал",
|
||||
href: "https://t.me/+xqHlRelJ2etkMGIy",
|
||||
@@ -593,9 +601,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
const workspace = await createWorkspace(newWorkspaceName.trim());
|
||||
const workspace = await createWorkspace(newWorkspaceName.trim(), newWorkspaceAvatar);
|
||||
setShowCreateDialog(false);
|
||||
setNewWorkspaceName("");
|
||||
setNewWorkspaceAvatar(null);
|
||||
setNewWorkspaceAvatarPreview(null);
|
||||
setCurrentWorkspace(workspace);
|
||||
} catch (err) {
|
||||
console.error("Failed to create workspace:", err);
|
||||
@@ -604,6 +614,30 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewWorkspaceAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNewWorkspaceAvatar(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setNewWorkspaceAvatarPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleNewWorkspaceAvatarRemove = () => {
|
||||
setNewWorkspaceAvatar(null);
|
||||
setNewWorkspaceAvatarPreview(null);
|
||||
if (newWorkspaceAvatarInputRef.current) {
|
||||
newWorkspaceAvatarInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -663,7 +697,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarGroup className="px-0">
|
||||
<SidebarGroup className="px-0 pb-0">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{/* Наш канал */}
|
||||
@@ -671,6 +705,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
size="sm"
|
||||
className="!p-1.5 !h-7"
|
||||
tooltip={isSidebarCollapsed ? channelLink.title : undefined}
|
||||
>
|
||||
<a
|
||||
@@ -679,9 +714,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<channelLink.icon className="size-4 text-muted-foreground/90" />
|
||||
<span className="text-muted-foreground/90">{channelLink.title}</span>
|
||||
<ArrowUpRight className="ml-auto size-3.5 text-muted-foreground/60" />
|
||||
<channelLink.icon className="size-3.5 text-muted-foreground/90" />
|
||||
<span className="text-xs text-muted-foreground/90">{channelLink.title}</span>
|
||||
<ArrowUpRight className="ml-auto size-3 text-muted-foreground/60" />
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -690,12 +725,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
className="!p-1.5 !h-7"
|
||||
tooltip={isSidebarCollapsed ? "Обучение" : undefined}
|
||||
onClick={openTraining}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<MonitorPlay className="size-4 text-muted-foreground/90" />
|
||||
<span className="text-muted-foreground/90">Обучение</span>
|
||||
<MonitorPlay className="size-3.5 text-muted-foreground/90" />
|
||||
<span className="text-xs text-muted-foreground/90">Обучение</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -706,14 +741,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
className="!p-1.5 !h-7 justify-between"
|
||||
tooltip="Помощь"
|
||||
className="justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoy className="size-4 text-muted-foreground/90" />
|
||||
<span className="text-muted-foreground/90">Помощь</span>
|
||||
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
|
||||
<span className="text-xs text-muted-foreground/90">Помощь</span>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground/60" />
|
||||
<ChevronRight className="size-3 text-muted-foreground/60" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@@ -735,20 +770,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
className="justify-between"
|
||||
className="!p-1.5 !h-7 justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LifeBuoy className="size-4 text-muted-foreground/90" />
|
||||
<span className="text-muted-foreground/90">Помощь</span>
|
||||
<LifeBuoy className="size-3.5 text-muted-foreground/90" />
|
||||
<span className="text-xs text-muted-foreground/90">Помощь</span>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
<ChevronRight className="size-3 text-muted-foreground/60 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{helpSubItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuSubButton asChild className="">
|
||||
<SidebarMenuSubButton asChild className="!p-1.5 !h-7">
|
||||
{renderHelpSubItem(item)}
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
@@ -826,6 +861,47 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={newWorkspaceAvatarPreview || undefined} alt="Avatar" />
|
||||
<AvatarFallback>
|
||||
{newWorkspaceName.charAt(0).toUpperCase() || "W"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => newWorkspaceAvatarInputRef.current?.click()}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Загрузить
|
||||
</Button>
|
||||
{newWorkspaceAvatarPreview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewWorkspaceAvatarRemove}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={newWorkspaceAvatarInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleNewWorkspaceAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="workspace-name">Название</Label>
|
||||
<Input
|
||||
@@ -838,13 +914,19 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
handleCreateWorkspace();
|
||||
}
|
||||
}}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
onClick={() => {
|
||||
setShowCreateDialog(false);
|
||||
setNewWorkspaceName("");
|
||||
setNewWorkspaceAvatar(null);
|
||||
setNewWorkspaceAvatarPreview(null);
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
|
||||
@@ -42,10 +42,12 @@ export function NavMain({
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
tooltip?: string;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
tooltip?: string;
|
||||
}[];
|
||||
}[];
|
||||
guideState?: NavGuideState;
|
||||
@@ -60,13 +62,15 @@ export function NavMain({
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const hasSubmenu = item.items && item.items.length > 0;
|
||||
const isActive = pathname === item.url;
|
||||
const isActive = hasSubmenu
|
||||
? pathname.startsWith(item.url)
|
||||
: pathname === item.url || (item.title === "Главная" ? false : pathname.startsWith(item.url + "/"));
|
||||
const hasActiveSubmenu =
|
||||
hasSubmenu && item.items!.some((sub) => pathname === sub.url);
|
||||
const shouldForceOpen = guideState?.expandedItems?.includes(item.title);
|
||||
const shouldHighlightButton = item.title === "Аналитика"
|
||||
? false
|
||||
: isActive;
|
||||
: isActive || hasActiveSubmenu;
|
||||
const isGuideHighlight = highlightedItem === item.title;
|
||||
const highlightedButtonClass = isGuideHighlight
|
||||
? "ring-2 ring-primary/70"
|
||||
@@ -79,7 +83,7 @@ export function NavMain({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
|
||||
isActive={shouldHighlightButton}
|
||||
className={cn(highlightedButtonClass)}
|
||||
>
|
||||
@@ -161,7 +165,7 @@ export function NavMain({
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
|
||||
isActive={shouldHighlightButton}
|
||||
className={cn(highlightedButtonClass)}
|
||||
>
|
||||
@@ -234,7 +238,7 @@ export function NavMain({
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
tooltip={item.tooltip ? { children: item.tooltip, side: "right" } : item.title}
|
||||
isActive={shouldHighlightButton}
|
||||
className={cn(highlightedButtonClass)}
|
||||
>
|
||||
|
||||
@@ -107,19 +107,23 @@ export function NavUser({
|
||||
{workspaceSwitcher.workspaces.length > 0 ? (
|
||||
workspaceSwitcher.workspaces.map((workspace) => {
|
||||
const isDemo = workspace.id === DEMO_WORKSPACE_ID;
|
||||
const isSelected = workspaceSwitcher.currentWorkspaceId === workspace.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={workspace.id}
|
||||
onClick={() => workspaceSwitcher.onSelect(workspace)}
|
||||
className="gap-2 p-2 text-sm"
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||
{isDemo ? (
|
||||
<Sparkles className="size-4 shrink-0 text-purple-500" />
|
||||
) : (
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<Avatar className="h-6 w-6 rounded-sm">
|
||||
<AvatarImage src={workspace.avatar_url || undefined} alt={workspace.name} />
|
||||
<AvatarFallback className="rounded-sm text-xs">
|
||||
{isDemo ? (
|
||||
<Sparkles className="h-3 w-3 text-purple-500" />
|
||||
) : (
|
||||
workspace.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="flex-1 truncate">
|
||||
{workspace.name}
|
||||
{isDemo && (
|
||||
@@ -128,7 +132,7 @@ export function NavUser({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{workspaceSwitcher.currentWorkspaceId === workspace.id && (
|
||||
{isSelected && (
|
||||
<Check className="size-4 ml-auto" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -55,6 +55,8 @@ interface WorkspaceContextType {
|
||||
permissions: Permission[];
|
||||
hasPermission: (key: PermissionKey, projectId?: string) => boolean;
|
||||
isAdmin: boolean;
|
||||
// Check if user is admin in a specific workspace
|
||||
isWorkspaceAdmin: (workspaceId: string) => Promise<boolean>;
|
||||
|
||||
// Loading states
|
||||
isLoading: boolean;
|
||||
@@ -64,7 +66,7 @@ interface WorkspaceContextType {
|
||||
// Actions
|
||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
refreshProjects: () => Promise<void>;
|
||||
createWorkspace: (name: string) => Promise<Workspace>;
|
||||
createWorkspace: (name: string, avatarFile?: File | null) => Promise<Workspace>;
|
||||
}
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextType | undefined>(
|
||||
@@ -406,12 +408,49 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
||||
}
|
||||
}, [projects, saveSelectedProjects]);
|
||||
|
||||
// ============================================================================
|
||||
// Check if user is admin in a specific workspace
|
||||
// ============================================================================
|
||||
|
||||
const isWorkspaceAdmin = useCallback(async (workspaceId: string): Promise<boolean> => {
|
||||
// Demo mode - user is admin
|
||||
if (isDemoMode || workspaceId === DEMO_WORKSPACE_ID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if this is the current workspace
|
||||
if (currentWorkspace?.id === workspaceId) {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
// Load permissions for the target workspace
|
||||
try {
|
||||
const me = await authApi.me();
|
||||
const membersResponse = await membersApi.list(workspaceId, { size: 100 });
|
||||
const member = membersResponse.items.find((m) => m.user.id === me.id);
|
||||
|
||||
if (!member) {
|
||||
// Check if user is creator (members list is empty or this is first member)
|
||||
if (membersResponse.items.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if has admin_full permission
|
||||
return member.permissions.some((p) => p.key === "admin_full");
|
||||
} catch (err) {
|
||||
console.error("Failed to check workspace permissions:", err);
|
||||
return false;
|
||||
}
|
||||
}, [currentWorkspace, isAdmin, isDemoMode]);
|
||||
|
||||
// ============================================================================
|
||||
// Create Workspace
|
||||
// ============================================================================
|
||||
|
||||
const createWorkspace = useCallback(async (name: string): Promise<Workspace> => {
|
||||
const workspace = await workspacesApi.create({ name });
|
||||
const createWorkspace = useCallback(async (name: string, avatarFile?: File | null): Promise<Workspace> => {
|
||||
const workspace = await workspacesApi.create({ name, avatar_file: avatarFile });
|
||||
setWorkspaces((prev) => [...prev, workspace]);
|
||||
return workspace;
|
||||
}, []);
|
||||
@@ -462,6 +501,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({
|
||||
permissions,
|
||||
hasPermission,
|
||||
isAdmin,
|
||||
isWorkspaceAdmin,
|
||||
|
||||
// Loading states
|
||||
isLoading,
|
||||
|
||||
@@ -128,7 +128,7 @@ function SidebarProvider({
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
@@ -533,12 +533,12 @@ function SidebarMenuButton({
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
hidden={isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -643,7 +643,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"flex min-w-0 flex-col gap-1 px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -19,10 +19,11 @@ function TooltipProvider({
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
delayDuration = 500,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
@@ -46,13 +47,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance shadow-md border border-border/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-popover fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user