diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index 6e9f56d..5838abe 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -32,6 +32,8 @@ import { Pencil, Send, Check, + ImageIcon, + MousePointerClick, } from "lucide-react"; import { DashboardHeader } from "@/components/layout/dashboard-header"; import { useWorkspace } from "@/components/providers/workspace-provider"; @@ -82,6 +84,8 @@ import { ColumnSelectorDialog } from "@/components/column-selector-dialog"; import { CreativeSelectDialog } from "@/components/creative-select-dialog"; import { PlacementStatusSelector } from "@/components/placement-status-selector"; import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector"; +import { CostFormatSelector } from "@/components/cost-format-selector"; +import { FormatSelector } from "@/components/format-selector"; import { PlacementWizard } from "@/components/placement-wizard"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -96,6 +100,7 @@ import type { PlacementType, CostType, UpdatePlacementInProjectRequest, + Creative, } from "@/lib/types/api"; import { ALL_COLUMNS, @@ -119,10 +124,11 @@ type PlacementInlineDraft = { cost_before_bargain?: { type: CostType; value: number } | null; placement_type?: PlacementType | null; format?: string | null; + originalFormat?: string | null; cpm?: number | null; }; -type EditableField = "status" | "post_status" | "placement_at" | "payment_at" | "cost" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name"; +type EditableField = "status" | "post_status" | "placement_at" | "payment_at" | "cost" | "cost_before" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name" | "placement_type"; const FIXED_COST_TYPE: CostType = "fixed"; @@ -225,6 +231,7 @@ interface ChannelGroupProps { onClearCreative?: (placementId: string) => void; onOpenCreativeSend?: (placement: PlacementWithStats) => void; onAddPlacement?: (channel: Placement["channel"]) => void; + creatives: Creative[]; } // ============================================================================ @@ -249,6 +256,7 @@ interface CellRendererProps { onOpenCreativeSelect?: (placement: PlacementWithStats) => void; onClearCreative?: (placementId: string) => void; onOpenCreativeSend?: (placement: PlacementWithStats) => void; + creatives: Creative[]; } function CellRenderer({ @@ -269,6 +277,7 @@ function CellRenderer({ onOpenCreativeSelect, onClearCreative, onOpenCreativeSend, + creatives, }: CellRendererProps) { const isEditing = editingCell?.placementId === placement.id; @@ -378,17 +387,37 @@ function CellRenderer({ case "creative": const viewCreativeId = placement.creative_id; const hasCreative = !!viewCreativeId; + const creative = hasCreative ? creatives.find((c) => c.id === viewCreativeId) : null; + const thumbnailUrl = creative?.media_items?.[0]?.s3_url; + const imagesCount = creative?.media_items?.length || 0; + const buttonsCount = creative?.buttons?.length || 0; + return (
- {hasCreative ? ( + {hasCreative && creative ? (
-
- {placement.creative_name || "Без названия"} +
+ {imagesCount > 0 && ( +
+ + {imagesCount} +
+ )} + {buttonsCount > 0 && ( +
+ + {buttonsCount} +
+ )} + + {creative.text?.replace(/<[^>]+>/g, '').substring(0, 50) || "Без текста"} + {creative.text && creative.text.replace(/<[^>]+>/g, '').length > 50 && '...'} +
); - case "invite_link": - return ( -
- {placement.invite_link ? ( - - {placement.invite_link.slice(8, 20)}… - - ) : ( - - )} -
- ); - - case "post_link": - return ( -
- {placement.placement_post?.post?.url ? ( - - Пост - - ) : ( - - )} -
- ); - case "cost": const costType = placement.details?.cost?.type || "fixed"; const costValue = placement.details?.cost?.value || 0; @@ -499,7 +497,7 @@ function CellRenderer({ + )}
); @@ -570,36 +604,18 @@ function CellRenderer({ const costFormat = placement.details?.cost?.type || "fixed" as CostType; return (
- {isEditing && editingCell?.field === "cost_format" ? ( - - ) : ( - - )} + setEditingCell({ placementId: placement.id, field: "cost_format" })} + onChange={(value: CostType) => { + handleDraftChange(placement.id, { + cost: { type: value, value: placement.details?.cost?.value || 0 }, + }); + }} + onCloseEdit={() => setEditingCell(null)} + />
); @@ -607,7 +623,36 @@ function CellRenderer({ const placementType = placement.details?.placement_type; return (
- {placementType === "self_promo" ? "Взаимный пиар" : placementType === "standard" ? "Стандартный" : "—"} + {isEditing && editingCell?.field === "placement_type" ? ( + + ) : ( + + )}
); @@ -616,7 +661,7 @@ function CellRenderer({ const cost = placement.details?.cost?.value; const discount = costBeforeBargain && cost && costBeforeBargain > 0 ? ((costBeforeBargain - cost) / costBeforeBargain) * 100 : null; return ( -
+
{discount ? `${discount.toFixed(1)}%` : "—"}
); @@ -641,7 +686,7 @@ function CellRenderer({ - )} + setEditingCell({ placementId: placement.id, field: "format" })} + onChange={(value) => handleDraftChange(placement.id, { format: value, originalFormat: value })} + onCloseEdit={() => setEditingCell(null)} + />
); case "time_top": const timeOnTop = placement.placement_post?.time_on_top; return ( -
+
{formatTime(timeOnTop)}
); case "link_created": return ( -
+
{formatDate(placement.invite_link_created_at)}
); case "post_deleted": return ( -
+
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)}
); case "subscriptions": const subsCountValue = placement.placement_post?.subscriptions_count; - // Show "—" if subscriptions are hidden due to permissions (0 means hidden) const showSubs = subsCountValue === 0 ? "—" : formatNumber(subsCountValue); return ( -
+
{showSubs}
); case "views": return ( -
+
{formatNumber(placement.placement_post?.views_count)}
); case "cpf": return ( -
+
{cpf ? formatCurrency(cpf) : "—"}
); case "conversion_24h": return ( -
+
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"}
); case "conversion_48h": return ( -
+
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"}
); case "conversion_total": return ( -
+
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"}
); case "total_unsubs": return ( -
+
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"}
); case "unsub_percent": return ( -
+
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"}
); case "total_active": return ( -
+
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"}
); case "time_in_feed": return ( -
+
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"}
); case "invite_type": return ( -
+
{placement.invite_link_type === "approval" ? "С заявками" : "Открытая"}
); + case "invite_link": + return ( +
+ {placement.invite_link ? ( + + {placement.invite_link.slice(8, 20)}… + + ) : ( + + )} +
+ ); + + case "post_link": + return ( +
+ {placement.placement_post?.post?.url ? ( + + Пост + + ) : ( + + )} +
+ ); + case "comment": return (
@@ -834,7 +892,7 @@ function CellRenderer({ handleDraftChange(placement.id, { @@ -847,13 +905,13 @@ function CellRenderer({ )}
@@ -900,6 +958,7 @@ function ChannelGroup({ onClearCreative, onOpenCreativeSend, onAddPlacement, + creatives, }: ChannelGroupProps) { const [isOpen, setIsOpen] = useState(true); @@ -1037,6 +1096,7 @@ function ChannelGroup({ onOpenCreativeSelect={onOpenCreativeSelect} onClearCreative={onClearCreative} onOpenCreativeSend={onOpenCreativeSend} + creatives={creatives} /> ); })} @@ -1109,7 +1169,7 @@ export default function PurchasePlanDetailPage() { // Creative selection dialog const [showCreativeDialog, setShowCreativeDialog] = useState(false); const [selectedPlacementForCreative, setSelectedPlacementForCreative] = useState(null); - const [creatives, setCreatives] = useState>([]); + const [creatives, setCreatives] = useState([]); // Placement wizard const [wizardInitialCreative, setWizardInitialCreative] = useState(null); @@ -1212,6 +1272,20 @@ export default function PurchasePlanDetailPage() { setPlacements(placementsWithCreativeNames); setDraftsByPlacementId({}); setSaveError(null); + + // Load creatives for the project + if (!isDemoMode) { + try { + const creativesResponse = await creativesApi.list(workspaceId, { + project_id: projectId, + include_archived: false, + size: 100, + }); + setCreatives(creativesResponse.items); + } catch (err) { + console.error("Failed to load creatives:", err); + } + } } catch (err) { console.error("Failed to load placements:", err); } finally { @@ -1308,9 +1382,11 @@ export default function PurchasePlanDetailPage() { ? draft.placement_type : placement.details?.placement_type ?? null, format: - draft.format !== undefined - ? draft.format - : placement.details?.format ?? null, + draft.originalFormat !== undefined + ? draft.originalFormat + : draft.format !== undefined + ? draft.format + : placement.details?.format ?? null, creative_id: draft.creative_id !== undefined ? draft.creative_id @@ -1619,33 +1695,33 @@ export default function PurchasePlanDetailPage() { filtered = filtered.map((group) => ({ ...group, placements: [...group.placements].sort((a, b) => { - let aValue = 0; - let bValue = 0; + let aValue: number | null = null; + let bValue: number | null = null; switch (filters.sort!.field) { case "cost": - aValue = a.details?.cost?.value || 0; - bValue = b.details?.cost?.value || 0; + aValue = a.details?.cost?.value ?? null; + bValue = b.details?.cost?.value ?? null; break; case "date": - aValue = a.details?.placement_at ? new Date(a.details.placement_at).getTime() : 0; - bValue = b.details?.placement_at ? new Date(b.details.placement_at).getTime() : 0; + aValue = a.details?.placement_at ? new Date(a.details.placement_at).getTime() : null; + bValue = b.details?.placement_at ? new Date(b.details.placement_at).getTime() : null; break; case "cpm": - aValue = a.cpm || 0; - bValue = b.cpm || 0; + aValue = a.cpm ?? null; + bValue = b.cpm ?? null; break; case "cpf": - aValue = a.cpf || 0; - bValue = b.cpf || 0; + aValue = a.cpf ?? null; + bValue = b.cpf ?? null; break; case "subscribers": - aValue = a.placement_post?.subscriptions_count || 0; - bValue = b.placement_post?.subscriptions_count || 0; + aValue = a.placement_post?.subscriptions_count ?? null; + bValue = b.placement_post?.subscriptions_count ?? null; break; case "views": - aValue = a.placement_post?.views_count || 0; - bValue = b.placement_post?.views_count || 0; + aValue = a.placement_post?.views_count ?? null; + bValue = b.placement_post?.views_count ?? null; break; case "created": aValue = new Date(a.created_at).getTime(); @@ -1653,6 +1729,11 @@ export default function PurchasePlanDetailPage() { break; } + // Null values always at the end + if (aValue === null && bValue === null) return 0; + if (aValue === null) return 1; + if (bValue === null) return -1; + return filters.sort!.direction === "asc" ? aValue - bValue : bValue - aValue; @@ -1858,6 +1939,7 @@ export default function PurchasePlanDetailPage() { onClearCreative={handleClearCreative} onOpenCreativeSend={handleOpenCreativeSend} onAddPlacement={handleAddPlacement} + creatives={creatives} /> ))}
diff --git a/components/cost-format-selector.tsx b/components/cost-format-selector.tsx new file mode 100644 index 0000000..329b88a --- /dev/null +++ b/components/cost-format-selector.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Pencil, Check, Banknote, Eye } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { CostType } from "@/lib/types/api"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; + +interface CostFormatSelectorProps { + value: CostType; + isEditing: boolean; + canEdit: boolean; + onEdit: () => void; + onChange: (value: CostType) => void; + onCloseEdit: () => void; +} + +const COST_FORMATS: { value: CostType; label: string; icon: typeof Banknote }[] = [ + { value: "fixed", label: "Фикс", icon: Banknote }, + { value: "cpm", label: "CPM", icon: Eye }, +]; + +export function CostFormatSelector({ + value, + isEditing, + canEdit, + onEdit, + onChange, + onCloseEdit, +}: CostFormatSelectorProps) { + const currentFormat = COST_FORMATS.find((f) => f.value === value) || COST_FORMATS[0]; + const Icon = currentFormat.icon; + + if (isEditing) { + return ( + !open && onCloseEdit()}> + + + + +
+ {COST_FORMATS.map((format) => { + const FormatIcon = format.icon; + const isSelected = format.value === value; + return ( + + ); + })} +
+
+
+ ); + } + + return ( + + ); +} diff --git a/components/creative-select-dialog.tsx b/components/creative-select-dialog.tsx index 946b9cb..a669493 100644 --- a/components/creative-select-dialog.tsx +++ b/components/creative-select-dialog.tsx @@ -1,24 +1,106 @@ import { useState, useEffect } from "react"; -import { X } from "lucide-react"; +import { X, Image, MousePointerClick } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import type { Creative } from "@/lib/types/api"; -interface Creative { - id: string; - name: string; - thumbnail_url?: string | null; +interface CreativeMediaItem { + media_type: string; + media_file_id: string; + position: number; + s3_url?: string | null; +} + +interface CreativeButton { + text: string; + url: string; } interface CreativeSelectDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - creatives: Creative[]; - onSelect: (creative: Creative) => void; + creatives: { + id: string; + name: string; + text: string; + media_items: CreativeMediaItem[]; + buttons: CreativeButton[]; + }[]; + onSelect: (creative: { id: string; name: string; thumbnail_url?: string | null }) => void; onClear: (() => void) | null; currentCreativeId: string | null | undefined; } +function CreativeCard({ + creative, + isSelected, + onClick, +}: { + creative: { + id: string; + name: string; + text: string; + media_items: CreativeMediaItem[]; + buttons: CreativeButton[]; + }; + isSelected: boolean; + onClick: () => void; +}) { + const thumbnailUrl = creative.media_items?.[0]?.s3_url; + const imagesCount = creative.media_items?.length || 0; + const buttonsCount = creative.buttons?.length || 0; + + const cleanText = creative.text?.replace(/<[^>]+>/g, '') || ''; + const truncatedText = cleanText.substring(0, 80) + (cleanText.length > 80 ? '...' : ''); + + return ( +
+ + {thumbnailUrl ? ( +
+ +
+ ) : ( + <> + )} + +
+
+ {imagesCount > 0 && ( + + + {imagesCount} + + )} + {buttonsCount > 0 && ( + + + {buttonsCount} + + )} +
+ +

+ {truncatedText || 'Без текста'} +

+
+
+ ); +} + export function CreativeSelectDialog({ open, onOpenChange, @@ -29,48 +111,40 @@ export function CreativeSelectDialog({ }: CreativeSelectDialogProps) { return ( - - + + Выберите креатив +

+ {creatives.length} креативов доступно +

+ {creatives.length === 0 ? (
Нет доступных креативов
) : ( -
+
{creatives.map((creative) => ( -
{ - onSelect(creative); + onSelect({ + id: creative.id, + name: creative.name, + thumbnail_url: creative.media_items?.[0]?.s3_url, + }); onOpenChange(false); }} - > -
- {creative.thumbnail_url ? ( - {creative.name} - ) : ( - 📷 - )} -
- {creative.name} -
+ /> ))}
)} + {currentCreativeId && onClear && ( -
+
+ ); +} + +export function FormatSelector({ + value, + isEditing, + canEdit, + onEdit, + onChange, + onCloseEdit, +}: FormatSelectorProps) { + const [showCustom, setShowCustom] = useState(false); + const [customTop, setCustomTop] = useState(""); + const [customFeedText, setCustomFeedText] = useState(""); + + const parsed = parseFormat(value); + const matchedOption = parsed ? findMatchingOption(parsed.topHours, parsed.feedHours) : null; + + const handleSelectPreset = (option: FormatOption) => { + const formatStr = formatToString(option.topHours, option.feedHours); + onChange(formatStr); + onCloseEdit(); + }; + + const handleSelectCustom = () => { + const top = parseInt(customTop, 10); + let feed: number | null = null; + + if (customFeedText.toLowerCase().includes('без')) { + feed = null; + } else if (customFeedText.includes('дней')) { + const daysMatch = customFeedText.match(/(\d+)/); + if (daysMatch) { + feed = parseInt(daysMatch[1], 10) * 24; + } + } else if (customFeedText) { + feed = parseInt(customFeedText, 10); + } + + if (!isNaN(top) && feed !== null && !isNaN(feed)) { + const formatStr = formatToString(top, feed); + onChange(formatStr); + onCloseEdit(); + } + }; + + if (isEditing) { + return ( + !open && onCloseEdit()}> + + + + +
+
+ Стандартные форматы +
+ {FORMAT_OPTIONS.map((option) => ( + handleSelectPreset(option)} + /> + ))} + +
+ + {!showCustom ? ( + + ) : ( +
+
+ Свой формат +
+
+ setCustomTop(e.target.value)} + /> + / + setCustomFeedText(e.target.value)} + /> +
+
+ + +
+
+ )} +
+ + + ); + } + + return ( + + ); +} diff --git a/components/layout/dashboard-layout-content.tsx b/components/layout/dashboard-layout-content.tsx index d46f16a..2dba183 100644 --- a/components/layout/dashboard-layout-content.tsx +++ b/components/layout/dashboard-layout-content.tsx @@ -1,9 +1,5 @@ "use client"; -// ============================================================================ -// Dashboard Layout Content -// ============================================================================ - import { useWorkspace } from "@/components/providers/workspace-provider"; import { DemoBanner } from "@/components/demo-banner"; @@ -17,9 +13,9 @@ export const DashboardLayoutContent: React.FC = ({ const { isDemoMode } = useWorkspace(); return ( -
+
{isDemoMode && } -
{children}
+
{children}
); }; diff --git a/components/nav-main.tsx b/components/nav-main.tsx index c8e146d..bb9bfab 100644 --- a/components/nav-main.tsx +++ b/components/nav-main.tsx @@ -92,7 +92,7 @@ export function NavMain({ className={cn( "h-4 w-4 transition-colors duration-200", shouldHighlightButton - ? "text-[hsl(262_83%_65%)] drop-shadow-[0_0_8px_rgba(147_51_234_0.25)]" + ? "text-[hsl(262_83%_65%)] drop-shadow-[0_0_15px_rgba(200_180_255_0.9)]" : "text-muted-foreground/90" )} /> @@ -130,7 +130,7 @@ export function NavMain({ className={cn( "h-4 w-4 transition-colors duration-200", isSubActive - ? "!text-[hsl(262_83%_65%)] drop-shadow-[0_0_8px_rgba(147_51_234_0.25)]" + ? "!text-[hsl(262_83%_65%)] drop-shadow-[0_0_15px_rgba(200_180_255_0.9)]" : "!text-muted-foreground/90" )} /> @@ -174,7 +174,7 @@ export function NavMain({ className={cn( "h-4 w-4 transition-colors duration-200", shouldHighlightButton - ? "text-[hsl(262_83%_65%)] drop-shadow-[0_0_8px_rgba(147_51_234_0.25)]" + ? "text-[hsl(262_83%_65%)] drop-shadow-[0_0_15px_rgba(200_180_255_0.9)]" : "text-muted-foreground/90" )} /> @@ -209,16 +209,16 @@ export function NavMain({ isActive={isSubActive} > - {subItem.icon && ( - - )} + {subItem.icon && ( + + )} diff --git a/components/placements-table-header.tsx b/components/placements-table-header.tsx index 38c3de7..d1db39e 100644 --- a/components/placements-table-header.tsx +++ b/components/placements-table-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { ALL_COLUMNS, getColumnStyle, type ColumnConfig } from "@/lib/config/placement-columns"; @@ -84,38 +84,17 @@ export function PlacementsTableHeader({ onClick={() => handleSort(column)} title={column.sortable ? "Нажмите для сортировки" : undefined} > - {column.editable && ( - - - ( - - - - - ) - - - )} {column.label} {column.sortable && ( - + sortDir ? ( + sortDir === "asc" ? ( + + ) : ( + + ) + ) : ( + + ) )}
); diff --git a/lib/types/api.ts b/lib/types/api.ts index 8a9fdcf..be7e193 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -221,6 +221,7 @@ export interface CreativeMediaItem { media_type: string; media_file_id: string; position: number; + s3_url?: string | null; } export interface CreativeButton {