Files
portal/app/resources/js/views/billing/ChargesTab.vue
T
Дмитрий cb05657f30 chore(format): prettier --write across 37 .vue/.ts files
Phase 1B audit found 48 files failing `prettier --check`. Auto-apply
via `npx prettier --write resources/js/**/*.{ts,vue,css}` produced
style-only changes:
- consistent quote style
- trailing comma normalization
- spaces around : in v-card style="position: relative" attrs
- explicit ; insertion

No semantic changes. No code-behavior changes. Production-code only;
test files batched separately into `test(frontend):` commit.

Verification:
- npx vitest run → 79/79 files, 614/614 + 3 skipped (no regression).
- npx vue-tsc --noEmit → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:24:33 +03:00

172 lines
5.7 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-select
v-model="source"
:items="sources"
item-title="title"
item-value="value"
label="Источник"
density="compact"
hide-details
clearable
style="max-width: 200px"
@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.charge_source`]="{ item }">
<v-chip size="small" :color="item.charge_source === 'prepaid' ? 'info' : 'success'">
{{ item.charge_source === 'prepaid' ? 'prepaid' : '₽' }}
</v-chip>
</template>
<template #[`item.price_rub`]="{ item }"> {{ (item.price_per_lead_kopecks / 100).toFixed(2) }} </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=...&charge_source=...
* POST /api/billing/charges/export → CSV blob.
*
* Pagination server-side через v-data-table-server (20/page).
* Фильтры: period (current_month / last_month / 90d) + charge_source (prepaid / rub).
* RLS: tenant-isolation на backend, frontend просто читает то что вернули.
*
* defineExpose ниже — для Vitest unit-тестов (`refresh`/`exportCsv`/`period`/
* `source`/`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;
}
const rows = ref<ChargeRow[]>([]);
const total = ref(0);
const loading = ref(false);
const exporting = ref(false);
const period = ref<string>('current_month');
const source = ref<string | null>(null);
const page = ref(1);
const periods = [
{ title: 'Текущий месяц', value: 'current_month' },
{ title: 'Прошлый месяц', value: 'last_month' },
{ title: 'Последние 90 дней', value: '90d' },
];
const sources = [
{ title: 'prepaid', value: 'prepaid' },
{ title: '₽', value: 'rub' },
];
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: 'charge_source', sortable: false, width: 120 },
{ title: 'Цена', key: 'price_rub', sortable: false, width: 120 },
];
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 };
if (source.value) params.charge_source = source.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 };
if (source.value) params.charge_source = source.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, source, 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>