a56dcb06b2
Клиент сам выставляет 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>
263 lines
9.0 KiB
Vue
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>
|