feat: ui updates

This commit is contained in:
ivannoskov
2026-01-07 17:54:58 +03:00
parent 646e97df3e
commit 52a4425751
11 changed files with 950 additions and 442 deletions

View File

@@ -18,10 +18,43 @@ import {
Eye,
ShoppingCart,
Info,
Bot,
Settings,
Folder,
Archive,
Trash2,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwareAnalyticsApi } from "@/lib/demo";
import { demoAwareAnalyticsApi, demoAwareProjectsApi } from "@/lib/demo";
import { projectsApi } from "@/lib/api";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
@@ -31,6 +64,7 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Table,
@@ -58,7 +92,7 @@ interface ProjectWithStats extends Project {
total_cost: number;
total_subscriptions: number;
total_views: number;
avg_cps: number | null;
avg_cpf: number | null;
};
}
@@ -88,11 +122,93 @@ const formatCurrency = (value: number | null | undefined): string => {
export default function ProjectsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace } = useWorkspace();
const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [settingsProject, setSettingsProject] = useState<Project | null>(null);
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
const { isDemoMode } = useWorkspace();
const handleOpenSettings = (project: Project) => {
setSettingsProject(project);
setInviteLinkType(project.purchase_invite_type_default || "public");
};
const handleSaveSettings = async () => {
if (!settingsProject) return;
try {
setIsUpdatingSettings(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.updateInviteLinkType(workspaceId, settingsProject.id, {
purchase_invite_type_default: inviteLinkType,
});
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to update settings:", err);
} finally {
setIsUpdatingSettings(false);
}
};
const handleArchive = async () => {
if (!settingsProject) return;
try {
setIsArchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.archive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to archive project:", err);
} finally {
setIsArchiving(false);
}
};
const handleDelete = async () => {
if (!settingsProject) return;
try {
setIsDeleting(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.delete(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
setShowDeleteDialog(false);
setDeleteConfirmationText("");
} catch (err) {
console.error("Failed to delete project:", err);
} finally {
setIsDeleting(false);
}
};
const handleOpenDeleteDialog = () => {
setShowDeleteDialog(true);
};
const handleCloseDeleteDialog = () => {
setShowDeleteDialog(false);
setDeleteConfirmationText("");
};
const getChannelAvatar = (project: Project) => {
// Telegram channel avatar URL format
if (project.username) {
return `https://t.me/i/userpic/320/${project.username}.jpg`;
}
return null;
};
// Load statistics for each project
useEffect(() => {
@@ -115,7 +231,7 @@ export default function ProjectsPage() {
total_cost: spending.total_cost,
total_subscriptions: spending.total_subscriptions,
total_views: spending.total_views,
avg_cps:
avg_cpf:
spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions
: null,
@@ -180,18 +296,34 @@ export default function ProjectsPage() {
<Alert>
<Tv className="h-4 w-4" />
<AlertDescription>
Чтобы добавить новый проект, добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
<AlertDescription className="flex items-center justify-between flex-wrap gap-3">
<span>
Чтобы добавить новый проект, добавьте бота{" "}
<a
href={`https://t.me/${BOT_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-primary hover:underline inline-flex items-center gap-1"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
администратором в ваш Telegram канал.
</span>
<Button
asChild
size="sm"
className="shrink-0"
>
@{BOT_USERNAME}
<ExternalLink className="h-3 w-3" />
</a>{" "}
администратором в ваш Telegram канал.
<a
href={`tg://resolve?domain=${BOT_USERNAME}&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages`}
target="_blank"
rel="noopener noreferrer"
>
<Bot className="h-4 w-4 mr-2" />
Добавить бота
</a>
</Button>
</AlertDescription>
</Alert>
@@ -220,20 +352,20 @@ export default function ProjectsPage() {
<TableHead>Статус</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Потрачено
Подписчиков
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Сумма всех размещений по проекту
Количество подписчиков в канале
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Подписчики
Привлечено подписчиков
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
@@ -246,7 +378,7 @@ export default function ProjectsPage() {
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Просмотры
Суммарный охват
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
@@ -259,18 +391,18 @@ export default function ProjectsPage() {
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
Сред. CPS
CPF
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Subscription средняя стоимость подписчика
Cost Per Follower средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -278,9 +410,12 @@ export default function ProjectsPage() {
<TableRow key={project.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Tv className="h-4 w-4 text-primary" />
</div>
<Avatar className="h-9 w-9">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-4 w-4 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{project.title}</div>
{project.username && (
@@ -308,8 +443,8 @@ export default function ProjectsPage() {
: "Архив"}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(project.stats?.total_cost)}
<TableCell className="text-right">
{formatNumber(project.subscribers_count)}
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_subscriptions)}
@@ -318,16 +453,34 @@ export default function ProjectsPage() {
{formatNumber(project.stats?.total_views)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.stats?.avg_cps)}
{formatCurrency(project.stats?.avg_cpf)}
</TableCell>
<TableCell>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
<div className="flex gap-1 flex-wrap">
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупов
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Folder className="h-3 w-3 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
План закупа
</Link>
</Button>
<Settings className="h-3 w-3 mr-1" />
Настройки
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -342,9 +495,12 @@ export default function ProjectsPage() {
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
</div>
<Avatar className="h-10 w-10">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
{project.username && (
@@ -375,82 +531,222 @@ export default function ProjectsPage() {
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
{project.stats && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.total_cost)}
</div>
<div className="text-xs text-muted-foreground">
Потрачено
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
{project.subscribers_count !== undefined && (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_subscriptions)}
{formatNumber(project.subscribers_count)}
</div>
<div className="text-xs text-muted-foreground">
Подписчиков
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_views)}
</div>
<div className="text-xs text-muted-foreground">
Просмотров
)}
{project.stats && (
<>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_subscriptions)}
</div>
<div className="text-xs text-muted-foreground">
Привлечено подписчиков
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.avg_cps)}
</div>
<div className="text-xs text-muted-foreground">
Сред. CPS
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatNumber(project.stats.total_views)}
</div>
<div className="text-xs text-muted-foreground">
Суммарный охват
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
{formatCurrency(project.stats.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div>
</div>
</>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
{project.username && (
<Button variant="outline" size="sm" asChild>
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-1" />
Канал
</a>
</Button>
)}
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/projects/${project.id}/purchase-plan`}
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупа
План закупов
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/creatives?project_id=${project.id}`}
>
<Folder className="h-4 w-4 mr-1" />
Креативы
</Link>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-4 w-4 mr-1" />
Настройки
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Settings Dialog */}
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Настройки проекта</DialogTitle>
<DialogDescription>
{settingsProject?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="invite-link-type">Тип ссылки по умолчанию</Label>
<Select
value={inviteLinkType}
onValueChange={(value) => setInviteLinkType(value as "public" | "approval")}
>
<SelectTrigger id="invite-link-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public">Открытая</SelectItem>
<SelectItem value="approval">С заявками</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Тип пригласительной ссылки, который будет использоваться по умолчанию при создании размещений для этого проекта
</p>
</div>
{/* Danger Zone */}
<div className="space-y-3 pt-4 border-t">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-destructive">Опасная зона</h4>
<div className="flex flex-col gap-2">
{settingsProject?.status !== "archived" && (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button>
)}
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
</Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setSettingsProject(null)}
>
Отмена
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting}
>
{isUpdatingSettings ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить проект "{settingsProject?.title}"?</AlertDialogTitle>
<AlertDialogDescription>
Это действие необратимо. Все данные проекта будут удалены, включая креативы, размещения и аналитику.
<br /><br />
Для подтверждения введите <strong>Удалить {settingsProject?.title}</strong> ниже:
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={`Удалить ${settingsProject?.title}`}
value={deleteConfirmationText}
onChange={(e) => setDeleteConfirmationText(e.target.value)}
className="mt-4"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCloseDeleteDialog}>
Отмена
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting || deleteConfirmationText !== `Удалить ${settingsProject?.title}`}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Удаление...
</>
) : (
"Удалить"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);