Files
tgex-frontend/components/dialog-create-placements.tsx
2026-02-02 11:42:03 +03:00

571 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { Loader2, Plus, Check, ChevronRight, X, AlertCircle, BookOpen } 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, channelsApi } from "@/lib/api";
import type { Placement, Creative, Channel } from "@/lib/types/api";
import { Badge } from "./ui/badge";
interface ChannelInput {
username: string;
channelId: string;
}
interface CreatePlacementsDialogProps {
workspaceId: string;
projectId: string;
existingPlacements: Placement[];
prefilledCreativeId?: string | null;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess: () => void;
children?: React.ReactNode;
}
const DEFAULT_STATUS = "Согласование условий";
const DEFAULT_PLACEMENT_TYPE = "standard";
export function CreatePlacementsDialog({
workspaceId,
projectId,
existingPlacements,
prefilledCreativeId = null,
open: controlledOpen,
onOpenChange,
onSuccess,
children,
}: CreatePlacementsDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
const [step, setStep] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newChannelInput, setNewChannelInput] = useState("");
const [selectedChannels, setSelectedChannels] = useState<ChannelInput[]>([]);
const [existingChannels, setExistingChannels] = useState<string[]>([]);
const [projectChannels, setProjectChannels] = useState<Channel[]>([]);
const [creativeId, setCreativeId] = useState<string | null>(prefilledCreativeId);
const [format, setFormat] = useState("");
const [cost, setCost] = useState("");
const [placementDate, setPlacementDate] = useState("");
const [comment, setComment] = useState("");
const [creatives, setCreatives] = useState<Creative[]>([]);
const [creating, setCreating] = useState(false);
const [createdCount, setCreatedCount] = useState<number | null>(null);
const handleOpenChange = (isOpen: boolean) => {
setInternalOpen(isOpen);
onOpenChange?.(isOpen);
};
const handleTriggerClick = () => {
resetForm();
setInternalOpen(true);
onOpenChange?.(true);
};
const resetForm = () => {
setStep(1);
setNewChannelInput("");
setSelectedChannels([]);
setExistingChannels([]);
setProjectChannels([]);
setCreativeId(prefilledCreativeId);
setFormat("");
setCost("");
setPlacementDate("");
setComment("");
setError(null);
setCreatedCount(null);
};
const existingUsernames = new Set(
existingPlacements
.map((p) => p.channel.username?.toLowerCase())
.filter(Boolean)
);
const getExistingChannelIdByUsername = (usernameLower: string): string | null => {
const matched = existingPlacements.find(
(p) => p.channel.username?.toLowerCase() === usernameLower
);
return matched?.channel.id ?? null;
};
useEffect(() => {
if (isOpen) {
loadCreatives();
loadProjectChannels();
}
}, [isOpen, workspaceId, projectId, prefilledCreativeId]);
const loadCreatives = async () => {
try {
const response = await creativesApi.list(workspaceId, {
project_id: projectId,
include_archived: false,
size: 100,
});
setCreatives(response.items);
if (prefilledCreativeId && response.items.length > 0) {
const prefilled = response.items.find((c) => c.id === prefilledCreativeId);
if (prefilled) {
setCreativeId(prefilled.id);
}
}
} catch (err) {
console.error("Failed to load creatives:", err);
}
};
const loadProjectChannels = async () => {
try {
const projectPlacements = await placementsApi.getByProject(workspaceId, projectId);
const channels = projectPlacements.map((p) => p.channel);
setProjectChannels(channels);
} catch (err) {
console.error("Failed to load project channels:", err);
}
};
const addChannel = async () => {
const username = parseChannelInput(newChannelInput);
if (!username) {
setError("Не удалось распознать username");
return;
}
const lowerUsername = username.toLowerCase();
if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) {
setError("Канал уже добавлен в список");
return;
}
const projectChannel = projectChannels.find((c) => c.username?.toLowerCase() === lowerUsername);
if (projectChannel) {
setSelectedChannels([...selectedChannels, { username, channelId: projectChannel.id }]);
setNewChannelInput("");
setError(null);
return;
}
if (existingUsernames.has(lowerUsername)) {
const existingChannelId = getExistingChannelIdByUsername(lowerUsername);
if (!existingChannelId) {
setError("Не удалось найти ID канала в проекте");
return;
}
setSelectedChannels([...selectedChannels, { username, channelId: existingChannelId }]);
setNewChannelInput("");
setError(null);
return;
}
try {
const createResponse = await channelsApi.create([{ username }]);
const channelId = createResponse.results[0]?.channel.id;
if (!channelId) {
setError("Не удалось получить ID канала");
return;
}
setSelectedChannels([...selectedChannels, { username, channelId }]);
setNewChannelInput("");
setError(null);
} catch (err) {
console.error("Failed to create channel:", err);
setError("Не удалось создать канал");
}
};
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) => ({
channel_id: c.channelId,
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 selectedCreative = creatives.find((c) => c.id === creativeId);
const isControlled = controlledOpen !== undefined;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild onClick={isControlled ? undefined : 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>
) : (
<>
<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 && (
<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 && (
<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>
)}
{projectChannels.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-base">Существующие каналы проекта</Label>
<Badge variant="outline" className="text-xs">
{projectChannels.length}
</Badge>
</div>
<div className="max-h-48 overflow-y-auto space-y-2">
{projectChannels.map((channel) => (
<div
key={channel.id}
className="flex items-center justify-between p-2 rounded-md bg-muted"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">@{channel.username}</span>
)}
</div>
{selectedChannels.some((c) => c.channelId === channel.id) ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeChannel(channel.username || channel.id)}
>
<X className="h-4 w-4" />
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const channelId = channel.id;
const username = channel.username || channel.id;
setSelectedChannels([...selectedChannels, { username, channelId }]);
}}
>
<Plus className="h-4 w-4 mr-1" />
Выбрать
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{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 className="col-span-2 space-y-2">
<Label>Комментарий</Label>
<Textarea
placeholder="Дополнительные заметки..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
/>
</div>
</div>
</div>
)}
{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>
)}
<DialogFooter>
{step > 1 && (
<Button
type="button"
variant="outline"
onClick={() => setStep(step - 1)}
>
Назад
</Button>
)}
{step < 3 ? (
<Button
type="button"
onClick={() => {
if (step === 1 && !prefilledCreativeId) {
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>
);
}