Files
portal/app/resources/js/views/admin/AdminBillingView.vue
T
Дмитрий e39a42cfdf fix(a11y): admin search inputs — add label prop for accessible name (Pattern H)
A11y rescan Pattern H — Vuetify <v-text-field> без `label` prop рендерит
empty `<label id="input-v-NN-label">` (referenced via aria-labelledby).
Pa11y/axe видит unlabelled input на /admin/billing (search «Поиск по
названию или ИНН») и /admin/system (search «Поиск по ключу или описанию»).

Initial naive fix добавил `aria-label="..."` — но ARIA priority говорит
aria-labelledby overrides aria-label, поэтому осталось violation.

Final fix: add `label="Поиск"` prop on VTextField. Vuetify рендерит
floating label с правильным accessible text → axe-core resolves через
aria-labelledby chain successfully. Placeholder сохранён (split: «Поиск»
теперь в label, «по названию или ИНН» / «по ключу или описанию» —
placeholder).

Files:
- AdminBillingView.vue:209-217
- AdminSystemView.vue:130-138

Closes Pa11y «label» violations на 2 admin URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:48 +03:00

280 lines
10 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 → Биллинг.
*
* Сводный биллинг по всем тенантам: выручка, MRR, retention, refunds.
* Источник данных: aggregate balance_transactions / invoices / tariff_subscriptions.
*
* MVP — только display-вьюха с mock-данными. Backend `/api/admin/billing/*`
* подключается отдельным коммитом.
*/
import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '../../composables/mockAdmin';
import { computed, onMounted, reactive, ref } from 'vue';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
const search = ref('');
/**
* Reactive-копия — initial = MOCK для UI без backend'а; replace на API на mount.
* View работает в обоих режимах: row может быть из mock (узкие enum-types)
* или из API (открытые string-типы).
*/
type BillingRow = {
id: number;
name: string;
inn: string; // в API нет — пустая строка
tariff: string;
balance_rub: number;
monthly_topups_rub: number;
monthly_charges_rub: number;
mrr_rub: number;
status: string;
};
const rowsState = reactive<BillingRow[]>(
ADMIN_BILLING_TENANTS.map((r) => ({
id: r.id,
name: r.name,
inn: r.inn,
tariff: r.tariff,
balance_rub: r.balance_rub,
monthly_topups_rub: r.monthly_topups_rub,
monthly_charges_rub: r.monthly_charges_rub,
mrr_rub: r.mrr_rub,
status: r.status,
})),
);
const summary = reactive({
total_mrr_rub: MOCK_SUMMARY.total_mrr_rub,
monthly_revenue_rub: MOCK_SUMMARY.monthly_revenue_rub,
overdue_count: MOCK_SUMMARY.overdue_count,
refunds_count_30d: MOCK_SUMMARY.refunds_count_30d,
});
const loading = ref(false);
const fetchError = ref(false);
function deriveStatus(api: { status: string; balance_rub: string; chargeback_unrecovered_rub: string }): string {
const balance = parseFloat(api.balance_rub);
const chargeback = parseFloat(api.chargeback_unrecovered_rub);
if (api.status === 'suspended') return 'suspended';
if (chargeback > 0 || balance < 0) return 'overdue';
return 'active';
}
async function loadBilling() {
loading.value = true;
fetchError.value = false;
try {
const res = await adminApi.listAdminBilling();
const mapped: BillingRow[] = res.tenants.map((t) => ({
id: t.id,
name: t.organization_name,
inn: '', // нет в API
tariff: t.tariff_name ?? '—',
balance_rub: parseFloat(t.balance_rub),
monthly_topups_rub: parseFloat(t.monthly_topups_rub),
monthly_charges_rub: parseFloat(t.monthly_charges_rub),
mrr_rub: parseFloat(t.mrr_rub),
status: deriveStatus(t),
}));
rowsState.splice(0, rowsState.length, ...mapped);
summary.total_mrr_rub = parseFloat(res.summary.total_mrr_rub);
summary.monthly_revenue_rub = parseFloat(res.summary.monthly_revenue_rub);
summary.overdue_count = res.summary.overdue_count;
summary.refunds_count_30d = res.summary.refunds_count_30d;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
onMounted(loadBilling);
usePolling(loadBilling);
defineExpose({ rowsState, summary, loading, fetchError, loadBilling });
const headers = [
{ title: 'Тенант', key: 'name', sortable: true },
{ title: 'Тариф', key: 'tariff', sortable: true },
{ title: 'Баланс', key: 'balance_rub', sortable: true, align: 'end' as const },
{ title: 'Пополнения за мес', key: 'monthly_topups_rub', sortable: true, align: 'end' as const },
{ title: 'Списания за мес', key: 'monthly_charges_rub', sortable: true, align: 'end' as const },
{ title: 'MRR', key: 'mrr_rub', sortable: true, align: 'end' as const },
{ title: 'Статус', key: 'status', sortable: true },
];
const filteredRows = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return rowsState;
return rowsState.filter((r) => r.name.toLowerCase().includes(q) || r.inn.includes(q));
});
const fmt = new Intl.NumberFormat('ru-RU');
const formatRub = (v: number) => `${fmt.format(v)}`;
const statusMeta: Record<string, { label: string; color: string }> = {
active: { label: 'Активен', color: 'success' },
overdue: { label: 'Просрочка', color: 'warning' },
suspended: { label: 'Заблокирован', color: 'error' },
};
function statusInfo(s: string) {
return statusMeta[s] ?? { label: s, color: 'default' };
}
// Mock-tariff slugs → русский title; backend возвращает уже переведённое имя
// (например «Команда»), которое отдаём как есть.
const tariffMap: Record<string, string> = {
start: 'Старт',
basic: 'Базовый',
pro: 'Команда',
enterprise: 'Enterprise',
};
function tariffLabel(t: string): string {
return tariffMap[t] ?? t;
}
</script>
<template>
<v-container fluid class="admin-billing pa-6">
<header class="page-head mb-4 d-flex justify-space-between align-start">
<div>
<h1 class="text-h4 page-title">Биллинг</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Сводка по всем тенантам: выручка, MRR, просрочки, возвраты.
</p>
</div>
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadBilling"
>
Обновить
</v-btn>
</header>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
</v-alert>
<!-- Stats row -->
<v-row class="mb-4" data-testid="billing-stats">
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">MRR</div>
<div class="text-h6 font-mono tabular">{{ formatRub(summary.total_mrr_rub) }}</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Выручка за месяц</div>
<div class="text-h6 font-mono tabular">
{{ formatRub(summary.monthly_revenue_rub) }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Просрочка</div>
<div class="text-h6 font-mono tabular text-warning">
{{ summary.overdue_count }}
</div>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined" class="pa-3">
<div class="text-caption text-medium-emphasis">Возвраты за 30 дн</div>
<div class="text-h6 font-mono tabular">{{ summary.refunds_count_30d }}</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>
<v-text-field
v-model="search"
label="Поиск"
placeholder="по названию или ИНН"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
hide-details
clearable
style="max-width: 320px"
/>
</div>
<v-data-table
:headers="headers"
:items="filteredRows"
:items-per-page="20"
density="comfortable"
class="font-mono-nums"
>
<template #[`item.name`]="{ item }">
<div>
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">ИНН {{ item.inn }}</div>
</div>
</template>
<template #[`item.tariff`]="{ item }">
{{ tariffLabel(item.tariff) }}
</template>
<template #[`item.balance_rub`]="{ item }">
<span
:class="
item.balance_rub < 0 ? 'text-error' : item.balance_rub === 0 ? 'text-medium-emphasis' : ''
"
>
{{ formatRub(item.balance_rub) }}
</span>
</template>
<template #[`item.monthly_topups_rub`]="{ item }">
{{ formatRub(item.monthly_topups_rub) }}
</template>
<template #[`item.monthly_charges_rub`]="{ item }">
{{ formatRub(item.monthly_charges_rub) }}
</template>
<template #[`item.mrr_rub`]="{ item }">
{{ formatRub(item.mrr_rub) }}
</template>
<template #[`item.status`]="{ item }">
<v-chip :color="statusInfo(item.status).color" size="small" variant="tonal">
{{ statusInfo(item.status).label }}
</v-chip>
</template>
</v-data-table>
</v-card>
</v-container>
</template>
<style scoped>
.admin-billing {
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>