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>
@@ -1025,8 +1057,12 @@ function ChannelGroup({
? (effectiveCost / viewPlacement.placement_post.views_count) * 1000
: null;
return (
<tr key={placement.id} className="border-b last:border-0 hover:bg-muted/30">
return (
<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>