Files
portal/app/resources/js/views/admin/AdminAdvertisingView.vue
T
Дмитрий 7c97980b3a fix(портал): читаемый текст зелёным и жёлтым — текстовые двойники брендовых цветов
Брендовые success и warning нечитаемы текстом: success даёт 4.25:1 на белой
карточке и 3.83:1 на ивори, warning — 2.25 и 2.03 при норме WCAG 2.1 AA 4.5:1.
Сами цвета не тронуты — остаются для заливок, иконок и рамок. Рядом добавлены
затемнённые текстовые двойники success-strong #256F46 и warning-strong #8A6410,
тон сохранён.

Все 15 мест text-success и 8 мест text-warning переведены на новые классы —
13 файлов: командный центр, биллинг и инциденты админки, система, тенанты,
интеграция с поставщиком, карточка тенанта, диалог баланса, рекламный
админ-экран, мастер рекламной кампании, массовые диалоги дней и регионов,
реквизиты в настройках.

Проверено: фронт 226 файлов / 1663 теста зелёные; Pa11y по всем 24 адресам —
рекламные и ВСЕ админские экраны дают 0 ошибок. Побочно вскрылось 12 старых
недочётов на клиентских экранах — записаны в находку, к этой правке отношения
не имеют.

Боевого не касается, в main не влито.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:02:26 +03:00

457 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Админка SaaS → Реклама: расход и маржа по тенантам.
*
* Сколько тенанты тратят на рекламу (client_spend), сколько из этого мы
* платим Яндексу за закупку (yandex_cost) и что остаётся нам маржой
* (our_margin). Источник данных: GET /api/admin/advertising/spend.
*/
import { computed, onMounted, reactive, ref, watch } from 'vue';
import * as adminApi from '../../api/admin';
import type { AdminAwaitingCampaign, AdvertisingSpendPeriod } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { formatPlain } from '../../composables/billingFormatters';
type SpendRow = {
tenant_id: number;
tenant_name: string;
client_spend_rub: number;
yandex_cost_rub: number;
our_margin_rub: number;
};
const periodOptions: { value: AdvertisingSpendPeriod; label: string }[] = [
{ value: 'current_month', label: 'Текущий месяц' },
{ value: 'last_month', label: 'Прошлый месяц' },
{ value: '90d', label: '90 дней' },
{ value: 'all', label: 'Всё время' },
];
const period = ref<AdvertisingSpendPeriod>('current_month');
const rows = reactive<SpendRow[]>([]);
const totals = reactive({
client_spend_rub: 0,
yandex_cost_rub: 0,
our_margin_rub: 0,
});
const adMarginPercent = ref('');
const loading = ref(false);
const fetchError = ref(false);
const errorMessage = ref('');
const cpm = ref('');
const margin = ref('');
const cpmSaving = ref(false);
const cpmSaved = ref(false);
const cpmError = ref('');
async function loadCpm() {
try {
const res = await adminApi.fetchAdSettings();
cpm.value = res.client_cpm_rub;
margin.value = res.ad_margin_percent;
} catch (e) {
cpmError.value = extractErrorMessage(e);
}
}
async function saveCpm() {
cpmSaving.value = true;
cpmError.value = '';
cpmSaved.value = false;
try {
await adminApi.updateAdSettings({ client_cpm_rub: cpm.value, ad_margin_percent: margin.value });
cpmSaved.value = true;
} catch (e) {
cpmError.value = extractErrorMessage(e);
} finally {
cpmSaving.value = false;
}
}
async function loadSpend() {
loading.value = true;
fetchError.value = false;
errorMessage.value = '';
try {
const res = await adminApi.fetchAdvertisingSpend(period.value);
const mapped: SpendRow[] = res.data.map((r) => ({
tenant_id: r.tenant_id,
tenant_name: r.tenant_name,
client_spend_rub: parseFloat(r.client_spend_rub),
yandex_cost_rub: parseFloat(r.yandex_cost_rub),
our_margin_rub: parseFloat(r.our_margin_rub),
}));
rows.splice(0, rows.length, ...mapped);
totals.client_spend_rub = parseFloat(res.totals.client_spend_rub);
totals.yandex_cost_rub = parseFloat(res.totals.yandex_cost_rub);
totals.our_margin_rub = parseFloat(res.totals.our_margin_rub);
adMarginPercent.value = res.ad_margin_percent;
} catch (e) {
fetchError.value = true;
errorMessage.value = extractErrorMessage(e);
} finally {
loading.value = false;
}
}
onMounted(loadSpend);
onMounted(loadCpm);
watch(period, loadSpend);
const isEmpty = computed(() => !loading.value && !fetchError.value && rows.length === 0);
// === Кампании, ждущие номер креатива Яндекса (8e) ===
const awaitingCampaigns = reactive<AdminAwaitingCampaign[]>([]);
const awaitingLoading = ref(false);
const awaitingError = ref('');
/** Ввод «номер креатива» построчно, id кампании → строка из поля. */
const creativeInputs = reactive<Record<number, string>>({});
const creativeSaving = reactive<Record<number, boolean>>({});
const creativeSaved = reactive<Record<number, boolean>>({});
const creativeErrors = reactive<Record<number, string>>({});
const awaitingIsEmpty = computed(() => !awaitingLoading.value && awaitingCampaigns.length === 0);
async function loadAwaiting() {
awaitingLoading.value = true;
awaitingError.value = '';
try {
const list = await adminApi.fetchAdAwaitingCampaigns();
awaitingCampaigns.splice(0, awaitingCampaigns.length, ...list);
for (const c of list) {
creativeInputs[c.id] = c.yandex_creative_id !== null ? String(c.yandex_creative_id) : '';
}
} catch (e) {
awaitingError.value = extractErrorMessage(e);
} finally {
awaitingLoading.value = false;
}
}
async function saveCreative(campaignId: number) {
creativeSaving[campaignId] = true;
creativeSaved[campaignId] = false;
creativeErrors[campaignId] = '';
try {
const value = Number(creativeInputs[campaignId]);
const res = await adminApi.setAdCampaignCreative(campaignId, value);
const row = awaitingCampaigns.find((c) => c.id === campaignId);
if (row) row.yandex_creative_id = res.yandex_creative_id;
creativeSaved[campaignId] = true;
} catch (e) {
creativeErrors[campaignId] = extractErrorMessage(e);
} finally {
creativeSaving[campaignId] = false;
}
}
onMounted(loadAwaiting);
defineExpose({
rows,
totals,
adMarginPercent,
loading,
fetchError,
errorMessage,
period,
loadSpend,
cpm,
margin,
cpmSaving,
cpmSaved,
cpmError,
saveCpm,
loadCpm,
awaitingCampaigns,
awaitingLoading,
awaitingError,
creativeInputs,
creativeSaving,
creativeSaved,
creativeErrors,
loadAwaiting,
saveCreative,
});
</script>
<template>
<v-container fluid class="admin-advertising pa-6" data-testid="admin-advertising-view">
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-4">
<div>
<h1 class="text-h4 page-title">Реклама расход и маржа</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Сколько тенанты тратят на рекламу и сколько из этого остаётся нам маржой.
</p>
</div>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadSpend"
>
Обновить
</v-btn>
</header>
<v-card variant="outlined" class="pa-4 mb-4" data-testid="ad-cpm-card">
<div class="text-h6 mb-1">Цена клиенту за показы</div>
<p class="text-caption text-medium-emphasis mb-3">Сколько клиент платит за 1000 показов, </p>
<div class="d-flex align-center flex-wrap ga-3">
<v-text-field
v-model="cpm"
type="number"
min="0"
density="comfortable"
variant="outlined"
hide-details
style="max-width: 200px"
class="font-mono tabular"
data-testid="ad-cpm-input"
/>
<v-text-field
v-model="margin"
type="number"
min="0"
max="90"
density="comfortable"
variant="outlined"
label="Наша наценка, %"
hint="В Директ уходит на столько меньше. Клиент не видит."
persistent-hint
style="max-width: 240px"
class="font-mono tabular"
data-testid="ad-margin-input"
/>
<v-btn
color="primary"
:loading="cpmSaving"
data-testid="ad-cpm-save"
@click="saveCpm"
>
Сохранить
</v-btn>
<span v-if="cpmSaved" class="text-success-strong text-body-2" data-testid="ad-cpm-saved">
Цена сохранена
</span>
<span v-if="cpmError" class="text-error text-body-2" data-testid="ad-cpm-error">
{{ cpmError }}
</span>
</div>
</v-card>
<v-btn-toggle
v-model="period"
mandatory
density="comfortable"
color="primary"
variant="outlined"
class="mb-4"
data-testid="ad-spend-period"
>
<v-btn
v-for="opt in periodOptions"
:key="opt.value"
:value="opt.value"
:data-testid="`period-${opt.value}`"
>
{{ opt.label }}
</v-btn>
</v-btn-toggle>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
{{ errorMessage || 'Не удалось загрузить расход на рекламу. Попробуйте обновить.' }}
</v-alert>
<!-- ИТОГО-строка -->
<v-row class="mb-4" data-testid="ad-spend-totals">
<v-col cols="12" sm="4">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Расход клиентов</div>
<div class="text-h6 font-mono tabular" data-testid="ad-spend-total-client">
{{ formatPlain(totals.client_spend_rub) }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Расход у Яндекса</div>
<div class="text-h6 font-mono tabular" data-testid="ad-spend-total-yandex">
{{ formatPlain(totals.yandex_cost_rub) }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="4">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Наша маржа</div>
<div class="text-h6 font-mono tabular text-success-strong" data-testid="ad-spend-total-margin">
{{ formatPlain(totals.our_margin_rub) }}
</div>
</v-card>
</v-col>
</v-row>
<v-card variant="outlined" class="pa-4">
<div class="d-flex justify-space-between align-center mb-3">
<h2 class="text-h6 ma-0">По тенантам</h2>
<span class="text-caption text-medium-emphasis" data-testid="ad-spend-markup">
Наценка: {{ adMarginPercent }}%
</span>
</div>
<div v-if="loading" class="d-flex justify-center pa-8">
<v-progress-circular indeterminate color="primary" data-testid="ad-spend-loading" />
</div>
<p v-else-if="isEmpty" class="text-body-2 text-medium-emphasis ma-0 pa-4" data-testid="ad-spend-empty">
За период рекламных расходов нет.
</p>
<v-table v-else density="comfortable" class="font-mono-nums">
<thead>
<tr>
<th>Тенант</th>
<th class="text-right">Расход клиента</th>
<th class="text-right">Расход Яндекса</th>
<th class="text-right">Наша маржа</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.tenant_id" data-testid="ad-spend-row">
<td class="font-weight-medium">{{ row.tenant_name }}</td>
<td class="text-right font-mono tabular">{{ formatPlain(row.client_spend_rub) }}</td>
<td class="text-right font-mono tabular">{{ formatPlain(row.yandex_cost_rub) }}</td>
<td class="text-right font-mono tabular text-success-strong">
{{ formatPlain(row.our_margin_rub) }}
</td>
</tr>
</tbody>
</v-table>
</v-card>
<v-card variant="outlined" class="pa-4 mt-4" data-testid="ad-awaiting-card">
<div class="text-h6 mb-1">Кампании на запуск номер креатива</div>
<p class="text-caption text-medium-emphasis mb-3">
Оформите креатив в конструкторе Яндекса и впишите его номер, чтобы кампанию можно было запустить.
</p>
<v-alert
v-if="awaitingError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="ad-awaiting-fetch-error"
>
{{ awaitingError }}
</v-alert>
<div v-if="awaitingLoading" class="d-flex justify-center pa-8">
<v-progress-circular indeterminate color="primary" data-testid="ad-awaiting-loading" />
</div>
<p
v-else-if="awaitingIsEmpty"
class="text-body-2 text-medium-emphasis ma-0 pa-4"
data-testid="ad-awaiting-empty"
>
Кампаний, ждущих номер креатива, нет.
</p>
<v-table v-else density="comfortable">
<thead>
<tr>
<th>Тенант</th>
<th>Кампания</th>
<th class="text-right">Смета показов</th>
<th>Ссылка</th>
<th>Номер креатива</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in awaitingCampaigns" :key="c.id" data-testid="ad-awaiting-row">
<td class="font-weight-medium">{{ c.tenant_name }}</td>
<td>{{ c.name }}</td>
<td class="text-right font-mono tabular">
{{ c.estimated_impressions !== null ? c.estimated_impressions : '—' }}
</td>
<td>
<a v-if="c.landing_url" :href="c.landing_url" target="_blank" rel="noopener">{{
c.landing_url
}}</a>
<span v-else></span>
</td>
<td>
<v-text-field
v-model="creativeInputs[c.id]"
type="number"
min="1"
density="comfortable"
variant="outlined"
hide-details
style="max-width: 200px"
class="font-mono tabular"
data-testid="ad-creative-input"
/>
</td>
<td>
<v-btn
color="primary"
size="small"
:loading="creativeSaving[c.id]"
data-testid="ad-creative-save"
@click="saveCreative(c.id)"
>
Сохранить
</v-btn>
<div
v-if="creativeSaved[c.id]"
class="text-success-strong text-body-2"
data-testid="ad-creative-saved"
>
Сохранено
</div>
<div
v-if="creativeErrors[c.id]"
class="text-error text-body-2"
data-testid="ad-creative-error"
>
{{ creativeErrors[c.id] }}
</div>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-container>
</template>
<style scoped>
.admin-advertising {
max-width: 1400px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.tabular {
font-feature-settings: 'tnum';
}
</style>