504 lines
17 KiB
TypeScript
504 lines
17 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 { TargetChannel, ExternalChannel, Creative } from "@/lib/types/api";
|
||
|
||
export default function CreatePurchasePage() {
|
||
const router = useRouter();
|
||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||
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({
|
||
target_channel_id: "",
|
||
external_channel_id: "",
|
||
creative_id: "",
|
||
scheduled_date: "",
|
||
cost: "",
|
||
comment: "",
|
||
post_link: "",
|
||
invite_link_type: "public" as "public" | "private",
|
||
});
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, []);
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const [channelsRes, externalChannelsRes, creativesRes] =
|
||
await Promise.all([
|
||
channelsApi.list({ is_active: true }),
|
||
externalChannelsApi.list(),
|
||
creativesApi.list(),
|
||
]);
|
||
setChannels(channelsRes.data);
|
||
setExternalChannels(externalChannelsRes.data);
|
||
setCreatives(creativesRes.data.filter((c) => !c.is_archived));
|
||
} catch (err) {
|
||
console.error("Error loading data:", err);
|
||
}
|
||
};
|
||
|
||
// Фильтр креативов по выбранному целевому каналу
|
||
const filteredCreatives = formData.target_channel_id
|
||
? creatives.filter(
|
||
(c) => c.target_channel_id === formData.target_channel_id
|
||
)
|
||
: creatives;
|
||
|
||
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError(null);
|
||
|
||
// Validation
|
||
if (!formData.target_channel_id) {
|
||
setError("Выберите целевой канал");
|
||
return;
|
||
}
|
||
|
||
if (!formData.external_channel_id) {
|
||
setError("Выберите внешний канал");
|
||
return;
|
||
}
|
||
|
||
if (!formData.creative_id) {
|
||
setError("Выберите креатив");
|
||
return;
|
||
}
|
||
|
||
if (!formData.scheduled_date) {
|
||
setError("Укажите дату размещения");
|
||
return;
|
||
}
|
||
|
||
if (!formData.cost || parseFloat(formData.cost) <= 0) {
|
||
setError("Укажите корректную стоимость");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setIsLoading(true);
|
||
const result = await purchasesApi.create({
|
||
target_channel_id: formData.target_channel_id,
|
||
external_channel_id: formData.external_channel_id,
|
||
creative_id: formData.creative_id,
|
||
scheduled_date: formData.scheduled_date,
|
||
cost: parseFloat(formData.cost),
|
||
comment: formData.comment || undefined,
|
||
post_link: 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({
|
||
target_channel_id: "",
|
||
external_channel_id: "",
|
||
creative_id: "",
|
||
scheduled_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">
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="target_channel_id">Целевой канал *</Label>
|
||
<Select
|
||
value={formData.target_channel_id}
|
||
onValueChange={(value) =>
|
||
setFormData({
|
||
...formData,
|
||
target_channel_id: value,
|
||
creative_id: "", // Сбросить креатив при смене канала
|
||
})
|
||
}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Выберите канал" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{channels.map((channel) => (
|
||
<SelectItem key={channel.id} value={channel.id}>
|
||
{channel.title}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="creative_id">Креатив *</Label>
|
||
<Select
|
||
value={formData.creative_id}
|
||
onValueChange={(value) =>
|
||
setFormData({ ...formData, creative_id: value })
|
||
}
|
||
disabled={!formData.target_channel_id}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue
|
||
placeholder={
|
||
formData.target_channel_id
|
||
? "Выберите креатив"
|
||
: "Сначала выберите целевой канал"
|
||
}
|
||
/>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{filteredCreatives.map((creative) => (
|
||
<SelectItem key={creative.id} value={creative.id}>
|
||
{creative.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</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="scheduled_date">Дата размещения *</Label>
|
||
<Input
|
||
id="scheduled_date"
|
||
type="date"
|
||
value={formData.scheduled_date}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
scheduled_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" | "private") =>
|
||
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="private" id="private" />
|
||
<Label htmlFor="private" 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>
|
||
</>
|
||
);
|
||
}
|