Files
tgex-frontend/app/(dashboard)/purchases/new/page.tsx
2025-11-10 12:07:24 +03:00

504 lines
17 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";
// ============================================================================
// 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>
</>
);
}