Files
portal/app/resources/js/components/billing/TransactionsTable.vue
T
Дмитрий ac2c794542 feat(billing): TransactionsTable + InvoicesTable real API (E3)
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>
2026-05-16 07:56:22 +03:00

197 lines
5.6 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">
/**
* 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>