feat: purchase-plans update

This commit is contained in:
ivannoskov
2026-02-03 11:14:53 +03:00
parent 6b16bfa6e2
commit d87d6e2568
6 changed files with 179 additions and 35 deletions

View File

@@ -19,7 +19,7 @@ import {
} from "lucide-react";
import { DashboardHeader } from "@/components/layout/dashboard-header";
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 { Badge } from "@/components/ui/badge";
import {
@@ -94,13 +94,35 @@ export default function PlacementDetailPage() {
const loadPlacement = async () => {
try {
setLoading(true);
// First, try to load placement without project_id (deprecated endpoint)
// If that fails, we'd need projectId
const data = await placementsApi.get(workspaceId, placementId);
setPlacement(data);
// Store projectId from placement for future operations
if (data.project?.id) {
setProjectId(data.project.id);
// First, get all projects to find which project this placement belongs to
const projectsResponse = await projectsApi.list(workspaceId, { size: 100 });
const projects = projectsResponse.items;
// Search for the placement in all projects
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) {
console.error("Failed to load placement:", err);

View File

@@ -216,6 +216,7 @@ interface ChannelGroupProps {
onOpenCreativeSelect?: (placement: PlacementWithStats) => void;
onClearCreative?: (placementId: string) => void;
onOpenCreativeSend?: (placement: PlacementWithStats) => void;
onAddPlacement?: (channel: Placement["channel"]) => void;
}
// ============================================================================
@@ -412,22 +413,22 @@ function CellRenderer({
);
case "creative":
const hasCreative = !!placement.creative_id;
const viewCreativeId = placement.creative_id;
const hasCreative = !!viewCreativeId;
return (
<td key={column.id} className="py-2 px-3">
{hasCreative ? (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center overflow-hidden shrink-0">
<span className="text-xs">📷</span>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2 px-2 py-1 bg-muted/50 rounded ">
<span className="text-xs font-medium truncate max-w-[100px]">{placement.creative_name || "Без названия"}</span>
</div>
<span className="text-xs truncate max-w-[100px]">{placement.creative_name || "Без названия"}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary"
title="Отправить креатив"
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary rounded"
title="Отправить готовый креатив"
onClick={() => {
if (placement.creative_id) {
if (viewCreativeId) {
onOpenCreativeSend?.(placement);
}
}}
@@ -437,10 +438,10 @@ function CellRenderer({
<Button
variant="ghost"
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="Очистить креатив"
onClick={() => {
if (placement.creative_id) {
if (viewCreativeId) {
onClearCreative?.(placement.id);
}
}}
@@ -469,7 +470,7 @@ function CellRenderer({
return (
<td key={column.id} className="py-2 px-3">
{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}
</code>
) : (
@@ -905,6 +906,7 @@ function ChannelGroup({
onOpenCreativeSelect,
onClearCreative,
onOpenCreativeSend,
onAddPlacement,
}: ChannelGroupProps) {
const [isOpen, setIsOpen] = useState(true);
@@ -993,6 +995,19 @@ function ChannelGroup({
{formatCompact(totalViews)}
</span>
</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>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -1005,10 +1020,27 @@ function ChannelGroup({
key={column.id}
className={cn(
"py-2 px-3 font-medium text-muted-foreground",
column.editable && "cursor-help",
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>
))}
</tr>
@@ -1026,7 +1058,11 @@ function ChannelGroup({
: null;
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) => (
<CellRenderer
key={column.id}
@@ -1335,6 +1371,7 @@ export default function PurchasePlanDetailPage() {
}
await loadPlacements();
setSavingEdits(false);
} catch (err: unknown) {
console.error("Failed to save placement edits:", err);
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 groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
@@ -1793,6 +1865,7 @@ export default function PurchasePlanDetailPage() {
onOpenCreativeSelect={handleOpenCreativeSelect}
onClearCreative={handleClearCreative}
onOpenCreativeSend={handleOpenCreativeSend}
onAddPlacement={handleAddPlacement}
/>
))}
</div>

View File

@@ -192,7 +192,7 @@ export const InviteLinkCell = ({ inviteLink, onCopy }: { inviteLink: string | nu
<td className="py-2 px-3">
{inviteLink ? (
<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}>
<Copy className="h-3 w-3" />
</Button>

View File

@@ -33,10 +33,17 @@ export const placementsApi = {
.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) =>
api.get<Placement>(`/workspaces/${workspaceId}/placements/${placementId}`),
get: (
workspaceId: string,
placementId: string,
projectId?: string
) =>
api.get<Placement>(
`/workspaces/${workspaceId}/projects/${projectId}/placements/${placementId}`
),
/**
* Get single placement within project
@@ -91,6 +98,47 @@ export const placementsApi = {
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
*/

View File

@@ -5,6 +5,7 @@ export interface ColumnConfig {
label: string;
group: string;
editable: boolean;
partialEdit?: boolean;
width?: string;
required?: boolean;
}
@@ -21,10 +22,10 @@ export const COLUMN_GROUPS = [
export const ALL_COLUMNS: ColumnConfig[] = [
// Basic
{ id: "checkbox", 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_post", label: "Статус поста", group: "basic", editable: true },
{ id: "checkbox", label: "Чекбокс", group: "basic", editable: false, required: true },
{ id: "number", label: "Номер", group: "basic", editable: false, required: true },
{ id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true },
{ id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true },
// Content
{ id: "creative", label: "Креатив", group: "content", editable: true },
@@ -32,9 +33,9 @@ export const ALL_COLUMNS: ColumnConfig[] = [
{ id: "post_link", label: "Ссылка на пост", group: "content", editable: false },
// 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_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: "discount", label: "% скидки", group: "finance", editable: false },
{ id: "payment_date", label: "Дата оплаты", group: "finance", editable: true },
@@ -54,7 +55,7 @@ export const ALL_COLUMNS: ColumnConfig[] = [
// Calculated
{ 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_48h", label: "Конверсия 48ч", group: "calculated", editable: false },
{ id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false },

View File

@@ -272,13 +272,13 @@ export const demoAwarePlacementsApi = {
}
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)) {
const placement = demoPlacements.find((p) => p.id === placementId);
if (!placement) throw new Error("Placement not found");
return placement;
}
return placementsApi.get(workspaceId, placementId);
return placementsApi.get(workspaceId, placementId, _projectId);
},
getByProject: async (workspaceId: string, projectId: string): Promise<Placement[]> => {
if (isDemoWorkspace(workspaceId)) {