f9e589497e
Списания type=autopodbor_charge шли без описания и показывались как «—» и в таблице (десктоп), и в карточках (телефон). Добавлена метка «Списание за автоподбор» в общий словарь операций TransactionsTable. Тесты транзакций 9/9 зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
8.8 KiB
Vue
286 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* TransactionsTable — server-driven история транзакций с табами
|
|
* (Все / Пополнения / Списания / Возвраты). Данные — GET
|
|
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
|
|
*/
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { getTransactions, type BillingTransaction } from '../../api/billing';
|
|
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
|
|
import ResponsiveTable from '../common/ResponsiveTable.vue';
|
|
|
|
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' },
|
|
];
|
|
|
|
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 },
|
|
];
|
|
|
|
/**
|
|
* Ярлык столбца «Операция». Показываем сохранённый description, если он есть;
|
|
* иначе — человекочитаемый ярлык по типу операции. Корень F4: списания за лид
|
|
* (lead_charge) создаются LedgerService без description, отчего ячейка была пустой.
|
|
*/
|
|
const OP_LABELS: Record<string, string> = {
|
|
lead_charge: 'Списание за лид',
|
|
topup: 'Пополнение баланса',
|
|
migration: 'Конвертация лидов в ₽',
|
|
autopodbor_charge: 'Списание за автоподбор',
|
|
};
|
|
|
|
function opLabel(tx: BillingTransaction): string {
|
|
return tx.description?.trim() || OP_LABELS[tx.type] || '—';
|
|
}
|
|
|
|
function formatWhen(iso: string): string {
|
|
return new Date(iso).toLocaleString('ru-RU', {
|
|
timeZone: 'Europe/Moscow',
|
|
year: '2-digit',
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
/** Числовое значение движения из display_amount_rub. */
|
|
function txAmountValue(tx: BillingTransaction): number {
|
|
return Number(tx.display_amount_rub);
|
|
}
|
|
|
|
/** Текст суммы из display_amount_rub. */
|
|
function txAmountText(tx: BillingTransaction): string {
|
|
return formatCost(Number(tx.display_amount_rub));
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// Пейджер для карточек (телефон): страниц по 20, как у таблицы.
|
|
const PER_PAGE = 20;
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PER_PAGE)));
|
|
|
|
async function goToPage(p: number): Promise<void> {
|
|
page.value = p;
|
|
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>
|
|
|
|
<ResponsiveTable :items="rows">
|
|
<template #table>
|
|
<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.description`]="{ item }">
|
|
<span class="tx-op">{{ opLabel(item) }}</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>
|
|
</template>
|
|
|
|
<template #card>
|
|
<div class="tx-cards">
|
|
<article v-for="tx in rows" :key="tx.code" class="tx-card">
|
|
<div class="tx-card__top">
|
|
<span class="tx-card__op">{{ opLabel(tx) }}</span>
|
|
<span class="num tx-card__amount" :class="txAmountClass(txAmountValue(tx))">{{
|
|
txAmountText(tx)
|
|
}}</span>
|
|
</div>
|
|
<div class="tx-card__meta">
|
|
<span class="tx-when num">{{ formatWhen(tx.created_at) }}</span>
|
|
<span class="tx-id">#{{ tx.code }}</span>
|
|
</div>
|
|
</article>
|
|
|
|
<div v-if="rows.length === 0 && !loading" class="tx-empty text-medium-emphasis">
|
|
Нет транзакций
|
|
</div>
|
|
|
|
<v-pagination
|
|
v-if="pageCount > 1"
|
|
:model-value="page"
|
|
:length="pageCount"
|
|
:total-visible="5"
|
|
density="comfortable"
|
|
class="tx-pager"
|
|
@update:model-value="goToPage"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</ResponsiveTable>
|
|
</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;
|
|
}
|
|
|
|
/* Карточки транзакций (телефон) */
|
|
.tx-cards {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
padding: 12px 16px 16px;
|
|
}
|
|
.tx-card {
|
|
border: 1px solid rgba(1, 32, 25, 0.1);
|
|
border-radius: 12px;
|
|
padding: 12px 14px;
|
|
background: #fff;
|
|
}
|
|
.tx-card__top {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
}
|
|
.tx-card__op {
|
|
font-weight: 500;
|
|
color: #081319;
|
|
font-size: 14px;
|
|
min-width: 0;
|
|
}
|
|
.tx-card__amount {
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
.tx-card__meta {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-top: 6px;
|
|
}
|
|
.tx-empty {
|
|
text-align: center;
|
|
padding: 24px 0;
|
|
}
|
|
.tx-pager {
|
|
margin-top: 4px;
|
|
}
|
|
</style>
|