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

597 lines
21 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 { Badge } from "@/components/ui/badge";
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";
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);
// Step 1: Channels
const [newChannelInput, setNewChannelInput] = useState("");
const [selectedChannels, setSelectedChannels] = useState<ChannelInput[]>([]);
const [existingChannels, setExistingChannels] = useState<string[]>([]);
const [projectChannels, setProjectChannels] = useState<Channel[]>([]);
// Step 2: Details
const [creativeId, setCreativeId] = useState<string | null>(prefilledCreativeId);
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 getExistingChannelIdByUsername = (usernameLower: string): string | null => {
const matched = existingPlacements.find(
(p) => p.channel.username?.toLowerCase() === usernameLower
);
return matched?.channel.id ?? null;
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
resetForm();
}
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);
};
// Load creatives when dialog opens or when moving to step 2
const loadCreatives = async () => {
try {
const response = await creativesApi.list(workspaceId, {
project_id: projectId,
include_archived: false,
size: 100,
});
setCreatives(response.items);
// Auto-select prefilled creative if set
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);
}
};
// Load project channels when opening dialog
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);
}
};
// Load creatives and project channels when dialog opens
useEffect(() => {
if (isOpen) {
loadCreatives();
loadProjectChannels();
}
}, [isOpen, workspaceId, projectId, prefilledCreativeId]);
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;
}
// Check if channel exists in project channels
const projectChannel = projectChannels.find((c) => c.username?.toLowerCase() === lowerUsername);
if (projectChannel) {
setSelectedChannels([...selectedChannels, { username, channelId: projectChannel.id }]);
setNewChannelInput("");
setError(null);
return;
}
// If channel already has placements in this project, reuse its channel_id.
if (existingUsernames.has(lowerUsername)) {
const existingChannelId = getExistingChannelIdByUsername(lowerUsername);
if (!existingChannelId) {
setError("Не удалось найти ID канала в проекте");
return;
}
setSelectedChannels([...selectedChannels, { username, channelId: existingChannelId }]);
setNewChannelInput("");
setError(null);
return;
}
// Channel doesn't exist in project yet. Create it globally via API to get channel_id.
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);
// If open is controlled, don't render DialogTrigger
const isControlled = controlledOpen !== undefined;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{!isControlled && (
<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">
{/* Add new channel */}
<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>
{/* Selected channels */}
{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>
)}
{/* Existing project channels */}
{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: 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 && !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>
);
}