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
*/