diff --git a/app/dashboard/[workspaceId]/projects/page.tsx b/app/dashboard/[workspaceId]/projects/page.tsx index 6612640..764f33c 100644 --- a/app/dashboard/[workspaceId]/projects/page.tsx +++ b/app/dashboard/[workspaceId]/projects/page.tsx @@ -4,7 +4,7 @@ // Projects Page (Target Channels) - Enhanced with Statistics // ============================================================================ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; import { @@ -166,6 +166,26 @@ export default function ProjectsPage() { const { isDemoMode } = useWorkspace(); + // Poll projects list every 5s so newly added projects (via bot) appear automatically. + const refreshProjectsRef = useRef(refreshProjects); + useEffect(() => { + refreshProjectsRef.current = refreshProjects; + }, [refreshProjects]); + + useEffect(() => { + if (!workspaceId) return; + if (isDemoMode) return; + + const intervalId = window.setInterval(() => { + if (typeof document !== "undefined" && document.hidden) return; + void refreshProjectsRef.current(); + }, 5000); + + return () => { + window.clearInterval(intervalId); + }; + }, [workspaceId, isDemoMode]); + const handleOpenSettings = (project: Project) => { setSettingsProject(project); setInviteLinkType(project.purchase_invite_type_default || "public"); diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index 8201b8c..109dee9 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -4,7 +4,7 @@ // Placements Detail Page - All placements grouped by channel // ============================================================================ -import { useEffect, useState, useMemo, useCallback } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import { @@ -65,6 +65,25 @@ import type { 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 // ============================================================================ @@ -101,6 +120,23 @@ const formatDate = (dateStr: string | null): string => { }); }; +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[] = [ "Без статуса", "Написать", @@ -141,9 +177,23 @@ 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 }: ChannelGroupProps) { +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); @@ -241,11 +291,35 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps) {placements.map((placement, idx) => { - const cpf = placement.placement_post?.subscriptions_count - ? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count + 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 = placement.placement_post?.views_count - ? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000 + const cpm = viewPlacement.placement_post?.views_count + ? (effectiveCost / viewPlacement.placement_post.views_count) * 1000 : null; return ( @@ -257,24 +331,122 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps) - - {placement.status} - + {isEditingStatus ? ( + + ) : ( + + )} - {formatDate(placement.details?.placement_at || null)} + {isEditingDate ? ( + + handleDraftChange(placement.id, { + placement_at: toIsoFromDateInput(e.target.value), + }) + } + onBlur={() => setEditingCell(null)} + /> + ) : ( + + )} - {formatCurrency(placement.details?.cost?.value || null)} + {isEditingCost ? ( + { + 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)} + /> + ) : ( + + )} - {formatCompact(placement.placement_post?.views_count || null)} + {formatCompact(viewPlacement.placement_post?.views_count || null)} - {formatNumber(placement.placement_post?.subscriptions_count || null)} + {formatNumber(viewPlacement.placement_post?.subscriptions_count || null)} {formatCurrency(cpf)} @@ -283,10 +455,62 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps) {formatCurrency(cpm)} - {placement.details?.format || "—"} + {isEditingFormat ? ( + + handleDraftChange(placement.id, { + format: e.target.value ? e.target.value : null, + }) + } + onBlur={() => setEditingCell(null)} + /> + ) : ( + + )} - - {placement.comment || "—"} + + {isEditingComment ? ( + + handleDraftChange(placement.id, { + comment: e.target.value ? e.target.value : null, + }) + } + onBlur={() => setEditingCell(null)} + /> + ) : ( + + )} ); @@ -314,6 +538,10 @@ export default function PurchasePlanDetailPage() { const [placements, setPlacements] = useState([]); const [loading, setLoading] = useState(true); const [project, setProject] = useState(null); + const [draftsByPlacementId, setDraftsByPlacementId] = useState>({}); + const [savingEdits, setSavingEdits] = useState(false); + const [saveError, setSaveError] = useState(null); + const [editingCell, setEditingCell] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); @@ -356,6 +584,8 @@ export default function PurchasePlanDetailPage() { 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 { @@ -363,6 +593,107 @@ export default function PurchasePlanDetailPage() { } }; + 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 = {}; @@ -646,12 +977,53 @@ export default function PurchasePlanDetailPage() { channel={group.channel} placements={group.placements} channelNumber={idx + 1} + canEdit={canEditPlacements} + getPlacementView={getPlacementView} + handleDraftChange={handleDraftChange} + editingCell={editingCell} + setEditingCell={setEditingCell} /> ))} )} + {hasUnsavedChanges && ( +
+
+
+ + +
+ {saveError && ( +
+ {saveError} +
+ )} +
+
+ )} + c.username?.toLowerCase() === username.toLowerCase() - ); + // Step 1: Create channel via API to get channel_id + const createResponse = await channelsApi.create([{ username }]); + const channelId = createResponse.results[0]?.channel.id; - if (!channel) { - setError("Канал не найден в каталоге"); + if (!channelId) { + setError("Не удалось получить ID канала"); return; } @@ -93,7 +91,7 @@ export function AddChannelDialog({ await placementsApi.createBulk(workspaceId, projectId, { channels: [ { - channel_id: channel.id, + channel_id: channelId, status: "Без статуса", }, ], diff --git a/components/dialog-create-placements.tsx b/components/dialog-create-placements.tsx index 28dfb1b..807de0c 100644 --- a/components/dialog-create-placements.tsx +++ b/components/dialog-create-placements.tsx @@ -78,6 +78,13 @@ export function CreatePlacementsDialog({ .filter(Boolean) ); + const getExistingChannelIdByUsername = (usernameLower: string): string | null => { + const matched = existingPlacements.find( + (p) => p.channel.username?.toLowerCase() === usernameLower + ); + return matched?.channel.id ?? null; + }; + const handleOpenChange = (isOpen: boolean) => { if (!isOpen) { resetForm(); @@ -116,33 +123,43 @@ export function CreatePlacementsDialog({ } const lowerUsername = username.toLowerCase(); - if (existingUsernames.has(lowerUsername)) { - setError("Этот канал уже добавлен в проект"); - return; - } if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) { setError("Канал уже добавлен в список"); return; } - // Look up channel by username to get channel_id - try { - const channelsResponse = await channelsApi.list({ username }); - const channel = channelsResponse.items.find( - (c) => c.username?.toLowerCase() === lowerUsername - ); + // If channel already has placements in this project, reuse its channel_id. + // Channels are global, so we don't need to create them again - we just need + // the channel_id to create a new placement for this channel. + if (existingUsernames.has(lowerUsername)) { + const existingChannelId = getExistingChannelIdByUsername(lowerUsername); + if (!existingChannelId) { + setError("Не удалось найти ID канала в проекте"); + return; + } + setSelectedChannels([...selectedChannels, { username, channelId: existingChannelId }]); + setNewChannelInput(""); + setError(null); + return; + } - if (!channel) { - setError("Канал не найден в каталоге"); + // Channel doesn't exist in project yet. Create it globally via API to get channel_id. + // If channel already exists globally, API will return existing channel. + try { + const createResponse = await channelsApi.create([{ username }]); + const channelId = createResponse.results[0]?.channel.id; + + if (!channelId) { + setError("Не удалось получить ID канала"); return; } - setSelectedChannels([...selectedChannels, { username, channelId: channel.id }]); + setSelectedChannels([...selectedChannels, { username, channelId }]); setNewChannelInput(""); setError(null); } catch (err) { - console.error("Failed to look up channel:", err); - setError("Не удалось найти канал"); + console.error("Failed to create channel:", err); + setError("Не удалось создать канал"); } }; diff --git a/lib/api/channels.ts b/lib/api/channels.ts index 74d3660..d4a2931 100644 --- a/lib/api/channels.ts +++ b/lib/api/channels.ts @@ -9,10 +9,32 @@ import type { PaginatedResponse, } from "@/lib/types/api"; +interface CreateChannelInput { + username?: string; + invite_link?: string; +} + +interface CreateChannelResult { + channel: { + id: string; + }; +} + +interface CreateChannelsResponse { + results: CreateChannelResult[]; +} + export const channelsApi = { /** * Get catalog of channels for placements */ list: (params?: ChannelsQueryParams) => api.get>("/channels", { params }), + + /** + * Create channels by username or invite_link + * Returns channel IDs for use in placement creation + */ + create: (channels: CreateChannelInput[]) => + api.post("/channels", { channels }), }; diff --git a/lib/api/placements.ts b/lib/api/placements.ts index d9dcc8d..fed707c 100644 --- a/lib/api/placements.ts +++ b/lib/api/placements.ts @@ -14,6 +14,18 @@ import type { PaginatedResponse, } from "@/lib/types/api"; +type UpdatePlacementInProjectRequest = Partial<{ + status: string; + 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; +}>; + export const placementsApi = { /** * Get list of placements for workspace @@ -96,6 +108,21 @@ export const placementsApi = { data ), + /** + * Update placement within project (Purchase Plans UI) + * PATCH /workspaces/{workspace_id}/projects/{project_id}/placements/{placement_id} + */ + updateInProject: ( + workspaceId: string, + projectId: string, + placementId: string, + data: UpdatePlacementInProjectRequest + ) => + api.patch( + `/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`, + data + ), + /** * Delete (archive) placement */