219 lines
8.1 KiB
Vue
219 lines
8.1 KiB
Vue
<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 { 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 markupPercent = ref(0);
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
const errorMessage = ref('');
|
||
|
||
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);
|
||
markupPercent.value = res.markup_percent;
|
||
} catch (e) {
|
||
fetchError.value = true;
|
||
errorMessage.value = extractErrorMessage(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(loadSpend);
|
||
watch(period, loadSpend);
|
||
|
||
const isEmpty = computed(() => !loading.value && !fetchError.value && rows.length === 0);
|
||
|
||
defineExpose({
|
||
rows,
|
||
totals,
|
||
markupPercent,
|
||
loading,
|
||
fetchError,
|
||
errorMessage,
|
||
period,
|
||
loadSpend,
|
||
});
|
||
</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-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" 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">
|
||
Наценка: {{ markupPercent }}%
|
||
</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">
|
||
{{ formatPlain(row.our_margin_rub) }}
|
||
</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>
|