479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// Create Purchase Page
|
||
// ============================================================================
|
||
|
||
import React, { useEffect, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Label } from "@/components/ui/label";
|
||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import {
|
||
purchasesApi,
|
||
channelsApi,
|
||
externalChannelsApi,
|
||
creativesApi,
|
||
} from "@/lib/api";
|
||
import {
|
||
AlertCircle,
|
||
Loader2,
|
||
ArrowLeft,
|
||
Info,
|
||
Copy,
|
||
Check,
|
||
} from "lucide-react";
|
||
import type { ExternalChannel, Creative } from "@/lib/types/api";
|
||
import { useTargetChannel } from "@/components/providers/target-channel-provider";
|
||
|
||
export default function CreatePurchasePage() {
|
||
const router = useRouter();
|
||
const { selectedChannel } = useTargetChannel();
|
||
const [externalChannels, setExternalChannels] = useState<ExternalChannel[]>(
|
||
[]
|
||
);
|
||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [inviteLink, setInviteLink] = useState<string>("");
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const [formData, setFormData] = useState({
|
||
external_channel_id: "",
|
||
creative_id: "",
|
||
placement_date: "",
|
||
cost: "",
|
||
comment: "",
|
||
post_link: "",
|
||
invite_link_type: "public" as "public" | "approval",
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (selectedChannel) {
|
||
loadData();
|
||
}
|
||
}, [selectedChannel]);
|
||
|
||
const loadData = async () => {
|
||
if (!selectedChannel) return;
|
||
|
||
try {
|
||
const [externalChannelsRes, creativesRes] = await Promise.all([
|
||
externalChannelsApi.list(selectedChannel.id),
|
||
creativesApi.list({ target_channel_id: selectedChannel.id }),
|
||
]);
|
||
setExternalChannels(externalChannelsRes.external_channels);
|
||
setCreatives(
|
||
creativesRes.creatives.filter((c) => c.status === "active")
|
||
);
|
||
} catch (err) {
|
||
console.error("Error loading data:", err);
|
||
}
|
||
};
|
||
|
||
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError(null);
|
||
|
||
// Validation
|
||
if (!selectedChannel) {
|
||
setError("Выберите целевой канал в верхнем меню");
|
||
return;
|
||
}
|
||
|
||
if (!formData.external_channel_id) {
|
||
setError("Выберите внешний канал");
|
||
return;
|
||
}
|
||
|
||
if (!formData.creative_id) {
|
||
setError("Выберите креатив");
|
||
return;
|
||
}
|
||
|
||
if (!formData.placement_date) {
|
||
setError("Укажите дату размещения");
|
||
return;
|
||
}
|
||
|
||
if (!formData.cost || parseFloat(formData.cost) <= 0) {
|
||
setError("Укажите корректную стоимость");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsLoading(true);
|
||
const result = await purchasesApi.create({
|
||
target_channel_id: selectedChannel.id,
|
||
external_channel_id: formData.external_channel_id,
|
||
creative_id: formData.creative_id,
|
||
placement_date: formData.placement_date,
|
||
cost: parseFloat(formData.cost),
|
||
comment: formData.comment || undefined,
|
||
ad_post_url: formData.post_link || undefined,
|
||
invite_link_type: formData.invite_link_type,
|
||
});
|
||
|
||
// Показываем сгенерированную ссылку
|
||
setInviteLink(result.invite_link);
|
||
} catch (err: any) {
|
||
setError(err?.error?.message || "Ошибка создания закупа");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCopyLink = () => {
|
||
navigator.clipboard.writeText(inviteLink);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
const handleCopyMessage = () => {
|
||
if (!selectedCreative) return;
|
||
const message = selectedCreative.text.replace("{link}", inviteLink);
|
||
navigator.clipboard.writeText(message);
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
};
|
||
|
||
if (inviteLink) {
|
||
const messageText = selectedCreative
|
||
? selectedCreative.text.replace("{link}", inviteLink)
|
||
: "";
|
||
|
||
return (
|
||
<>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: "/" },
|
||
{ label: "Закупы", href: "/purchases" },
|
||
{ label: "Создание" },
|
||
]}
|
||
/>
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||
<Card className="border-green-500">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||
<Check className="h-5 w-5" />
|
||
Закуп успешно создан!
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Используйте ссылку и готовое сообщение для размещения
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="space-y-2">
|
||
<Label>Пригласительная ссылка</Label>
|
||
<div className="flex gap-2">
|
||
<Input value={inviteLink} readOnly className="font-mono" />
|
||
<Button onClick={handleCopyLink} variant="outline">
|
||
{copied ? (
|
||
<Check className="h-4 w-4" />
|
||
) : (
|
||
<Copy className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>Готовое сообщение для размещения</Label>
|
||
<div className="relative">
|
||
<Textarea
|
||
value={messageText}
|
||
readOnly
|
||
rows={8}
|
||
className="pr-12"
|
||
/>
|
||
<Button
|
||
onClick={handleCopyMessage}
|
||
variant="ghost"
|
||
size="sm"
|
||
className="absolute top-2 right-2"
|
||
>
|
||
{copied ? (
|
||
<Check className="h-4 w-4" />
|
||
) : (
|
||
<Copy className="h-4 w-4" />
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Alert>
|
||
<Info className="h-4 w-4" />
|
||
<AlertDescription>
|
||
Скопируйте готовое сообщение и разместите его во внешнем
|
||
канале. Все переходы по ссылке будут автоматически
|
||
отслеживаться.
|
||
</AlertDescription>
|
||
</Alert>
|
||
|
||
<div className="flex gap-2 pt-4">
|
||
<Button asChild>
|
||
<Link href="/purchases">Перейти к закупам</Link>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => {
|
||
setInviteLink("");
|
||
setFormData({
|
||
external_channel_id: "",
|
||
creative_id: "",
|
||
placement_date: "",
|
||
cost: "",
|
||
comment: "",
|
||
post_link: "",
|
||
invite_link_type: "public",
|
||
});
|
||
}}
|
||
>
|
||
Создать еще
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: "/" },
|
||
{ label: "Закупы", href: "/purchases" },
|
||
{ label: "Создание" },
|
||
]}
|
||
/>
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold tracking-tight">Создать закуп</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
Новое рекламное размещение
|
||
</p>
|
||
</div>
|
||
<Button asChild variant="outline">
|
||
<Link href="/purchases">
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
Назад
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
{error && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Основная информация</CardTitle>
|
||
<CardDescription>
|
||
Выберите каналы и креатив для размещения
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{selectedChannel && (
|
||
<Alert>
|
||
<Info className="h-4 w-4" />
|
||
<AlertDescription>
|
||
Размещение будет создано для канала: <strong>{selectedChannel.title}</strong>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="external_channel_id">Внешний канал *</Label>
|
||
<Select
|
||
value={formData.external_channel_id}
|
||
onValueChange={(value) =>
|
||
setFormData({
|
||
...formData,
|
||
external_channel_id: value,
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Выберите внешний канал" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{externalChannels.map((channel) => (
|
||
<SelectItem key={channel.id} value={channel.id}>
|
||
{channel.title}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="creative_id">Креатив *</Label>
|
||
<Select
|
||
value={formData.creative_id}
|
||
onValueChange={(value) =>
|
||
setFormData({ ...formData, creative_id: value })
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Выберите креатив" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{creatives.map((creative) => (
|
||
<SelectItem key={creative.id} value={creative.id}>
|
||
{creative.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Детали размещения</CardTitle>
|
||
<CardDescription>
|
||
Стоимость, дата и параметры ссылки
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="placement_date">Дата размещения *</Label>
|
||
<Input
|
||
id="placement_date"
|
||
type="datetime-local"
|
||
value={formData.placement_date}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
placement_date: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="cost">Стоимость (₽) *</Label>
|
||
<Input
|
||
id="cost"
|
||
type="number"
|
||
step="0.01"
|
||
value={formData.cost}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, cost: e.target.value })
|
||
}
|
||
placeholder="1000"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="post_link">Ссылка на рекламное сообщение</Label>
|
||
<Input
|
||
id="post_link"
|
||
type="url"
|
||
value={formData.post_link}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, post_link: e.target.value })
|
||
}
|
||
placeholder="https://t.me/channel/123"
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Для автоматического отслеживания просмотров
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label>Тип пригласительной ссылки *</Label>
|
||
<RadioGroup
|
||
value={formData.invite_link_type}
|
||
onValueChange={(value: "public" | "approval") =>
|
||
setFormData({ ...formData, invite_link_type: value })
|
||
}
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="public" id="public" />
|
||
<Label htmlFor="public" className="font-normal">
|
||
Открытая (пользователь сразу вступает в канал)
|
||
</Label>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<RadioGroupItem value="approval" id="approval" />
|
||
<Label htmlFor="approval" className="font-normal">
|
||
С одобрением (запрос на вступление через бота)
|
||
</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="comment">Комментарий</Label>
|
||
<Textarea
|
||
id="comment"
|
||
value={formData.comment}
|
||
onChange={(e) =>
|
||
setFormData({ ...formData, comment: e.target.value })
|
||
}
|
||
placeholder="Дополнительные заметки о закупе"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => router.push("/purchases")}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button type="submit" disabled={isLoading}>
|
||
{isLoading ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Создание...
|
||
</>
|
||
) : (
|
||
"Создать закуп"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|