Files
portal/app/resources/js/views/billing/ChargesTab.vue
T

166 lines
5.8 KiB
Vue

<template>
<div class="charges-tab">
<div class="d-flex align-center mb-4 ga-3 flex-wrap">
<v-select
v-model="period"
:items="periods"
item-title="title"
item-value="value"
label="Период"
density="compact"
hide-details
style="max-width: 220px"
@update:model-value="refresh"
/>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-download" :loading="exporting" @click="exportCsv">
Скачать CSV
</v-btn>
</div>
<v-data-table-server
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
:items-per-page="20"
class="numeric-tnum"
@update:options="loadOptions"
>
<template #[`item.charged_at`]="{ item }">
{{ formatDate(item.charged_at) }}
</template>
<template #[`item.deal_id`]="{ item }">
<RouterLink :to="`/deals/${item.deal_id}`">#{{ item.deal_id }}</RouterLink>
</template>
<template #[`item.price_rub`]="{ item }">
<span v-if="item.price_per_lead_kopecks === 0" class="text-medium-emphasis">
0
<v-tooltip
activator="parent"
text="До перехода на новую модель эти лиды списывались из бесплатного остатка."
/>
<span class="text-caption ml-1">(из бесплатного)</span>
</span>
<span v-else>{{ formatRub(item.price_per_lead_kopecks) }}&nbsp;</span>
</template>
</v-data-table-server>
</div>
</template>
<script setup lang="ts">
/**
* ChargesTab — read-only ledger списаний за лиды (Plan 4 Task 11).
*
* Backend: GET /api/billing/charges?page=N&period=...
* POST /api/billing/charges/export → CSV blob.
*
* Pagination server-side через v-data-table-server (20/page).
* Фильтры: period (current_month / last_month / 90d).
* Исторические prepaid-строки (price_per_lead_kopecks=0) помечены
* тултипом «До перехода на новую модель...».
* RLS: tenant-isolation на backend, frontend просто читает то что вернули.
*
* defineExpose ниже — для Vitest unit-тестов (`refresh`/`exportCsv`/`period`/
* `total`/`load`).
*/
import { ref, onMounted } from 'vue';
import axios from 'axios';
interface ChargeRow {
id: number;
charged_at: string;
deal_id: number;
tier_no: number;
charge_source: 'prepaid' | 'rub';
price_per_lead_kopecks: number;
}
// Формат денег ru-RU (запятая-десятичный + разделитель тысяч) — единообразно
// с биллингом (раньше было «12.50» через toFixed, без разделителя).
const rubFmt = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function formatRub(kopecks: number): string {
return rubFmt.format(kopecks / 100);
}
const rows = ref<ChargeRow[]>([]);
const total = ref(0);
const loading = ref(false);
const exporting = ref(false);
const period = ref<string>('current_month');
const page = ref(1);
const periods = [
{ title: 'Текущий месяц', value: 'current_month' },
{ title: 'Прошлый месяц', value: 'last_month' },
{ title: 'Последние 90 дней', value: '90d' },
];
const headers = [
{ title: 'Дата', key: 'charged_at', sortable: false },
{ title: 'Сделка', key: 'deal_id', sortable: false, width: 100 },
{ title: 'Tier', key: 'tier_no', sortable: false, width: 80 },
{ title: 'Цена', key: 'price_rub', sortable: false, width: 160 },
];
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' });
}
async function refresh(): Promise<void> {
page.value = 1;
await load();
}
async function loadOptions(opts: { page: number }): Promise<void> {
page.value = opts.page;
await load();
}
async function load(): Promise<void> {
loading.value = true;
try {
const params: Record<string, string | number> = { page: page.value, period: period.value };
const { data } = await axios.get('/api/billing/charges', { params });
rows.value = data.data;
total.value = data.meta.total;
} finally {
loading.value = false;
}
}
async function exportCsv(): Promise<void> {
exporting.value = true;
try {
const params: Record<string, string> = { period: period.value };
const response = await axios.post('/api/billing/charges/export', params, { responseType: 'blob' });
// jsdom не реализует URL.createObjectURL — gracefully no-op (тесты мокают).
if (typeof URL.createObjectURL !== 'function') return;
const url = URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = `charges_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} finally {
exporting.value = false;
}
}
onMounted(load);
defineExpose({ refresh, exportCsv, load, period, total });
</script>
<style scoped>
.charges-tab {
padding-top: 8px;
}
.numeric-tnum :deep(td) {
font-feature-settings: 'tnum';
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
</style>