Files
portal/app/resources/js/views/admin/AdminInvoicesView.vue
T
Дмитрий a56dcb06b2 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:25:16 +03:00

263 lines
9.0 KiB
Vue

<script setup lang="ts">
/**
* AdminInvoicesView — SaaS-admin экран «Счета» (Этап 1 «оплата по счёту»).
* Серверная пагинация/поиск/фильтр по статусу. Кнопка «Отметить оплаченным»
* у выставленных счетов открывает диалог подтверждения → markInvoicePaid →
* зачисление баланса + формирование Акта на бэкенде.
*/
import { ref, onMounted, watch } from 'vue';
import {
listAdminInvoices,
markInvoicePaid,
type AdminInvoiceRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
overdue: 'Просрочен',
cancelled: 'Отменён',
};
const STATUS_COLORS: Record<string, string> = {
issued: 'info',
paid: 'success',
overdue: 'warning',
cancelled: 'grey',
draft: 'grey',
};
const STATUS_FILTERS = [
{ value: '', title: 'Все статусы' },
{ value: 'issued', title: 'Выставленные' },
{ value: 'paid', title: 'Оплаченные' },
{ value: 'overdue', title: 'Просроченные' },
{ value: 'cancelled', title: 'Отменённые' },
];
const rows = ref<AdminInvoiceRow[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
const page = ref(1);
const perPage = ref(25);
const total = ref(0);
const lastPage = ref(1);
const search = ref('');
const filterStatus = ref('');
const confirmOpen = ref(false);
const confirmRow = ref<AdminInvoiceRow | null>(null);
const marking = ref(false);
const snackbar = ref(false);
const snackMsg = ref('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function statusLabel(s: string): string {
return STATUS_LABELS[s] ?? s;
}
function statusColor(s: string): string {
return STATUS_COLORS[s] ?? 'grey';
}
function formatDate(iso: string | null): string {
return iso ? new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' }) : '—';
}
function formatAmount(v: string): string {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2 }).format(Number(v));
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const res = await listAdminInvoices({
status: filterStatus.value || undefined,
search: search.value || undefined,
page: page.value,
per_page: perPage.value,
});
rows.value = res.data;
total.value = res.meta.total;
lastPage.value = res.meta.last_page;
} catch (e) {
loadError.value = extractErrorMessage(e, 'Не удалось загрузить счета.');
} finally {
loading.value = false;
}
}
function goPage(p: number): void {
page.value = p;
void load();
}
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
page.value = 1;
void load();
}, 400);
});
watch(filterStatus, () => {
page.value = 1;
void load();
});
function askMarkPaid(row: AdminInvoiceRow): void {
confirmRow.value = row;
confirmOpen.value = true;
}
async function doMarkPaid(): Promise<void> {
if (confirmRow.value === null) return;
marking.value = true;
try {
await markInvoicePaid(confirmRow.value.id);
snackMsg.value = `Счёт ${confirmRow.value.invoice_number} отмечен оплаченным, баланс зачислен.`;
snackbar.value = true;
confirmOpen.value = false;
confirmRow.value = null;
await load();
} catch (e) {
snackMsg.value = extractErrorMessage(e, 'Не удалось отметить оплату.');
snackbar.value = true;
} finally {
marking.value = false;
}
}
onMounted(load);
defineExpose({ rows, page, perPage, total, search, filterStatus, goPage, load });
</script>
<template>
<v-container class="invoices-admin" fluid>
<div class="page-head mb-4">
<h1 class="text-h5 page-title ma-0">Счета</h1>
</div>
<div class="filters mb-4">
<v-text-field
v-model="search"
label="Поиск по номеру, клиенту, плательщику"
density="comfortable"
hide-details
clearable
prepend-inner-icon="mdi-magnify"
style="max-width: 420px"
/>
<v-select
v-model="filterStatus"
:items="STATUS_FILTERS"
item-title="title"
item-value="value"
label="Статус"
density="comfortable"
hide-details
style="max-width: 220px"
/>
</div>
<v-card variant="outlined">
<div v-if="loading" class="py-10 d-flex justify-center">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="ma-4" role="alert">
{{ loadError }}
</v-alert>
<div v-else-if="rows.length === 0" class="py-10 text-center text-medium-emphasis">
Счетов не найдено.
</div>
<v-table v-else>
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Клиент</th>
<th>Плательщик</th>
<th class="text-right">Сумма, </th>
<th>Статус</th>
<th>Оплатить до</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td class="num">{{ formatDate(r.issued_at) }}</td>
<td class="num">{{ r.invoice_number }}</td>
<td>{{ r.tenant_name ?? '—' }}</td>
<td>{{ r.payer_name ?? '—' }}</td>
<td class="text-right num">{{ formatAmount(r.amount_total) }}</td>
<td><v-chip :color="statusColor(r.status)" size="small" variant="tonal">{{ statusLabel(r.status) }}</v-chip></td>
<td class="num">{{ formatDate(r.expires_at) }}</td>
<td class="text-right">
<v-btn
v-if="r.status === 'issued' || r.status === 'overdue'"
color="success"
size="small"
variant="flat"
:data-testid="`mark-paid-${r.id}`"
@click="askMarkPaid(r)"
>
Отметить оплаченным
</v-btn>
</td>
</tr>
</tbody>
</v-table>
<div v-if="lastPage > 1" class="d-flex justify-center py-4">
<v-pagination
:model-value="page"
:length="lastPage"
:total-visible="7"
@update:model-value="goPage"
/>
</div>
</v-card>
<v-dialog v-model="confirmOpen" max-width="460">
<v-card v-if="confirmRow">
<v-card-title class="text-h6">Подтверждение оплаты</v-card-title>
<v-card-text>
Отметить счёт <b>{{ confirmRow.invoice_number }}</b> на сумму
<b>{{ formatAmount(confirmRow.amount_total) }} </b>
(клиент: {{ confirmRow.tenant_name ?? confirmRow.payer_name ?? '—' }}) как оплаченный?
Баланс клиента будет пополнен, сформируется Акт.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="marking" @click="confirmOpen = false">Отмена</v-btn>
<v-btn color="success" variant="flat" :loading="marking" @click="doMarkPaid">Подтверждаю</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar" :timeout="5000" color="success">{{ snackMsg }}</v-snackbar>
</v-container>
</template>
<style scoped>
.page-title {
font-variation-settings: 'opsz' 24;
letter-spacing: -0.015em;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
</style>