Files
portal/app/resources/js/components/billing/InvoicesTable.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

203 lines
5.7 KiB
Vue

<script setup lang="ts">
/**
* InvoicesTable — список счетов тенанта. Данные — GET /api/billing/invoices
* (E3). Real-but-empty до Б-1: на MVP saas_invoices пуста (нужно
* зарегистрированное юр-лицо), компонент показывает empty-state.
*/
import { ref, onMounted } from 'vue';
import { getInvoices, type BillingInvoice } from '../../api/billing';
import { formatPlain } from '../../composables/billingFormatters';
const invoices = ref<BillingInvoice[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
overdue: 'Просрочен',
cancelled: 'Отменён',
};
function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' });
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
invoices.value = (await getInvoices()).data;
} catch {
loadError.value = 'Не удалось загрузить счета.';
} finally {
loading.value = false;
}
}
onMounted(load);
defineExpose({ load, invoices });
</script>
<template>
<v-card variant="outlined" class="mt-4 panel" data-tour="invoices-table">
<div class="panel-h pa-4">
<h2 class="text-h6 panel-title ma-0">Счета</h2>
</div>
<v-divider />
<div v-if="loading" class="py-8 d-flex justify-center">
<v-progress-circular indeterminate color="primary" size="28" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" density="compact" class="ma-4" role="alert">
{{ loadError }}
</v-alert>
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
Здесь появятся выставленные вами счета на оплату.
</div>
<ul v-else class="invoices-list pa-2 ma-0">
<li v-for="inv in invoices" :key="inv.id" class="inv-row">
<span class="inv-when num">{{ formatDate(inv.issued_at) }}</span>
<span class="inv-name">
{{ inv.invoice_number }}
<span class="sub">{{ statusLabel(inv.status) }}</span>
</span>
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
<span class="inv-actions">
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:href="inv.pdf_url ?? undefined"
target="_blank"
:disabled="!inv.has_pdf"
:data-testid="`inv-pdf-${inv.id}`"
data-tour="invoice-pdf"
>
Счёт
</v-btn>
<v-btn
v-if="inv.has_act"
variant="text"
size="small"
prepend-icon="mdi-file-document-check-outline"
:href="inv.act_url ?? undefined"
target="_blank"
:data-testid="`inv-act-${inv.id}`"
data-tour="invoice-act"
>
Акт
</v-btn>
</span>
</li>
</ul>
</v-card>
</template>
<style scoped>
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.panel {
background: #fff;
}
.panel-h {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.panel-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.01em;
}
.empty {
font-size: 14px;
}
.invoices-list {
list-style: none;
padding: 0;
margin: 0;
}
.inv-row {
display: grid;
grid-template-columns: 110px 1fr auto auto;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #f0ede4;
}
.inv-row:last-child {
border-bottom: none;
}
.inv-when {
font-size: 12px;
color: #66635c;
}
.inv-name {
display: flex;
flex-direction: column;
font-weight: 500;
color: #081319;
}
.inv-name .sub {
font-weight: 400;
color: #66635c;
font-size: 12px;
}
.inv-amount {
font-weight: 500;
color: #081319;
}
.inv-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
/* Телефон: 4 колонки (дата | номер | сумма | кнопки) не влезают — при кнопках
«Счёт»+«Акт» номер крошился, а кнопки уезжали за край. Раскладываем в ярусы:
номер+сумма, дата, кнопки во всю ширину. */
@media (max-width: 599.98px) {
.inv-row {
grid-template-columns: 1fr auto;
grid-template-areas:
'name amount'
'when when'
'actions actions';
row-gap: 6px;
}
.inv-when {
grid-area: when;
}
.inv-name {
grid-area: name;
}
.inv-amount {
grid-area: amount;
text-align: right;
align-self: start;
}
.inv-actions {
grid-area: actions;
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>