cb05657f30
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>
172 lines
5.7 KiB
Vue
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>
|