feat: placements update

This commit is contained in:
ivannoskov
2026-01-26 12:13:38 +03:00
parent 63b2fd5d77
commit f46a3265c6
6 changed files with 499 additions and 43 deletions

View File

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

View File

@@ -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",
</span> 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>
</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}

View File

@@ -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: "Без статуса",
}, },
], ],

View File

@@ -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 канала в проекте");
return;
}
setSelectedChannels([...selectedChannels, { username, channelId: existingChannelId }]);
setNewChannelInput("");
setError(null);
return;
}
if (!channel) { // Channel doesn't exist in project yet. Create it globally via API to get channel_id.
setError("Канал не найден в каталоге"); // 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; return;
} }
setSelectedChannels([...selectedChannels, { username, channelId: channel.id }]); 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("Не удалось создать канал");
} }
}; };

View File

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

View File

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