Files
tgex-frontend/components/dialog-create-placements.tsx
2026-01-21 23:52:21 +03:00

471 lines
16 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, 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, 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[];
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 = async () => {
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;
}
// Look up channel by username to get channel_id
try {
const channelsResponse = await channelsApi.list({ username });
const channel = channelsResponse.items.find(
(c) => c.username?.toLowerCase() === lowerUsername
);
if (!channel) {
setError("Канал не найден в каталоге");
return;
}
setSelectedChannels([...selectedChannels, { username, channelId: channel.id }]);
setNewChannelInput("");
setError(null);
} catch (err) {
console.error("Failed to look up 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 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>
);
}