feat: placements update
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
// Projects Page (Target Channels) - Enhanced with Statistics
|
// Projects Page (Target Channels) - Enhanced with Statistics
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -166,6 +166,26 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
const { isDemoMode } = useWorkspace();
|
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) => {
|
const handleOpenSettings = (project: Project) => {
|
||||||
setSettingsProject(project);
|
setSettingsProject(project);
|
||||||
setInviteLinkType(project.purchase_invite_type_default || "public");
|
setInviteLinkType(project.purchase_invite_type_default || "public");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// Placements Detail Page - All placements grouped by channel
|
// 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 { useParams, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +65,25 @@ import type {
|
|||||||
PlacementStatus,
|
PlacementStatus,
|
||||||
} from "@/lib/types/api";
|
} 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
|
// 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[] = [
|
const PLACEMENT_STATUSES: PlacementStatus[] = [
|
||||||
"Без статуса",
|
"Без статуса",
|
||||||
"Написать",
|
"Написать",
|
||||||
@@ -141,9 +177,23 @@ interface ChannelGroupProps {
|
|||||||
channel: Placement["channel"];
|
channel: Placement["channel"];
|
||||||
placements: PlacementWithStats[];
|
placements: PlacementWithStats[];
|
||||||
channelNumber: number;
|
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 [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
|
const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
|
||||||
@@ -241,11 +291,35 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps)
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{placements.map((placement, idx) => {
|
{placements.map((placement, idx) => {
|
||||||
const cpf = placement.placement_post?.subscriptions_count
|
const viewPlacement = getPlacementView(placement);
|
||||||
? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count
|
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;
|
: null;
|
||||||
const cpm = placement.placement_post?.views_count
|
const cpm = viewPlacement.placement_post?.views_count
|
||||||
? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000
|
? (effectiveCost / viewPlacement.placement_post.views_count) * 1000
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -257,24 +331,122 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps)
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<span className={cn(
|
{isEditingStatus ? (
|
||||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
|
<select
|
||||||
getStatusColor(placement.status)
|
aria-label="Статус размещения"
|
||||||
)}>
|
className={cn(
|
||||||
{placement.status}
|
"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>
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-muted-foreground">
|
<td className="py-2 px-3 text-muted-foreground">
|
||||||
{formatDate(placement.details?.placement_at || null)}
|
{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>
|
||||||
<td className="py-2 px-3 font-medium">
|
<td className="py-2 px-3 font-medium">
|
||||||
{formatCurrency(placement.details?.cost?.value || null)}
|
{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>
|
||||||
<td className="py-2 px-3 text-right text-muted-foreground">
|
<td className="py-2 px-3 text-right text-muted-foreground">
|
||||||
{formatCompact(placement.placement_post?.views_count || null)}
|
{formatCompact(viewPlacement.placement_post?.views_count || null)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-right text-muted-foreground">
|
<td className="py-2 px-3 text-right text-muted-foreground">
|
||||||
{formatNumber(placement.placement_post?.subscriptions_count || null)}
|
{formatNumber(viewPlacement.placement_post?.subscriptions_count || null)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-right font-medium">
|
<td className="py-2 px-3 text-right font-medium">
|
||||||
{formatCurrency(cpf)}
|
{formatCurrency(cpf)}
|
||||||
@@ -283,10 +455,62 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps)
|
|||||||
{formatCurrency(cpm)}
|
{formatCurrency(cpm)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-muted-foreground">
|
<td className="py-2 px-3 text-muted-foreground">
|
||||||
{placement.details?.format || "—"}
|
{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>
|
||||||
<td className="py-2 px-3 text-muted-foreground max-w-[150px] truncate">
|
<td className="py-2 px-3 text-muted-foreground">
|
||||||
{placement.comment || "—"}
|
{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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -314,6 +538,10 @@ export default function PurchasePlanDetailPage() {
|
|||||||
const [placements, setPlacements] = useState<Placement[]>([]);
|
const [placements, setPlacements] = useState<Placement[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
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);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
@@ -356,6 +584,8 @@ export default function PurchasePlanDetailPage() {
|
|||||||
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
|
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
|
||||||
const data = await api.getByProject(workspaceId, projectId);
|
const data = await api.getByProject(workspaceId, projectId);
|
||||||
setPlacements(data);
|
setPlacements(data);
|
||||||
|
setDraftsByPlacementId({});
|
||||||
|
setSaveError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load placements:", err);
|
console.error("Failed to load placements:", err);
|
||||||
} finally {
|
} 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 groupedPlacements = useMemo(() => {
|
||||||
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
|
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
|
||||||
|
|
||||||
@@ -646,12 +977,53 @@ export default function PurchasePlanDetailPage() {
|
|||||||
channel={group.channel}
|
channel={group.channel}
|
||||||
placements={group.placements}
|
placements={group.placements}
|
||||||
channelNumber={idx + 1}
|
channelNumber={idx + 1}
|
||||||
|
canEdit={canEditPlacements}
|
||||||
|
getPlacementView={getPlacementView}
|
||||||
|
handleDraftChange={handleDraftChange}
|
||||||
|
editingCell={editingCell}
|
||||||
|
setEditingCell={setEditingCell}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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
|
<FiltersModal
|
||||||
open={filtersOpen}
|
open={filtersOpen}
|
||||||
onOpenChange={setFiltersOpen}
|
onOpenChange={setFiltersOpen}
|
||||||
|
|||||||
@@ -78,14 +78,12 @@ export function AddChannelDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Find channel by username to get channel_id
|
// Step 1: Create channel via API to get channel_id
|
||||||
const channelsResponse = await channelsApi.list({ username });
|
const createResponse = await channelsApi.create([{ username }]);
|
||||||
const channel = channelsResponse.items.find(
|
const channelId = createResponse.results[0]?.channel.id;
|
||||||
(c) => c.username?.toLowerCase() === username.toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!channel) {
|
if (!channelId) {
|
||||||
setError("Канал не найден в каталоге");
|
setError("Не удалось получить ID канала");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ export function AddChannelDialog({
|
|||||||
await placementsApi.createBulk(workspaceId, projectId, {
|
await placementsApi.createBulk(workspaceId, projectId, {
|
||||||
channels: [
|
channels: [
|
||||||
{
|
{
|
||||||
channel_id: channel.id,
|
channel_id: channelId,
|
||||||
status: "Без статуса",
|
status: "Без статуса",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export function CreatePlacementsDialog({
|
|||||||
.filter(Boolean)
|
.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) => {
|
const handleOpenChange = (isOpen: boolean) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
resetForm();
|
resetForm();
|
||||||
@@ -116,33 +123,43 @@ export function CreatePlacementsDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lowerUsername = username.toLowerCase();
|
const lowerUsername = username.toLowerCase();
|
||||||
if (existingUsernames.has(lowerUsername)) {
|
|
||||||
setError("Этот канал уже добавлен в проект");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) {
|
if (selectedChannels.some((c) => c.username.toLowerCase() === lowerUsername)) {
|
||||||
setError("Канал уже добавлен в список");
|
setError("Канал уже добавлен в список");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up channel by username to get channel_id
|
// If channel already has placements in this project, reuse its channel_id.
|
||||||
try {
|
// Channels are global, so we don't need to create them again - we just need
|
||||||
const channelsResponse = await channelsApi.list({ username });
|
// the channel_id to create a new placement for this channel.
|
||||||
const channel = channelsResponse.items.find(
|
if (existingUsernames.has(lowerUsername)) {
|
||||||
(c) => c.username?.toLowerCase() === lowerUsername
|
const existingChannelId = getExistingChannelIdByUsername(lowerUsername);
|
||||||
);
|
if (!existingChannelId) {
|
||||||
|
setError("Не удалось найти ID канала в проекте");
|
||||||
if (!channel) {
|
return;
|
||||||
setError("Канал не найден в каталоге");
|
}
|
||||||
|
setSelectedChannels([...selectedChannels, { username, channelId: existingChannelId }]);
|
||||||
|
setNewChannelInput("");
|
||||||
|
setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedChannels([...selectedChannels, { username, channelId: channel.id }]);
|
// 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 }]);
|
||||||
setNewChannelInput("");
|
setNewChannelInput("");
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to look up channel:", err);
|
console.error("Failed to create channel:", err);
|
||||||
setError("Не удалось найти канал");
|
setError("Не удалось создать канал");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,32 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
|
interface CreateChannelInput {
|
||||||
|
username?: string;
|
||||||
|
invite_link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateChannelResult {
|
||||||
|
channel: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateChannelsResponse {
|
||||||
|
results: CreateChannelResult[];
|
||||||
|
}
|
||||||
|
|
||||||
export const channelsApi = {
|
export const channelsApi = {
|
||||||
/**
|
/**
|
||||||
* Get catalog of channels for placements
|
* Get catalog of channels for placements
|
||||||
*/
|
*/
|
||||||
list: (params?: ChannelsQueryParams) =>
|
list: (params?: ChannelsQueryParams) =>
|
||||||
api.get<PaginatedResponse<Channel>>("/channels", { params }),
|
api.get<PaginatedResponse<Channel>>("/channels", { params }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create channels by username or invite_link
|
||||||
|
* Returns channel IDs for use in placement creation
|
||||||
|
*/
|
||||||
|
create: (channels: CreateChannelInput[]) =>
|
||||||
|
api.post<CreateChannelsResponse>("/channels", { channels }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
} from "@/lib/types/api";
|
} 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 = {
|
export const placementsApi = {
|
||||||
/**
|
/**
|
||||||
* Get list of placements for workspace
|
* Get list of placements for workspace
|
||||||
@@ -96,6 +108,21 @@ export const placementsApi = {
|
|||||||
data
|
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<Placement>(
|
||||||
|
`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`,
|
||||||
|
data
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete (archive) placement
|
* Delete (archive) placement
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user