Files
portal/app/resources/js/components/billing/InvoicesTable.vue
T
Дмитрий ac2c794542 feat(billing): TransactionsTable + InvoicesTable real API (E3)
TransactionsTable — server-driven история транзакций (GET
/api/billing/transactions, табы → фильтр type). InvoicesTable —
GET /api/billing/invoices с empty-state (real-but-empty до Б-1).
billingFormatters почищен (drop status/format-функций), mockBilling
ужат до pending-баннера (E4).

Sprint 2 Plan C, audit E3 (frontend pt2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:56:22 +03:00

150 lines
3.9 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">
<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>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:disabled="!inv.has_pdf"
>
PDF
</v-btn>
</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;
}
</style>