feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -16,13 +16,18 @@ import {
TrendingUp,
Users,
Eye,
ShoppingCart,
Info,
Bot,
Settings,
Folder,
FolderKanban,
Archive,
RotateCcw,
Trash2,
Zap,
Target,
CircleDollarSign,
CircleQuestionMark,
ArrowRightFromLine,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
@@ -80,6 +85,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { BOT_USERNAME } from "@/lib/utils/constants";
import type { Project, SpendingAnalytics } from "@/lib/types/api";
@@ -93,6 +99,8 @@ interface ProjectWithStats extends Project {
total_subscriptions: number;
total_views: number;
avg_cpf: number | null;
avg_cpm: number | null;
placements_count: number;
};
}
@@ -115,6 +123,19 @@ const formatCurrency = (value: number | null | undefined): string => {
}).format(value);
};
const formatStatus = (status: string): string => {
switch (status) {
case "active":
return "Активен";
case "inactive":
return "Неактивен";
case "archived":
return "В архиве";
default:
return status;
}
};
// ============================================================================
// Component
// ============================================================================
@@ -122,7 +143,7 @@ const formatCurrency = (value: number | null | undefined): string => {
export default function ProjectsPage() {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { projects, isLoadingProjects, currentWorkspace, refreshProjects } = useWorkspace();
const { projects, isLoadingProjects, currentWorkspace, refreshProjects, workspaces, isAdmin, isWorkspaceAdmin } = useWorkspace();
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
@@ -131,9 +152,18 @@ export default function ProjectsPage() {
const [inviteLinkType, setInviteLinkType] = useState<"public" | "approval">("public");
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [isUnarchiving, setIsUnarchiving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteConfirmationText, setDeleteConfirmationText] = useState("");
// Move project state
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
const [isMoving, setIsMoving] = useState(false);
const [targetWorkspaceAdmin, setTargetWorkspaceAdmin] = useState(false);
const [isCheckingTargetPermissions, setIsCheckingTargetPermissions] = useState(false);
const { isDemoMode } = useWorkspace();
const handleOpenSettings = (project: Project) => {
@@ -175,6 +205,22 @@ export default function ProjectsPage() {
}
};
const handleUnarchive = async () => {
if (!settingsProject) return;
try {
setIsUnarchiving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.unarchive(workspaceId, settingsProject.id);
await refreshProjects();
setSettingsProject(null);
} catch (err) {
console.error("Failed to unarchive project:", err);
} finally {
setIsUnarchiving(false);
}
};
const handleDelete = async () => {
if (!settingsProject) return;
@@ -202,8 +248,67 @@ export default function ProjectsPage() {
setDeleteConfirmationText("");
};
const handleOpenMoveDialog = async () => {
setShowMoveDialog(true);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
// Check permissions for all other workspaces
setIsCheckingTargetPermissions(true);
const adminWorkspaces: string[] = [];
for (const ws of workspaces) {
if (ws.id === workspaceId) continue;
const isAdmin = await isWorkspaceAdmin(ws.id);
if (isAdmin) {
adminWorkspaces.push(ws.id);
}
}
// Set first admin workspace as default if available
if (adminWorkspaces.length > 0) {
setTargetWorkspaceId(adminWorkspaces[0]);
setTargetWorkspaceAdmin(true);
}
setIsCheckingTargetPermissions(false);
};
const handleCloseMoveDialog = () => {
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
};
const handleTargetWorkspaceChange = async (newWorkspaceId: string) => {
setTargetWorkspaceId(newWorkspaceId);
setTargetWorkspaceAdmin(false);
// Check if user is admin in target workspace
const isAdmin = await isWorkspaceAdmin(newWorkspaceId);
setTargetWorkspaceAdmin(isAdmin);
};
const handleMoveProject = async () => {
if (!settingsProject || !targetWorkspaceId || !targetWorkspaceAdmin) return;
try {
setIsMoving(true);
const api = isDemoMode ? demoAwareProjectsApi : projectsApi;
await api.move(workspaceId, settingsProject.id, { target_workspace_id: targetWorkspaceId });
await refreshProjects();
setSettingsProject(null);
setShowMoveDialog(false);
setTargetWorkspaceId("");
setTargetWorkspaceAdmin(false);
} catch (err) {
console.error("Failed to move project:", err);
} finally {
setIsMoving(false);
}
};
const getChannelAvatar = (project: Project) => {
// Telegram channel avatar URL format
if (project.username) {
return `https://t.me/i/userpic/320/${project.username}.jpg`;
}
@@ -235,6 +340,11 @@ export default function ProjectsPage() {
spending.total_subscriptions > 0
? spending.total_cost / spending.total_subscriptions
: null,
avg_cpm:
spending.total_views > 0
? (spending.total_cost / spending.total_views) * 1000
: null,
placements_count: spending.placements_count || 0,
},
};
} catch {
@@ -275,27 +385,37 @@ export default function ProjectsPage() {
</p>
</div>
<div className="flex items-center gap-2 border rounded-lg p-1">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
aria-label="Плиточный вид"
>
<Grid className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Плиточный вид</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={viewMode === "table" ? "secondary" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
aria-label="Табличный вид"
>
<List className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Табличный вид</TooltipContent>
</Tooltip>
</div>
</div>
<Alert>
<Tv className="h-4 w-4" />
<CircleQuestionMark className="h-4 w-4 mt-1.5" />
<AlertDescription className="flex items-center justify-between flex-wrap gap-3">
<span>
Чтобы добавить новый проект, добавьте бота{" "}
@@ -334,7 +454,7 @@ export default function ProjectsPage() {
) : projectsWithStats.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Tv className="h-12 w-12 text-muted-foreground mb-4" />
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Нет проектов</h3>
<p className="text-muted-foreground text-center max-w-md">
Добавьте бота администратором в ваш Telegram канал, и он
@@ -343,7 +463,6 @@ export default function ProjectsPage() {
</CardContent>
</Card>
) : viewMode === "table" ? (
// Table View
<Card>
<Table>
<TableHeader>
@@ -365,7 +484,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" />
@@ -378,7 +497,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" />
@@ -389,19 +508,7 @@ export default function ProjectsPage() {
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">
<div className="flex items-center justify-end gap-1">
CPF
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
Cost Per Follower средняя стоимость подписчика
</TooltipContent>
</Tooltip>
</div>
</TableHead>
<TableHead className="text-right">CPF</TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
@@ -413,7 +520,7 @@ export default function ProjectsPage() {
<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" />
<FolderKanban className="h-4 w-4 text-primary" />
</AvatarFallback>
</Avatar>
<div>
@@ -435,25 +542,38 @@ export default function ProjectsPage() {
<TableCell>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
className={cn(
project.status === "archived" && "bg-muted text-muted-foreground"
)}
>
{project.status === "active"
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
{formatStatus(project.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right font-medium">
{formatNumber(project.subscribers_count)}
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_subscriptions)}
<div className="flex items-center justify-end gap-1">
<span className="font-medium">
{formatNumber(project.stats?.total_subscriptions)}
</span>
{project.stats?.placements_count ? (
<span className="text-xs text-muted-foreground">
({project.stats.placements_count} размещ.)
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right">
{formatNumber(project.stats?.total_views)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(project.stats?.avg_cpf)}
<div className="flex items-center justify-end gap-1">
<CircleDollarSign className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">
{formatCurrency(project.stats?.avg_cpf)}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
@@ -461,14 +581,15 @@ export default function ProjectsPage() {
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупов
<Zap className="h-3 w-3 mr-1" />
Размещения
</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" />
<Target className="h-3 w-3 mr-1" />
Креативы
</Link>
</Button>
@@ -477,8 +598,7 @@ export default function ProjectsPage() {
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-3 w-3 mr-1" />
Настройки
<Settings className="h-3 w-3" />
</Button>
</div>
</TableCell>
@@ -488,31 +608,30 @@ export default function ProjectsPage() {
</Table>
</Card>
) : (
// Grid View
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projectsWithStats.map((project) => (
<Card key={project.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<Card key={project.id} className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-10 w-10 shrink-0">
<AvatarImage src={getChannelAvatar(project) || undefined} alt={project.title} />
<AvatarFallback className="rounded-lg bg-primary/10">
<Tv className="h-5 w-5 text-primary" />
<FolderKanban className="h-5 w-5 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{project.title}</CardTitle>
<div className="min-w-0">
<CardTitle className="text-base truncate">{project.title}</CardTitle>
{project.username && (
<CardDescription>
<a
href={`https://t.me/${project.username}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline inline-flex items-center gap-0.5"
className="hover:underline inline-flex items-center gap-0.5 truncate"
>
@{project.username}
<ExternalLink className="h-2.5 w-2.5" />
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
</a>
</CardDescription>
)}
@@ -520,85 +639,88 @@ export default function ProjectsPage() {
</div>
<Badge
variant={project.status === "active" ? "default" : "secondary"}
className={cn(
"shrink-0",
project.status === "archived" && "bg-muted text-muted-foreground"
)}
>
{project.status === "active"
? "Активен"
: project.status === "inactive"
? "Неактивен"
: "Архив"}
{formatStatus(project.status)}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<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.subscribers_count)}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>Подписчиков</span>
</div>
<span className="font-semibold">
{formatNumber(project.subscribers_count)}
</span>
</div>
{project.stats && (
<>
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Target className="h-4 w-4 text-primary" />
</div>
<div className="text-xs text-muted-foreground">
Подписчиков
<div>
<div className="text-xs text-muted-foreground">Привлечено</div>
<div className="font-semibold text-sm">
{formatNumber(project.stats.total_subscriptions)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<Eye className="h-4 w-4 text-primary" />
</div>
<div>
<div className="text-xs text-muted-foreground">Охват</div>
<div className="font-semibold text-sm">
{formatNumber(project.stats.total_views)}
</div>
</div>
</div>
</div>
)}
{project.stats && (
<>
<div className="flex items-center justify-between pt-2 border-t">
<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 className="flex items-center justify-center h-8 w-8 rounded-lg bg-primary/10">
<CircleDollarSign className="h-4 w-4 text-primary" />
</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">
Суммарный охват
</div>
</div>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">
<div className="text-xs text-muted-foreground">CPF</div>
<div className="font-semibold">
{formatCurrency(project.stats.avg_cpf)}
</div>
<div className="text-xs text-muted-foreground">
CPF
</div>
</div>
</div>
</>
)}
</div>
{project.stats.placements_count > 0 && (
<Badge variant="outline" className="text-xs">
{project.stats.placements_count} размещ.
</Badge>
)}
</div>
</>
)}
{/* Actions */}
<div className="flex gap-2 flex-wrap">
<Button variant="outline" size="sm" asChild>
<div className="flex gap-2 pt-2 border-t">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link
href={`/dashboard/${currentWorkspace?.id}/purchase-plans/${project.id}`}
>
План закупов
<Zap className="h-3.5 w-3.5 mr-1.5" />
Размещения
</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" />
Креативы
<Target className="h-3.5 w-3.5" />
</Link>
</Button>
<Button
@@ -606,8 +728,7 @@ export default function ProjectsPage() {
size="sm"
onClick={() => handleOpenSettings(project)}
>
<Settings className="h-4 w-4 mr-1" />
Настройки
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
@@ -616,7 +737,6 @@ export default function ProjectsPage() {
</div>
)}
{/* Settings Dialog */}
<Dialog open={!!settingsProject} onOpenChange={(open) => !open && setSettingsProject(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
@@ -641,45 +761,71 @@ export default function ProjectsPage() {
</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>
)}
<h4 className="text-sm font-semibold">Действия</h4>
<div className="flex flex-col gap-2">
{settingsProject?.status === "archived" ? (
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
variant="outline"
onClick={handleUnarchive}
disabled={isUnarchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
{isUnarchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Восстановление...
</>
) : (
<>
<RotateCcw className="h-4 w-4 mr-2" />
Восстановить из архива
</>
)}
</Button>
) : (
<Button
variant="outline"
onClick={handleArchive}
disabled={isArchiving || isDeleting || isUpdatingSettings}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
{isArchiving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Архивирование...
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
Архивировать проект
</>
)}
</Button>
</div>
)}
<Button
variant="outline"
onClick={handleOpenMoveDialog}
disabled={!isAdmin || isArchiving || isDeleting || isUpdatingSettings || isUnarchiving}
className="w-full justify-start"
title={!isAdmin ? "Вы должны быть администратором для перемещения проекта" : undefined}
>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить в другой воркспейс
</Button>
<Button
variant="destructive"
onClick={handleOpenDeleteDialog}
disabled={isArchiving || isDeleting || isUpdatingSettings || settingsProject?.status === "archived"}
className="w-full justify-start"
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить проект
</Button>
</div>
</div>
</div>
@@ -688,11 +834,11 @@ export default function ProjectsPage() {
variant="outline"
onClick={() => setSettingsProject(null)}
>
Отмена
Закрыть
</Button>
<Button
onClick={handleSaveSettings}
disabled={isUpdatingSettings || isArchiving || isDeleting}
disabled={isUpdatingSettings || isArchiving || isDeleting || isUnarchiving}
>
{isUpdatingSettings ? (
<>
@@ -707,7 +853,6 @@ export default function ProjectsPage() {
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={handleCloseDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -747,6 +892,89 @@ export default function ProjectsPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Move Project Dialog */}
<Dialog open={showMoveDialog} onOpenChange={handleCloseMoveDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Переместить проект</DialogTitle>
<DialogDescription>
{settingsProject?.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isCheckingTargetPermissions ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="target-workspace">Воркспейс назначения</Label>
<Select
value={targetWorkspaceId}
onValueChange={handleTargetWorkspaceChange}
>
<SelectTrigger id="target-workspace">
<SelectValue placeholder="Выберите воркспейс" />
</SelectTrigger>
<SelectContent>
{workspaces
.filter((ws) => ws.id !== workspaceId)
.map((ws) => (
<SelectItem key={ws.id} value={ws.id}>
{ws.name}
</SelectItem>
))}
</SelectContent>
</Select>
{!targetWorkspaceId && (
<p className="text-xs text-muted-foreground">
Выберите воркспейс для перемещения проекта
</p>
)}
</div>
{targetWorkspaceId && !targetWorkspaceAdmin && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
У вас нет прав администратора в этом воркспейсе. Для перемещения проекта вы должны быть администратором в обоих воркспейсах.
</div>
)}
{targetWorkspaceId && workspaces.find(w => w.id === targetWorkspaceId)?.avatar_url && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Проект будет перемещён в воркспейс «{workspaces.find(w => w.id === targetWorkspaceId)?.name}»</span>
</div>
)}
</>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleCloseMoveDialog}
>
Отмена
</Button>
<Button
onClick={handleMoveProject}
disabled={!targetWorkspaceId || !targetWorkspaceAdmin || isMoving || isCheckingTargetPermissions}
>
{isMoving ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Перемещение...
</>
) : (
<>
<ArrowRightFromLine className="h-4 w-4 mr-2" />
Переместить
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>
);