feat: global ui & functional update

This commit is contained in:
ivannoskov
2026-01-21 22:43:54 +03:00
parent 8958d89d12
commit 619fd5c1ef
32 changed files with 3803 additions and 1325 deletions

View File

@@ -157,17 +157,17 @@ const exportToCSV = (placements: Placement[], filename: string) => {
];
const rows = placements.map((p) => [
p.placement_channel_title,
p.channel.title,
"",
p.creative_name,
new Date(p.placement_date).toISOString().split("T")[0],
p.cost?.toString() || "",
p.subscriptions_count.toString(),
p.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
"—", // creative_name not available in new structure
p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "",
p.details?.cost?.value?.toString() || "",
p.placement_post?.subscriptions_count.toString() || "0",
p.placement_post?.views_count?.toString() || "",
calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "",
calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "",
p.status,
p.ad_post_url || "",
p.placement_post?.post?.url || "",
p.invite_link,
]);
@@ -192,15 +192,15 @@ const exportToExcel = (placements: Placement[], filename: string) => {
];
const rows = placements.map((p) => [
p.placement_channel_title,
p.channel.title,
"",
p.creative_name,
new Date(p.placement_date).toISOString().split("T")[0],
p.cost?.toString() || "",
p.subscriptions_count.toString(),
p.views_count?.toString() || "",
calculateCPS(p.cost, p.subscriptions_count)?.toFixed(2) || "",
calculateCPM(p.cost, p.views_count)?.toFixed(2) || "",
"—",
p.details?.placement_at ? new Date(p.details.placement_at).toISOString().split("T")[0] : "",
p.details?.cost?.value?.toString() || "",
p.placement_post?.subscriptions_count.toString() || "0",
p.placement_post?.views_count?.toString() || "",
calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0)?.toFixed(2) || "",
calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null)?.toFixed(2) || "",
p.status,
]);
@@ -213,18 +213,21 @@ const exportToExcel = (placements: Placement[], filename: string) => {
const exportToTxt = (placements: Placement[], filename: string) => {
const lines = placements.map((p) => {
const cps = calculateCPS(p.cost, p.subscriptions_count);
const cpm = calculateCPM(p.cost, p.views_count);
const cost = p.details?.cost?.value ?? null;
const subscriptions = p.placement_post?.subscriptions_count ?? 0;
const views = p.placement_post?.views_count ?? null;
const cps = calculateCPS(cost, subscriptions);
const cpm = calculateCPM(cost, views);
return [
`Канал: ${p.placement_channel_title}`,
`Креатив: ${p.creative_name}`,
`Дата: ${formatDate(p.placement_date)}`,
`Стоимость: ${formatCurrency(p.cost)}`,
`Подписки: ${p.subscriptions_count}`,
`Просмотры: ${p.views_count || "—"}`,
`Канал: ${p.channel.title}`,
`Креатив: `,
`Дата: ${p.details?.placement_at ? formatDate(p.details.placement_at) : "—"}`,
`Стоимость: ${formatCurrency(cost)}`,
`Подписки: ${p.placement_post?.subscriptions_count ?? "—"}`,
`Просмотры: ${p.placement_post?.views_count || "—"}`,
`CPS: ${cps ? formatCurrency(cps) : "—"}`,
`CPM: ${cpm ? formatCurrency(cpm) : "—"}`,
`Ссылка: ${p.invite_link}`,
`Ссылка: ${p.invite_link || "—"}`,
"---",
].join("\n");
});
@@ -320,8 +323,8 @@ export default function PlacementsPage() {
return placements
.filter(
(p) =>
p.placement_channel_title.toLowerCase().includes(search.toLowerCase()) ||
p.creative_name.toLowerCase().includes(search.toLowerCase())
p.channel.title.toLowerCase().includes(search.toLowerCase()) ||
(p.creative_id && p.creative_id.toString().toLowerCase().includes(search.toLowerCase()))
)
.sort((a, b) => {
if (!sortField || !sortOrder) return 0;
@@ -329,30 +332,39 @@ export default function PlacementsPage() {
let aVal: number | string = 0;
let bVal: number | string = 0;
const aCost = a.details?.cost?.value ?? null;
const bCost = b.details?.cost?.value ?? null;
const aSubs = a.placement_post?.subscriptions_count ?? 0;
const bSubs = b.placement_post?.subscriptions_count ?? 0;
const aViews = a.placement_post?.views_count ?? null;
const bViews = b.placement_post?.views_count ?? null;
switch (sortField) {
case "date":
const aDate = a.details?.placement_at || "";
const bDate = b.details?.placement_at || "";
return sortOrder === "asc"
? new Date(a.placement_date).getTime() - new Date(b.placement_date).getTime()
: new Date(b.placement_date).getTime() - new Date(a.placement_date).getTime();
? aDate.localeCompare(bDate)
: bDate.localeCompare(aDate);
case "cost":
aVal = a.cost ?? 0;
bVal = b.cost ?? 0;
aVal = aCost ?? 0;
bVal = bCost ?? 0;
break;
case "subscriptions":
aVal = a.subscriptions_count;
bVal = b.subscriptions_count;
aVal = aSubs;
bVal = bSubs;
break;
case "views":
aVal = a.views_count ?? 0;
bVal = b.views_count ?? 0;
aVal = aViews ?? 0;
bVal = bViews ?? 0;
break;
case "cps":
aVal = calculateCPS(a.cost, a.subscriptions_count) ?? 0;
bVal = calculateCPS(b.cost, b.subscriptions_count) ?? 0;
aVal = calculateCPS(aCost, aSubs) ?? 0;
bVal = calculateCPS(bCost, bSubs) ?? 0;
break;
case "cpm":
aVal = calculateCPM(a.cost, a.views_count) ?? 0;
bVal = calculateCPM(b.cost, b.views_count) ?? 0;
aVal = calculateCPM(aCost, aViews) ?? 0;
bVal = calculateCPM(bCost, bViews) ?? 0;
break;
case "status":
aVal = a.status;
@@ -370,14 +382,14 @@ export default function PlacementsPage() {
// Calculate totals with aggregation functions
const totals = useMemo(() => {
const costs = filteredPlacements.map((p) => p.cost).filter((c): c is number => c !== null);
const subs = filteredPlacements.map((p) => p.subscriptions_count);
const views = filteredPlacements.map((p) => p.views_count).filter((v): v is number => v !== null);
const costs = filteredPlacements.map((p) => p.details?.cost?.value).filter((c): c is number => c !== null);
const subs = filteredPlacements.map((p) => p.placement_post?.subscriptions_count ?? 0);
const views = filteredPlacements.map((p) => p.placement_post?.views_count).filter((v): v is number => v !== null);
const cpsValues = filteredPlacements
.map((p) => calculateCPS(p.cost, p.subscriptions_count))
.map((p) => calculateCPS(p.details?.cost?.value ?? null, p.placement_post?.subscriptions_count ?? 0))
.filter((c): c is number => c !== null);
const cpmValues = filteredPlacements
.map((p) => calculateCPM(p.cost, p.views_count))
.map((p) => calculateCPM(p.details?.cost?.value ?? null, p.placement_post?.views_count ?? null))
.filter((c): c is number => c !== null);
return {
@@ -639,28 +651,31 @@ export default function PlacementsPage() {
</TableHeader>
<TableBody>
{filteredPlacements.map((placement) => {
const cps = calculateCPS(placement.cost, placement.subscriptions_count);
const cpm = calculateCPM(placement.cost, placement.views_count);
const cost = placement.details?.cost?.value ?? null;
const subscriptions = placement.placement_post?.subscriptions_count ?? 0;
const views = placement.placement_post?.views_count ?? null;
const cps = calculateCPS(cost, subscriptions);
const cpm = calculateCPM(cost, views);
return (
<TableRow key={placement.id}>
<TableCell>
<div className="font-medium">{placement.placement_channel_title}</div>
<div className="font-medium">{placement.channel.title}</div>
</TableCell>
<TableCell>
<div className="text-sm">{placement.creative_name}</div>
<div className="text-sm"></div>
</TableCell>
<TableCell>{formatDate(placement.placement_date)}</TableCell>
<TableCell>{formatCurrency(placement.cost)}</TableCell>
<TableCell>{formatNumber(placement.subscriptions_count)}</TableCell>
<TableCell>{formatNumber(placement.views_count ?? null)}</TableCell>
<TableCell>{placement.details?.placement_at ? formatDate(placement.details.placement_at) : "—"}</TableCell>
<TableCell>{formatCurrency(cost)}</TableCell>
<TableCell>{formatNumber(subscriptions)}</TableCell>
<TableCell>{formatNumber(views)}</TableCell>
<TableCell>{cps ? formatCurrency(cps) : "—"}</TableCell>
<TableCell>{cpm ? formatCurrency(cpm) : "—"}</TableCell>
<TableCell>
<Badge
variant={placement.status === "active" ? "default" : "secondary"}
variant={placement.status === "Оплачено" ? "default" : "secondary"}
>
{placement.status === "active" ? "Активен" : "Архив"}
{placement.status}
</Badge>
</TableCell>
<TableCell>
@@ -677,10 +692,10 @@ export default function PlacementsPage() {
Подробнее
</Link>
</DropdownMenuItem>
{placement.ad_post_url && (
{placement.placement_post?.post?.url && (
<DropdownMenuItem asChild>
<a
href={placement.ad_post_url}
href={placement.placement_post.post.url}
target="_blank"
rel="noopener noreferrer"
>