feat: placements update
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
// 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 Link from "next/link";
|
||||
import {
|
||||
@@ -65,6 +65,25 @@ import type {
|
||||
PlacementStatus,
|
||||
} 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
|
||||
// ============================================================================
|
||||
@@ -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[] = [
|
||||
"Без статуса",
|
||||
"Написать",
|
||||
@@ -141,9 +177,23 @@ interface ChannelGroupProps {
|
||||
channel: Placement["channel"];
|
||||
placements: PlacementWithStats[];
|
||||
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 totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0);
|
||||
@@ -241,11 +291,35 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps)
|
||||
</thead>
|
||||
<tbody>
|
||||
{placements.map((placement, idx) => {
|
||||
const cpf = placement.placement_post?.subscriptions_count
|
||||
? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count
|
||||
const viewPlacement = getPlacementView(placement);
|
||||
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;
|
||||
const cpm = placement.placement_post?.views_count
|
||||
? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000
|
||||
const cpm = viewPlacement.placement_post?.views_count
|
||||
? (effectiveCost / viewPlacement.placement_post.views_count) * 1000
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -257,24 +331,122 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps)
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border",
|
||||
getStatusColor(placement.status)
|
||||
)}>
|
||||
{placement.status}
|
||||
</span>
|
||||
{isEditingStatus ? (
|
||||
<select
|
||||
aria-label="Статус размещения"
|
||||
className={cn(
|
||||
"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",
|
||||
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 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 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 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 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 className="py-2 px-3 text-right font-medium">
|
||||
{formatCurrency(cpf)}
|
||||
@@ -283,10 +455,62 @@ function ChannelGroup({ channel, placements, channelNumber }: ChannelGroupProps)
|
||||
{formatCurrency(cpm)}
|
||||
</td>
|
||||
<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 className="py-2 px-3 text-muted-foreground max-w-[150px] truncate">
|
||||
{placement.comment || "—"}
|
||||
<td className="py-2 px-3 text-muted-foreground">
|
||||
{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>
|
||||
</tr>
|
||||
);
|
||||
@@ -314,6 +538,10 @@ export default function PurchasePlanDetailPage() {
|
||||
const [placements, setPlacements] = useState<Placement[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 [statusFilter, setStatusFilter] = useState("all");
|
||||
@@ -356,6 +584,8 @@ export default function PurchasePlanDetailPage() {
|
||||
const api = isDemoMode ? demoAwarePlacementsApi : placementsApi;
|
||||
const data = await api.getByProject(workspaceId, projectId);
|
||||
setPlacements(data);
|
||||
setDraftsByPlacementId({});
|
||||
setSaveError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to load placements:", err);
|
||||
} 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 groups: Record<string, { channel: Placement["channel"]; placements: PlacementWithStats[] }> = {};
|
||||
|
||||
@@ -646,12 +977,53 @@ export default function PurchasePlanDetailPage() {
|
||||
channel={group.channel}
|
||||
placements={group.placements}
|
||||
channelNumber={idx + 1}
|
||||
canEdit={canEditPlacements}
|
||||
getPlacementView={getPlacementView}
|
||||
handleDraftChange={handleDraftChange}
|
||||
editingCell={editingCell}
|
||||
setEditingCell={setEditingCell}
|
||||
/>
|
||||
))}
|
||||
</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
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
|
||||
Reference in New Issue
Block a user