Files
tgex-frontend/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
2026-02-02 11:17:50 +03:00

1052 lines
41 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";
// ============================================================================
// Placements Detail Page - All placements grouped by channel
// ============================================================================
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
Loader2,
Plus,
ArrowLeft,
ChevronDown,
ChevronRight,
Search,
Filter,
Eye,
Users,
ExternalLink,
CircleDollarSign,
TrendingUp,
RefreshCw,
Loader2 as LoaderIcon,
BarChart3,
ArrowUpDown,
X,
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { demoAwarePlacementsApi } from "@/lib/demo";
import { placementsApi } from "@/lib/api";
import { AddChannelDialog } from "@/components/dialog-add-channel";
import { CreatePlacementsDialog } from "@/components/dialog-create-placements";
import { FiltersModal, PlacementFilters } from "@/components/filters-modal";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type {
Project,
Placement,
PlacementWithStats,
PlacementsGroupedByChannel,
PlacementStatus,
} from "@/lib/types/api";
type PlacementInlineDraft = Partial<{
status: PlacementStatus;
comment: string | null;
creative_id: string | null;
placement_at: string | null;
payment_at: string | null;
cost: { type: "fixed"; value: number } | null;
cost_before_bargain: number | null;
placement_type: string | null;
format: string | null;
}>;
type EditableField = "status" | "placement_at" | "cost" | "format" | "comment";
interface EditingCell {
placementId: string;
field: EditableField;
}
// ============================================================================
// Helpers
// ============================================================================
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU").format(value);
};
const formatCurrency = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
const formatCompact = (value: number | null | undefined): string => {
if (value === null || value === undefined) return "—";
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`;
return value.toString();
};
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return "—";
const date = new Date(dateStr);
return date.toLocaleDateString("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const toDateInputValue = (iso: string | null | undefined): string => {
if (!iso) return "";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const toIsoFromDateInput = (value: string): string | null => {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
};
const PLACEMENT_STATUSES: PlacementStatus[] = [
"Без статуса",
"Написать",
"Ждём ответа",
"Согласование условий",
"Оплатить",
"Оплачено",
"Отмена",
"Не подходит цена",
"Неактуально",
"Не отвечает",
];
const getStatusColor = (status: PlacementStatus): string => {
switch (status) {
case "Оплачено":
return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
case "Согласование условий":
case "Ждём ответа":
return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
case "Отмена":
case "Не подходит цена":
case "Неактуально":
case "Не отвечает":
return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
case "Оплатить":
return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
default:
return "bg-muted text-muted-foreground border-border";
}
};
// ============================================================================
// Components
// ============================================================================
interface ChannelGroupProps {
channel: Placement["channel"];
placements: PlacementWithStats[];
channelNumber: number;
canEdit: boolean;
getPlacementView: (placement: PlacementWithStats) => PlacementWithStats;
handleDraftChange: (placementId: string, patch: PlacementInlineDraft) => void;
editingCell: EditingCell | null;
setEditingCell: (cell: EditingCell | null) => void;
}
function ChannelGroup({
channel,
placements,
channelNumber,
canEdit,
getPlacementView,
handleDraftChange,
editingCell,
setEditingCell,
}: ChannelGroupProps) {
const [isOpen, setIsOpen] = useState(true);
const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
const avgCpf = totalSubscriptions > 0 ? totalCost / totalSubscriptions : null;
const avgCpm = totalViews > 0 ? (totalCost / totalViews) * 1000 : null;
if (placements.length === 0) return null;
return (
<div className="mb-3">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-lg cursor-pointer hover:bg-muted transition-colors">
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
!isOpen && "-rotate-90"
)}
/>
<span className="text-xs font-mono text-muted-foreground bg-background px-1.5 py-0.5 rounded w-6 text-center">
{channelNumber}
</span>
<Avatar className="h-8 w-8">
<AvatarImage
src={channel.username ? `https://t.me/i/userpic/160/${channel.username}.jpg` : undefined}
alt={channel.title}
/>
<AvatarFallback className="text-sm">
{channel.title[0]}
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="font-medium text-sm">{channel.title}</span>
{channel.username && (
<span className="text-xs text-muted-foreground">@{channel.username}</span>
)}
</div>
{channel.username && (
<div className="flex items-center gap-1 ml-2">
<a
href={`https://telemetr.me/channel/${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:opacity-75 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<img src="/icons/telemetr.jpg" alt="TgStat" className="h-5 w-5 rounded" />
</a>
<a
href={`https://tgstat.ru/channel/@${channel.username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center h-6 w-6 hover:opacity-75 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<img src="/icons/tgstat.jpg" alt="TgStat" className="h-5 w-5 rounded" />
</a>
</div>
)}
<Badge variant="outline" className="ml-auto text-xs">
{placements.length}
</Badge>
<div className="flex items-center gap-3 text-xs text-muted-foreground ml-2">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{formatCompact(channel.subscribers_count)}
</span>
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{formatCompact(totalViews)}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-8"></th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-16"></th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-28">Статус</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-28">Дата</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-24">Стоимость</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">Охват</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">Подписки</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">CPF</th>
<th className="text-right py-2 px-3 font-medium text-muted-foreground w-20">CPM</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-24">Формат</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground w-32">Комментарий</th>
</tr>
</thead>
<tbody>
{placements.map((placement, idx) => {
const viewPlacement = getPlacementView(placement);
const effectiveCost = viewPlacement.details?.cost?.value || 0;
const isEditingStatus =
canEdit &&
editingCell?.placementId === placement.id &&
editingCell.field === "status";
const isEditingDate =
canEdit &&
editingCell?.placementId === placement.id &&
editingCell.field === "placement_at";
const isEditingCost =
canEdit &&
editingCell?.placementId === placement.id &&
editingCell.field === "cost";
const isEditingFormat =
canEdit &&
editingCell?.placementId === placement.id &&
editingCell.field === "format";
const isEditingComment =
canEdit &&
editingCell?.placementId === placement.id &&
editingCell.field === "comment";
const cpf = viewPlacement.placement_post?.subscriptions_count
? effectiveCost / viewPlacement.placement_post.subscriptions_count
: null;
const cpm = viewPlacement.placement_post?.views_count
? (effectiveCost / viewPlacement.placement_post.views_count) * 1000
: null;
return (
<tr key={placement.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="py-2 px-3"></td>
<td className="py-2 px-3">
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{channelNumber}.{idx + 1}
</span>
</td>
<td className="py-2 px-3">
{isEditingStatus ? (
<select
aria-label="Статус размещения"
className={cn(
"h-7 w-full rounded-md border border-input bg-background px-2 text-xs font-medium shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
getStatusColor(viewPlacement.status)
)}
value={viewPlacement.status}
onChange={(e) =>
handleDraftChange(placement.id, {
status: e.target.value as PlacementStatus,
})
}
onBlur={() => setEditingCell(null)}
>
{PLACEMENT_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex max-w-full items-center rounded px-0 py-0.5 text-left text-xs font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit &&
setEditingCell({ placementId: placement.id, field: "status" })
}
>
<span
className={cn(
"inline-flex items-center px-2 py-0.5 rounded border",
getStatusColor(viewPlacement.status)
)}
>
{viewPlacement.status}
</span>
</button>
)}
</td>
<td className="py-2 px-3 text-muted-foreground">
{isEditingDate ? (
<Input
aria-label="Дата размещения"
type="date"
autoFocus
className="h-7 w-[140px] bg-background px-2 text-xs"
value={toDateInputValue(viewPlacement.details?.placement_at || null)}
onChange={(e) =>
handleDraftChange(placement.id, {
placement_at: toIsoFromDateInput(e.target.value),
})
}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-[140px] items-center rounded px-0 text-left text-xs text-muted-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit &&
setEditingCell({ placementId: placement.id, field: "placement_at" })
}
>
{formatDate(viewPlacement.details?.placement_at || null)}
</button>
)}
</td>
<td className="py-2 px-3 font-medium">
{isEditingCost ? (
<Input
aria-label="Стоимость"
type="number"
inputMode="numeric"
autoFocus
className="h-7 w-[140px] bg-background px-2 text-xs"
value={
viewPlacement.details?.cost?.value === null ||
viewPlacement.details?.cost?.value === undefined
? ""
: String(viewPlacement.details.cost.value)
}
onChange={(e) => {
const raw = e.target.value;
if (!raw) {
handleDraftChange(placement.id, { cost: null });
return;
}
const value = Number(raw);
if (Number.isNaN(value)) return;
handleDraftChange(placement.id, { cost: { type: "fixed", value } });
}}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-[140px] items-center rounded px-0 text-left text-xs font-medium hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit &&
setEditingCell({ placementId: placement.id, field: "cost" })
}
>
{formatCurrency(viewPlacement.details?.cost?.value || null)}
</button>
)}
</td>
<td className="py-2 px-3 text-right text-muted-foreground">
{formatCompact(viewPlacement.placement_post?.views_count || null)}
</td>
<td className="py-2 px-3 text-right text-muted-foreground">
{formatNumber(viewPlacement.placement_post?.subscriptions_count || null)}
</td>
<td className="py-2 px-3 text-right font-medium">
{formatCurrency(cpf)}
</td>
<td className="py-2 px-3 text-right font-medium">
{formatCurrency(cpm)}
</td>
<td className="py-2 px-3 text-muted-foreground">
{isEditingFormat ? (
<Input
aria-label="Формат"
autoFocus
className="h-7 w-[140px] bg-background px-2 text-xs"
value={viewPlacement.details?.format || ""}
onChange={(e) =>
handleDraftChange(placement.id, {
format: e.target.value ? e.target.value : null,
})
}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] w-[140px] items-center rounded px-0 text-left text-xs text-muted-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit &&
setEditingCell({ placementId: placement.id, field: "format" })
}
>
{viewPlacement.details?.format || "—"}
</button>
)}
</td>
<td className="py-2 px-3 text-muted-foreground">
{isEditingComment ? (
<Input
aria-label="Комментарий"
autoFocus
className="h-7 min-w-[240px] bg-background px-2 text-xs"
value={viewPlacement.comment || ""}
onChange={(e) =>
handleDraftChange(placement.id, {
comment: e.target.value ? e.target.value : null,
})
}
onBlur={() => setEditingCell(null)}
/>
) : (
<button
type="button"
disabled={!canEdit}
className="inline-flex min-h-[1.75rem] max-w-[240px] items-center rounded px-0 text-left text-xs text-muted-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
onClick={() =>
canEdit &&
setEditingCell({ placementId: placement.id, field: "comment" })
}
>
<span className="truncate w-full">
{viewPlacement.comment || "—"}
</span>
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function PurchasePlanDetailPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const workspaceId = params?.workspaceId as string;
const projectId = params?.projectId as string;
const { projects, hasPermission } = useWorkspace();
const [placements, setPlacements] = useState<Placement[]>([]);
const [loading, setLoading] = useState(true);
const [project, setProject] = useState<Project | null>(null);
const [draftsByPlacementId, setDraftsByPlacementId] = useState<Record<string, PlacementInlineDraft>>({});
const [savingEdits, setSavingEdits] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
// Create placements dialog
const prefilledCreativeId = searchParams.get("creative_id");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [expandedAll, setExpandedAll] = useState(true);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<PlacementFilters>({
channelFilters: {
name: { value: "", condition: "contains" },
username: { value: "", condition: "contains" },
subscribersMin: null,
subscribersMax: null,
},
placementFilters: {
statuses: [],
costMin: null,
costMax: null,
dateFrom: null,
dateTo: null,
hasPlacement: null,
},
sort: null,
});
const { isDemoMode } = useWorkspace();
useEffect(() => {
const proj = projects.find((p) => p.id === projectId);
if (proj) {
setProject(proj);
}
}, [projects, projectId]);
// Auto-open create dialog if creative_id is provided
useEffect(() => {
if (prefilledCreativeId && project) {
setShowCreateDialog(true);
}
}, [prefilledCreativeId, project]);
useEffect(() => {
loadPlacements();
}, [workspaceId, projectId]);
const loadPlacements = async () => {
try {
setLoading(true);
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
const data = await api.getByProject(workspaceId, projectId);
setPlacements(data);
setDraftsByPlacementId({});
setSaveError(null);
} catch (err) {
console.error("Failed to load placements:", err);
} finally {
setLoading(false);
}
};
const canEditPlacements = hasPermission("placements_write") && !isDemoMode;
const hasUnsavedChanges = Object.keys(draftsByPlacementId).length > 0;
const handleDraftChange = (placementId: string, patch: PlacementInlineDraft) => {
if (!canEditPlacements) return;
setDraftsByPlacementId((prev) => ({
...prev,
[placementId]: {
...(prev[placementId] || {}),
...patch,
},
}));
};
const getPlacementView = (placement: PlacementWithStats): PlacementWithStats => {
const draft = draftsByPlacementId[placement.id];
if (!draft) return placement;
const nextStatus = draft.status ?? placement.status;
const nextComment = draft.comment !== undefined ? draft.comment : placement.comment;
const nextDetails = {
...(placement.details || {
placement_at: null,
payment_at: null,
cost: null,
cost_before_bargain: null,
placement_type: null,
format: null,
comment: null,
creative_id: null,
}),
placement_at:
draft.placement_at !== undefined
? draft.placement_at
: placement.details?.placement_at ?? null,
payment_at:
draft.payment_at !== undefined
? draft.payment_at
: placement.details?.payment_at ?? null,
cost:
draft.cost !== undefined
? draft.cost
: placement.details?.cost ?? null,
cost_before_bargain:
draft.cost_before_bargain !== undefined
? draft.cost_before_bargain
: placement.details?.cost_before_bargain ?? null,
placement_type:
draft.placement_type !== undefined
? draft.placement_type
: placement.details?.placement_type ?? null,
format:
draft.format !== undefined
? draft.format
: placement.details?.format ?? null,
creative_id:
draft.creative_id !== undefined
? draft.creative_id
: placement.details?.creative_id ?? null,
};
return {
...placement,
status: nextStatus,
comment: nextComment,
details: nextDetails,
};
};
const handleCancelEdits = () => {
setDraftsByPlacementId({});
setSaveError(null);
setEditingCell(null);
};
const handleSaveEdits = async () => {
if (!canEditPlacements) return;
const entries = Object.entries(draftsByPlacementId);
if (entries.length === 0) return;
try {
setSavingEdits(true);
setSaveError(null);
for (const [placementId, draft] of entries) {
await placementsApi.updateInProject(workspaceId, projectId, placementId, draft);
}
await loadPlacements();
} catch (err: unknown) {
console.error("Failed to save placement edits:", err);
const message =
typeof err === "object" && err && "detail" in err
? String((err as any).detail)
: "Не удалось сохранить изменения";
setSaveError(message);
setSavingEdits(false);
}
};
const groupedPlacements = useMemo(() => {
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
placements.forEach((placement) => {
const channelId = placement.channel.id;
if (!groups[channelId]) {
groups[channelId] = {
channel: placement.channel,
placements: [],
};
}
const cpf = placement.placement_post?.subscriptions_count
? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count
: null;
const cpm = placement.placement_post?.views_count
? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000
: null;
groups[channelId].placements.push({
...placement,
cpf,
cpm,
});
});
return Object.entries(groups).map(([_, group]) => ({
channel: group.channel,
placements: group.placements,
}));
}, [placements]);
const summary = useMemo(() => {
const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0);
const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0);
return {
totalPlacements: placements.length,
totalChannels: groupedPlacements.length,
totalCost,
totalSubscriptions,
totalViews,
avgCpf: totalSubscriptions > 0 ? totalCost / totalSubscriptions : null,
avgCpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null,
};
}, [placements, groupedPlacements]);
const filteredGroups = useMemo(() => {
let filtered = groupedPlacements.filter((group) => {
// Channel filters
const nameFilter = filters.channelFilters.name;
const channelNameMatch = !nameFilter.value ||
(nameFilter.condition === "contains"
? group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase())
: nameFilter.condition === "not_contains"
? !group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase())
: group.channel.title.toLowerCase() === nameFilter.value.toLowerCase());
const usernameFilter = filters.channelFilters.username;
const channelUsernameMatch = !usernameFilter.value ||
(usernameFilter.condition === "contains"
? group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase())
: usernameFilter.condition === "not_contains"
? !group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase())
: group.channel.username?.toLowerCase() === usernameFilter.value.toLowerCase());
const subscribersMinMatch = filters.channelFilters.subscribersMin === null ||
(group.channel.subscribers_count || 0) >= filters.channelFilters.subscribersMin;
const subscribersMaxMatch = filters.channelFilters.subscribersMax === null ||
(group.channel.subscribers_count || 0) <= filters.channelFilters.subscribersMax;
// Placement filters
const placementsWithStatus = filters.placementFilters.statuses.length === 0 ||
group.placements.some((p) => filters.placementFilters.statuses.includes(p.status));
const costMinMatch = filters.placementFilters.costMin === null ||
group.placements.some((p) => (p.details?.cost?.value || 0) >= filters.placementFilters.costMin!);
const costMaxMatch = filters.placementFilters.costMax === null ||
group.placements.some((p) => (p.details?.cost?.value || 0) <= filters.placementFilters.costMax!);
let dateMatch = true;
if (filters.placementFilters.dateFrom || filters.placementFilters.dateTo) {
dateMatch = group.placements.some((p) => {
if (!p.details?.placement_at) return false;
const placementDate = new Date(p.details.placement_at);
const fromMatch = !filters.placementFilters.dateFrom ||
placementDate >= new Date(filters.placementFilters.dateFrom);
const toMatch = !filters.placementFilters.dateTo ||
placementDate <= new Date(filters.placementFilters.dateTo);
return fromMatch && toMatch;
});
}
return channelNameMatch && channelUsernameMatch && subscribersMinMatch &&
subscribersMaxMatch && placementsWithStatus && costMinMatch && costMaxMatch && dateMatch;
});
// Apply sorting
if (filters.sort) {
filtered = [...filtered].sort((a, b) => {
let aValue: number | string = 0;
let bValue: number | string = 0;
switch (filters.sort!.field) {
case "title":
aValue = a.channel.title.toLowerCase();
bValue = b.channel.title.toLowerCase();
break;
case "subscribers":
aValue = a.channel.subscribers_count || 0;
bValue = b.channel.subscribers_count || 0;
break;
case "cost":
aValue = Math.max(...a.placements.map((p) => p.details?.cost?.value || 0));
bValue = Math.max(...b.placements.map((p) => p.details?.cost?.value || 0));
break;
case "date":
aValue = Math.max(
...a.placements.map((p) => p.details?.placement_at ? new Date(p.details.placement_at).getTime() : 0)
);
bValue = Math.max(
...b.placements.map((p) => p.details?.placement_at ? new Date(p.details.placement_at).getTime() : 0)
);
break;
case "cpm":
aValue = a.placements[0]?.cpm || 0;
bValue = b.placements[0]?.cpm || 0;
break;
case "cpf":
aValue = a.placements[0]?.cpf || 0;
bValue = b.placements[0]?.cpf || 0;
break;
}
if (typeof aValue === "string") {
return filters.sort!.direction === "asc"
? aValue.localeCompare(bValue as string)
: (bValue as string).localeCompare(aValue);
}
return filters.sort!.direction === "asc"
? (aValue as number) - (bValue as number)
: (bValue as number) - (aValue as number);
});
}
return filtered;
}, [groupedPlacements, filters]);
const canWrite = hasPermission("placements_write");
const hasActiveFilters = filters.channelFilters.name ||
filters.channelFilters.username ||
filters.channelFilters.subscribersMin !== null ||
filters.channelFilters.subscribersMax !== null ||
filters.placementFilters.statuses.length > 0 ||
filters.placementFilters.costMin !== null ||
filters.placementFilters.costMax !== null ||
filters.placementFilters.dateFrom ||
filters.placementFilters.dateTo ||
filters.sort;
if (loading) {
return (
<>
<DashboardHeader breadcrumbs={[{ label: "Загрузка..." }]} />
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
);
}
return (
<>
<DashboardHeader
breadcrumbs={[
{ label: "Главная", href: `/dashboard/${workspaceId}` },
{ label: "Размещения", href: `/dashboard/${workspaceId}/purchase-plans` },
{ label: project?.title || "Проект" },
]}
/>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/dashboard/${workspaceId}/purchase-plans`)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">
План закупов проекта «{project?.title || "Проект"}»
</h1>
<p className="text-muted-foreground">
Список каналов, в которых планируем размещения для проекта
</p>
</div>
</div>
<div className="flex items-center gap-2">
<AddChannelDialog
workspaceId={workspaceId}
projectId={projectId}
existingPlacements={placements}
onSuccess={loadPlacements}
>
<Button variant="outline" disabled={isDemoMode}>
<Plus className="h-4 w-4 mr-2" />
Добавить канал
</Button>
</AddChannelDialog>
<CreatePlacementsDialog
workspaceId={workspaceId}
projectId={projectId}
existingPlacements={placements}
prefilledCreativeId={prefilledCreativeId}
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSuccess={loadPlacements}
>
<Button disabled={isDemoMode}>
<Plus className="h-4 w-4 mr-2" />
Создать размещения
</Button>
</CreatePlacementsDialog>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск канала..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button
variant={hasActiveFilters ? "default" : "outline"}
size="sm"
onClick={() => setFiltersOpen(true)}
>
<Filter className="h-4 w-4 mr-1" />
Фильтры
{hasActiveFilters && (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-primary-foreground/20 rounded">
</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setExpandedAll(!expandedAll)}
>
{expandedAll ? "Свернуть все" : "Развернуть все"}
</Button>
</div>
{/* Placements List */}
{filteredGroups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<TrendingUp 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">
{placements.length === 0
? "Добавьте первое размещение для этого проекта"
: "Попробуйте изменить параметры поиска"}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-1">
{filteredGroups.map((group, idx) => (
<ChannelGroup
key={group.channel.id}
channel={group.channel}
placements={group.placements}
channelNumber={idx + 1}
canEdit={canEditPlacements}
getPlacementView={getPlacementView}
handleDraftChange={handleDraftChange}
editingCell={editingCell}
setEditingCell={setEditingCell}
/>
))}
</div>
)}
</div>
{hasUnsavedChanges && (
<div className="fixed bottom-4 right-4 z-50">
<div className="rounded-lg border bg-background/95 p-3 shadow-lg backdrop-blur">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleCancelEdits}
disabled={savingEdits}
>
Отменить
</Button>
<Button
type="button"
onClick={handleSaveEdits}
disabled={savingEdits}
>
{savingEdits ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Сохранение...
</>
) : (
"Сохранить"
)}
</Button>
</div>
{saveError && (
<div className="mt-2 text-xs text-destructive">
{saveError}
</div>
)}
</div>
</div>
)}
<FiltersModal
open={filtersOpen}
onOpenChange={setFiltersOpen}
onApply={setFilters}
existingPlacements={placements}
initialFilters={filters}
/>
</>
);
}