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