1059 lines
41 KiB
TypeScript
1059 lines
41 KiB
TypeScript
"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;
|
||
expanded: boolean;
|
||
}
|
||
|
||
function ChannelGroup({
|
||
channel,
|
||
placements,
|
||
channelNumber,
|
||
canEdit,
|
||
getPlacementView,
|
||
handleDraftChange,
|
||
editingCell,
|
||
setEditingCell,
|
||
expanded,
|
||
}: ChannelGroupProps) {
|
||
const [isOpen, setIsOpen] = useState(true);
|
||
|
||
useEffect(() => {
|
||
setIsOpen(expanded);
|
||
}, [expanded]);
|
||
|
||
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}
|
||
expanded={expandedAll}
|
||
/>
|
||
))}
|
||
</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}
|
||
/>
|
||
</>
|
||
);
|
||
}
|