ac2c794542
TransactionsTable — server-driven история транзакций (GET /api/billing/transactions, табы → фильтр type). InvoicesTable — GET /api/billing/invoices с empty-state (real-but-empty до Б-1). billingFormatters почищен (drop status/format-функций), mockBilling ужат до pending-баннера (E4). Sprint 2 Plan C, audit E3 (frontend pt2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
5.6 KiB
Vue
197 lines
5.6 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* TransactionsTable — server-driven история транзакций с табами
|
||
* (Все / Пополнения / Списания / Возвраты). Данные — GET
|
||
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
|
||
*/
|
||
import { ref, onMounted } from 'vue';
|
||
import { getTransactions, type BillingTransaction } from '../../api/billing';
|
||
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
|
||
|
||
interface Tab {
|
||
id: string;
|
||
label: string;
|
||
type: string | null;
|
||
}
|
||
|
||
const TABS: Tab[] = [
|
||
{ id: 'all', label: 'Все', type: null },
|
||
{ id: 'topup', label: 'Пополнения', type: 'topup' },
|
||
{ id: 'lead_charge', label: 'Списания', type: 'lead_charge' },
|
||
{ id: 'refund', label: 'Возвраты', type: 'refund' },
|
||
];
|
||
|
||
const activeTab = ref<string>('all');
|
||
const rows = ref<BillingTransaction[]>([]);
|
||
const total = ref(0);
|
||
const loading = ref(false);
|
||
const loadError = ref<string | null>(null);
|
||
const page = ref(1);
|
||
|
||
const headers = [
|
||
{ title: 'Дата', key: 'created_at', sortable: false },
|
||
{ title: 'Операция', key: 'description', sortable: false },
|
||
{ title: 'ID', key: 'code', sortable: false, width: 120 },
|
||
{ title: 'Сумма', key: 'amount_rub', align: 'end' as const, sortable: false, width: 140 },
|
||
];
|
||
|
||
function formatWhen(iso: string): string {
|
||
return new Date(iso).toLocaleString('ru-RU', {
|
||
timeZone: 'Europe/Moscow',
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
/** Числовое значение движения: рубли приоритетно, иначе лиды. */
|
||
function txAmountValue(tx: BillingTransaction): number {
|
||
const rub = Number(tx.amount_rub);
|
||
return rub !== 0 ? rub : tx.amount_leads;
|
||
}
|
||
|
||
/** Текст суммы: «+ 5 000 ₽» / «− 1 лид.» / «0 ₽». */
|
||
function txAmountText(tx: BillingTransaction): string {
|
||
const rub = Number(tx.amount_rub);
|
||
if (rub !== 0) return formatCost(rub);
|
||
if (tx.amount_leads !== 0) {
|
||
const sign = tx.amount_leads > 0 ? '+ ' : '− ';
|
||
return sign + Math.abs(tx.amount_leads) + ' лид.';
|
||
}
|
||
return '0 ₽';
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true;
|
||
loadError.value = null;
|
||
try {
|
||
const tab = TABS.find((t) => t.id === activeTab.value);
|
||
const params: { page: number; type?: string } = { page: page.value };
|
||
if (tab?.type) params.type = tab.type;
|
||
const res = await getTransactions(params);
|
||
rows.value = res.data;
|
||
total.value = res.meta.total;
|
||
} catch {
|
||
loadError.value = 'Не удалось загрузить транзакции.';
|
||
rows.value = [];
|
||
total.value = 0;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function changeTab(id: string): Promise<void> {
|
||
activeTab.value = id;
|
||
page.value = 1;
|
||
await load();
|
||
}
|
||
|
||
async function loadOptions(opts: { page: number }): Promise<void> {
|
||
page.value = opts.page;
|
||
await load();
|
||
}
|
||
|
||
async function refresh(): Promise<void> {
|
||
page.value = 1;
|
||
await load();
|
||
}
|
||
|
||
onMounted(load);
|
||
|
||
defineExpose({ load, refresh, changeTab, activeTab, total, rows });
|
||
</script>
|
||
|
||
<template>
|
||
<v-card variant="outlined" class="mt-4 panel">
|
||
<div class="panel-h pa-4">
|
||
<h2 class="text-h6 panel-title ma-0">История транзакций</h2>
|
||
<v-btn-toggle
|
||
:model-value="activeTab"
|
||
mandatory
|
||
color="primary"
|
||
density="comfortable"
|
||
variant="text"
|
||
>
|
||
<v-btn
|
||
v-for="tab in TABS"
|
||
:key="tab.id"
|
||
:value="tab.id"
|
||
size="small"
|
||
@click="changeTab(tab.id)"
|
||
>
|
||
{{ tab.label }}
|
||
</v-btn>
|
||
</v-btn-toggle>
|
||
</div>
|
||
|
||
<v-alert v-if="loadError" type="error" variant="tonal" density="compact" class="mx-4 mb-4" role="alert">
|
||
{{ loadError }}
|
||
</v-alert>
|
||
|
||
<v-data-table-server
|
||
:headers="headers"
|
||
:items="rows"
|
||
:items-length="total"
|
||
:loading="loading"
|
||
:items-per-page="20"
|
||
density="comfortable"
|
||
@update:options="loadOptions"
|
||
>
|
||
<template #[`item.created_at`]="{ item }">
|
||
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
|
||
</template>
|
||
<template #[`item.code`]="{ item }">
|
||
<span class="tx-id">#{{ item.code }}</span>
|
||
</template>
|
||
<template #[`item.amount_rub`]="{ item }">
|
||
<span class="num" :class="txAmountClass(txAmountValue(item))">
|
||
{{ txAmountText(item) }}
|
||
</span>
|
||
</template>
|
||
</v-data-table-server>
|
||
</v-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.num {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
font-weight: 500;
|
||
}
|
||
|
||
.panel {
|
||
background: #fff;
|
||
}
|
||
.panel-h {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
.panel-title {
|
||
font-variation-settings: 'opsz' 18;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.tx-when {
|
||
font-size: 12px;
|
||
color: #66635c;
|
||
}
|
||
.tx-id {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-size: 12px;
|
||
color: #66635c;
|
||
}
|
||
.tx-amount-up {
|
||
color: #1b6e3b;
|
||
}
|
||
.tx-amount-down {
|
||
color: #b83a3a;
|
||
}
|
||
.tx-amount-neutral {
|
||
color: #66635c;
|
||
}
|
||
</style>
|