feat: purchase-plans update
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
import { useWorkspace } from "@/components/providers/workspace-provider";
|
import { useWorkspace } from "@/components/providers/workspace-provider";
|
||||||
import { placementsApi } from "@/lib/api";
|
import { placementsApi, projectsApi } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -94,13 +94,35 @@ export default function PlacementDetailPage() {
|
|||||||
const loadPlacement = async () => {
|
const loadPlacement = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// First, try to load placement without project_id (deprecated endpoint)
|
|
||||||
// If that fails, we'd need projectId
|
// First, get all projects to find which project this placement belongs to
|
||||||
const data = await placementsApi.get(workspaceId, placementId);
|
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
|
||||||
setPlacement(data);
|
const projects = projectsResponse.items;
|
||||||
// Store projectId from placement for future operations
|
|
||||||
if (data.project?.id) {
|
// Search for the placement in all projects
|
||||||
setProjectId(data.project.id);
|
let foundProjectId: string | null = null;
|
||||||
|
let foundPlacement: any = null;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
try {
|
||||||
|
const placements = await placementsApi.getByProject(workspaceId, project.id);
|
||||||
|
const placement = placements.find((p) => p.id === placementId);
|
||||||
|
if (placement) {
|
||||||
|
foundProjectId = project.id;
|
||||||
|
foundPlacement = placement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Skip if can't get placements for this project
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundPlacement) {
|
||||||
|
setPlacement(foundPlacement);
|
||||||
|
setProjectId(foundProjectId);
|
||||||
|
} else {
|
||||||
|
console.error("Placement not found in any project");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load placement:", err);
|
console.error("Failed to load placement:", err);
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ interface ChannelGroupProps {
|
|||||||
onOpenCreativeSelect?: (placement: PlacementWithStats) => void;
|
onOpenCreativeSelect?: (placement: PlacementWithStats) => void;
|
||||||
onClearCreative?: (placementId: string) => void;
|
onClearCreative?: (placementId: string) => void;
|
||||||
onOpenCreativeSend?: (placement: PlacementWithStats) => void;
|
onOpenCreativeSend?: (placement: PlacementWithStats) => void;
|
||||||
|
onAddPlacement?: (channel: Placement["channel"]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -412,22 +413,22 @@ function CellRenderer({
|
|||||||
);
|
);
|
||||||
|
|
||||||
case "creative":
|
case "creative":
|
||||||
const hasCreative = !!placement.creative_id;
|
const viewCreativeId = placement.creative_id;
|
||||||
|
const hasCreative = !!viewCreativeId;
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<td key={column.id} className="py-2 px-3">
|
||||||
{hasCreative ? (
|
{hasCreative ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded ">
|
||||||
<span className="text-xs">📷</span>
|
<span className="text-xs font-medium truncate max-w-[100px]">{placement.creative_name || "Без названия"}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs truncate max-w-[100px]">{placement.creative_name || "Без названия"}</span>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary"
|
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary rounded"
|
||||||
title="Отправить креатив"
|
title="Отправить готовый креатив"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (placement.creative_id) {
|
if (viewCreativeId) {
|
||||||
onOpenCreativeSend?.(placement);
|
onOpenCreativeSend?.(placement);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -437,10 +438,10 @@ function CellRenderer({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive rounded"
|
||||||
title="Очистить креатив"
|
title="Очистить креатив"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (placement.creative_id) {
|
if (viewCreativeId) {
|
||||||
onClearCreative?.(placement.id);
|
onClearCreative?.(placement.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -469,7 +470,7 @@ function CellRenderer({
|
|||||||
return (
|
return (
|
||||||
<td key={column.id} className="py-2 px-3">
|
<td key={column.id} className="py-2 px-3">
|
||||||
{placement.invite_link ? (
|
{placement.invite_link ? (
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[200px]">
|
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">
|
||||||
{placement.invite_link}
|
{placement.invite_link}
|
||||||
</code>
|
</code>
|
||||||
) : (
|
) : (
|
||||||
@@ -905,6 +906,7 @@ function ChannelGroup({
|
|||||||
onOpenCreativeSelect,
|
onOpenCreativeSelect,
|
||||||
onClearCreative,
|
onClearCreative,
|
||||||
onOpenCreativeSend,
|
onOpenCreativeSend,
|
||||||
|
onAddPlacement,
|
||||||
}: ChannelGroupProps) {
|
}: ChannelGroupProps) {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
|
||||||
@@ -993,6 +995,19 @@ function ChannelGroup({
|
|||||||
{formatCompact(totalViews)}
|
{formatCompact(totalViews)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs ml-4"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddPlacement?.(channel);
|
||||||
|
}}
|
||||||
|
title="Добавить размещение"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
@@ -1005,10 +1020,27 @@ function ChannelGroup({
|
|||||||
key={column.id}
|
key={column.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"py-2 px-3 font-medium text-muted-foreground",
|
"py-2 px-3 font-medium text-muted-foreground",
|
||||||
|
column.editable && "cursor-help",
|
||||||
column.width || "w-auto"
|
column.width || "w-auto"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{column.label}
|
<div className="flex items-center gap-1.5">
|
||||||
|
{column.editable && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-0.5"
|
||||||
|
title={column.partialEdit ? "Частично редактируемое" : "Редактируемое"}
|
||||||
|
>
|
||||||
|
<p className="flex items-center whitespace-nowrap">
|
||||||
|
{column.partialEdit ? (
|
||||||
|
<span className="text-[10px] opacity-40 flex items-center gap-0.5">(
|
||||||
|
<Pencil className="h-3 w-3 " />)</span>
|
||||||
|
) : (
|
||||||
|
<Pencil className="h-3 w-3 opacity-40" />)}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1026,7 +1058,11 @@ function ChannelGroup({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={placement.id} className="border-b last:border-0 hover:bg-muted/30">
|
<tr
|
||||||
|
key={placement.id}
|
||||||
|
data-placement-id={placement.id}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30"
|
||||||
|
>
|
||||||
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
|
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
|
||||||
<CellRenderer
|
<CellRenderer
|
||||||
key={column.id}
|
key={column.id}
|
||||||
@@ -1335,6 +1371,7 @@ export default function PurchasePlanDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await loadPlacements();
|
await loadPlacements();
|
||||||
|
setSavingEdits(false);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("Failed to save placement edits:", err);
|
console.error("Failed to save placement edits:", err);
|
||||||
const message =
|
const message =
|
||||||
@@ -1422,6 +1459,41 @@ export default function PurchasePlanDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddPlacement = async (channel: Placement["channel"]) => {
|
||||||
|
if (!canEditPlacements) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await placementsApi.createInProject(workspaceId, projectId, {
|
||||||
|
creative_id: undefined,
|
||||||
|
channels: [{
|
||||||
|
channel_id: channel.id,
|
||||||
|
status: "Без статуса",
|
||||||
|
comment: undefined,
|
||||||
|
details: {},
|
||||||
|
}],
|
||||||
|
details: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract the new placement from the response
|
||||||
|
const newPlacement = result.placements[0];
|
||||||
|
|
||||||
|
// Add the new placement to the list and switch to edit mode
|
||||||
|
setPlacements((prev) => [...prev, newPlacement]);
|
||||||
|
|
||||||
|
// Scroll to and focus the new placement after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`);
|
||||||
|
if (rowElement) {
|
||||||
|
rowElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
// Set editing state for the first editable field
|
||||||
|
setEditingCell({ placementId: newPlacement.id, field: "status" });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to add placement:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const groupedPlacements = useMemo(() => {
|
const groupedPlacements = useMemo(() => {
|
||||||
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
|
const groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
|
||||||
|
|
||||||
@@ -1793,6 +1865,7 @@ export default function PurchasePlanDetailPage() {
|
|||||||
onOpenCreativeSelect={handleOpenCreativeSelect}
|
onOpenCreativeSelect={handleOpenCreativeSelect}
|
||||||
onClearCreative={handleClearCreative}
|
onClearCreative={handleClearCreative}
|
||||||
onOpenCreativeSend={handleOpenCreativeSend}
|
onOpenCreativeSend={handleOpenCreativeSend}
|
||||||
|
onAddPlacement={handleAddPlacement}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const InviteLinkCell = ({ inviteLink, onCopy }: { inviteLink: string | nu
|
|||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
{inviteLink ? (
|
{inviteLink ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded truncate max-w-[200px]">{inviteLink}</code>
|
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">{inviteLink}</code>
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onCopy}>
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onCopy}>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -33,10 +33,17 @@ export const placementsApi = {
|
|||||||
.then((response) => response.items),
|
.then((response) => response.items),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get single placement (deprecated - use getInProject for project-specific data)
|
* Get single placement (requires project_id in original backend)
|
||||||
|
* Uses: GET /workspaces/{workspace_id}/projects/{project_id}/placements/{placement_id}
|
||||||
*/
|
*/
|
||||||
get: (workspaceId: string, placementId: string) =>
|
get: (
|
||||||
api.get<Placement>(`/workspaces/${workspaceId}/placements/${placementId}`),
|
workspaceId: string,
|
||||||
|
placementId: string,
|
||||||
|
projectId?: string
|
||||||
|
) =>
|
||||||
|
api.get<Placement>(
|
||||||
|
`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get single placement within project
|
* Get single placement within project
|
||||||
@@ -91,6 +98,47 @@ export const placementsApi = {
|
|||||||
data
|
data
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create single placement within project (bulk format)
|
||||||
|
* POST /workspaces/{workspace_id}/projects/{project_id}/placements
|
||||||
|
*/
|
||||||
|
createInProject: (
|
||||||
|
workspaceId: string,
|
||||||
|
projectId: string,
|
||||||
|
data: {
|
||||||
|
creative_id?: string;
|
||||||
|
channels: Array<{
|
||||||
|
channel_id: string;
|
||||||
|
status?: string;
|
||||||
|
comment?: string;
|
||||||
|
details?: {
|
||||||
|
placement_at?: string;
|
||||||
|
payment_at?: string;
|
||||||
|
cost?: { type: string; value: number };
|
||||||
|
cost_before_bargain?: number;
|
||||||
|
placement_type?: string;
|
||||||
|
format?: string;
|
||||||
|
comment?: string;
|
||||||
|
creative_id?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
details?: {
|
||||||
|
placement_at?: string;
|
||||||
|
payment_at?: string;
|
||||||
|
cost?: { type: string; value: number };
|
||||||
|
cost_before_bargain?: number;
|
||||||
|
placement_type?: string;
|
||||||
|
format?: string;
|
||||||
|
comment?: string;
|
||||||
|
creative_id?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
api.post<{ placements: Placement[] }>(
|
||||||
|
`/workspaces/${workspaceId}/projects/${projectId}/placements`,
|
||||||
|
data
|
||||||
|
),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update placement
|
* Update placement
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface ColumnConfig {
|
|||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
partialEdit?: boolean;
|
||||||
width?: string;
|
width?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}
|
}
|
||||||
@@ -21,10 +22,10 @@ export const COLUMN_GROUPS = [
|
|||||||
|
|
||||||
export const ALL_COLUMNS: ColumnConfig[] = [
|
export const ALL_COLUMNS: ColumnConfig[] = [
|
||||||
// Basic
|
// Basic
|
||||||
{ id: "checkbox", label: "", group: "basic", editable: false, required: true },
|
{ id: "checkbox", label: "Чекбокс", group: "basic", editable: false, required: true },
|
||||||
{ id: "number", label: "№", group: "basic", editable: false, required: true },
|
{ id: "number", label: "Номер", group: "basic", editable: false, required: true },
|
||||||
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true },
|
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true },
|
||||||
{ id: "status_post", label: "Статус поста", group: "basic", editable: true },
|
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true },
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
{ id: "creative", label: "Креатив", group: "content", editable: true },
|
||||||
@@ -32,9 +33,9 @@ export const ALL_COLUMNS: ColumnConfig[] = [
|
|||||||
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
|
||||||
|
|
||||||
// Finance
|
// Finance
|
||||||
{ id: "cost", label: "Стоимость", group: "finance", editable: true },
|
{ id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true },
|
||||||
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true },
|
{ id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true },
|
||||||
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true },
|
{ id: "cost_format", label: "Формат оплаты", group: "finance", editable: true, partialEdit: true },
|
||||||
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true },
|
{ id: "placement_type", label: "Тип закупа", group: "finance", editable: true },
|
||||||
{ id: "discount", label: "% скидки", group: "finance", editable: false },
|
{ id: "discount", label: "% скидки", group: "finance", editable: false },
|
||||||
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
|
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
|
||||||
@@ -54,7 +55,7 @@ export const ALL_COLUMNS: ColumnConfig[] = [
|
|||||||
|
|
||||||
// Calculated
|
// Calculated
|
||||||
{ id: "cpf", label: "CPF", group: "calculated", editable: false },
|
{ id: "cpf", label: "CPF", group: "calculated", editable: false },
|
||||||
{ id: "cpm", label: "CPM", group: "calculated", editable: false },
|
{ id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true },
|
||||||
{ id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false },
|
{ id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false },
|
||||||
{ id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false },
|
{ id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false },
|
||||||
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false },
|
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false },
|
||||||
|
|||||||
@@ -272,13 +272,13 @@ export const demoAwarePlacementsApi = {
|
|||||||
}
|
}
|
||||||
return placementsApi.list(workspaceId, params);
|
return placementsApi.list(workspaceId, params);
|
||||||
},
|
},
|
||||||
get: async (workspaceId: string, placementId: string): Promise<Placement> => {
|
get: async (workspaceId: string, placementId: string, _projectId?: string): Promise<Placement> => {
|
||||||
if (isDemoWorkspace(workspaceId)) {
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
const placement = demoPlacements.find((p) => p.id === placementId);
|
const placement = demoPlacements.find((p) => p.id === placementId);
|
||||||
if (!placement) throw new Error("Placement not found");
|
if (!placement) throw new Error("Placement not found");
|
||||||
return placement;
|
return placement;
|
||||||
}
|
}
|
||||||
return placementsApi.get(workspaceId, placementId);
|
return placementsApi.get(workspaceId, placementId, _projectId);
|
||||||
},
|
},
|
||||||
getByProject: async (workspaceId: string, projectId: string): Promise<Placement[]> => {
|
getByProject: async (workspaceId: string, projectId: string): Promise<Placement[]> => {
|
||||||
if (isDemoWorkspace(workspaceId)) {
|
if (isDemoWorkspace(workspaceId)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user