Files
portal/app/resources/js/views/admin/AdminAdvertisingView.vue
T

219 lines
8.1 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 { 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>