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"; } 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);

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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 },

View File

@@ -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)) {