Files
portal/app/resources/js/views/admin/AdminSmsView.vue
T
Дмитрий 37493b7d02 feat(смс-клиент): экраны Этапа 1 — «Не писать этим», «Остановить», ключ заказа, общий стоп-лист в админке
Вкладка «Не писать этим» отдельной панелью SmsOptoutsPanel.vue: номер руками,
пачкой из файла, удаление; непонятые строки показаны образцами, а не молча.

Кнопка «Остановить» видна, пока рассылка в очереди, идёт или ждёт утра; после
остановки строка показывает честный итог «Ушло N из M, списано X ₽» — деньги
фактические, а не смета.

Ключ заказа crypto.randomUUID() уходит с каждой отправкой и меняется после
успеха. На ответ сервера «похоже, это повтор» экран задаёт вопрос словами
сервера и повторяет только по согласию человека.

В админке раздел «Общий стоп-лист»: список, внесение с причиной, удаление.

Отказ получателя (страница по ссылке и приписка в тексте) в экранах
отсутствует — отменён владельцем, см. В-30 приёмочного листа.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 19:45:47 +03:00

661 lines
26 KiB
Vue

<script setup lang="ts">
/**
* Админка SaaS → СМС: тарифы и имена (Task 16b).
*
* Три блока:
* 1. Тарифы (ступени) — редактируемая лестница цен ₽/СМС по объёму (min_qty).
* 2. Настройки имени — плата за платное имя отправителя (₽/мес за оператора)
* и срок долга до автоматического отключения.
* 3. Имена на согласовании — заявки клиентов на своё имя отправителя (approve/reject/disable).
*
* Backend: AdminSmsController (см. routes/web.php § /api/admin/sms/*).
*/
import { onMounted, reactive, ref } from 'vue';
import * as adminApi from '../../api/admin';
import type { AdminSmsSender, AdminSmsTariffRow } from '../../api/admin';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
const tariffRows = reactive<AdminSmsTariffRow[]>([]);
const tariffsLoading = ref(false);
const tariffsSaving = ref(false);
const tariffsError = ref('');
const nameFee = ref('');
const graceDays = ref(0);
const settingsLoading = ref(false);
const settingsSaving = ref(false);
const settingsError = ref('');
const settingsSuccess = ref(false);
const senders = reactive<AdminSmsSender[]>([]);
const sendersLoading = ref(false);
const sendersError = ref('');
/** Человеческий вид типа имени. */
const NAME_TYPE_LABELS: Record<string, string> = {
legal: 'название юрлица',
ip: 'название ИП',
website: 'домен сайта',
trademark: 'товарный знак',
company: 'название компании',
};
function nameTypeLabel(t: string): string {
return NAME_TYPE_LABELS[t] ?? t;
}
/** Ссылка на скачивание приложенного скана: kind — basis/consent. */
function docUrl(id: number, kind: 'basis' | 'consent'): string {
return adminApi.smsSenderDocumentUrl(id, kind);
}
const rejectNoteBySenderId = reactive<Record<number, string>>({});
const rejectOpenSenderId = ref<number | null>(null);
const senderActionError = ref('');
async function loadTariffs() {
tariffsLoading.value = true;
tariffsError.value = '';
try {
const res = await adminApi.fetchSmsTariffs();
tariffRows.splice(0, tariffRows.length, ...res.tariffs.map((t) => ({ min_qty: t.min_qty, price_rub: t.price_rub })));
nameFee.value = res.settings.name_fee_rub_per_operator;
graceDays.value = res.settings.name_debt_grace_days;
} catch (e) {
tariffsError.value = extractErrorMessage(e, 'Не удалось загрузить тарифы СМС.');
} finally {
tariffsLoading.value = false;
}
}
async function loadSenders() {
sendersLoading.value = true;
sendersError.value = '';
try {
const res = await adminApi.fetchSmsSenders('pending');
senders.splice(0, senders.length, ...res);
} catch (e) {
sendersError.value = extractErrorMessage(e, 'Не удалось загрузить заявки на имя отправителя.');
} finally {
sendersLoading.value = false;
}
}
function addTariffRow() {
tariffRows.push({ min_qty: 1, price_rub: '0.00' });
}
function removeTariffRow(index: number) {
tariffRows.splice(index, 1);
}
async function saveTariffs() {
tariffsSaving.value = true;
tariffsError.value = '';
try {
const res = await adminApi.updateSmsTariffs(tariffRows.map((r) => ({ min_qty: r.min_qty, price_rub: r.price_rub })));
tariffRows.splice(0, tariffRows.length, ...res.tariffs.map((t) => ({ min_qty: t.min_qty, price_rub: t.price_rub })));
} catch (e) {
const validation = extractValidationErrors(e);
tariffsError.value =
validation && Object.keys(validation).length > 0
? Object.values(validation).flat().join(' ')
: extractErrorMessage(e, 'Не удалось сохранить тарифы.');
} finally {
tariffsSaving.value = false;
}
}
async function saveSettings() {
settingsSaving.value = true;
settingsError.value = '';
settingsSuccess.value = false;
try {
const res = await adminApi.updateSmsSettings(nameFee.value, graceDays.value);
nameFee.value = res.name_fee_rub_per_operator;
graceDays.value = res.name_debt_grace_days;
settingsSuccess.value = true;
} catch (e) {
const validation = extractValidationErrors(e);
settingsError.value =
validation && Object.keys(validation).length > 0
? Object.values(validation).flat().join(' ')
: extractErrorMessage(e, 'Не удалось сохранить настройки имени.');
} finally {
settingsSaving.value = false;
}
}
async function approve(id: number) {
senderActionError.value = '';
try {
await adminApi.approveSmsSender(id);
await loadSenders();
} catch (e) {
senderActionError.value = extractErrorMessage(e, 'Не удалось подтвердить имя отправителя.');
}
}
function openReject(id: number) {
rejectOpenSenderId.value = id;
if (rejectNoteBySenderId[id] === undefined) rejectNoteBySenderId[id] = '';
}
function cancelReject() {
rejectOpenSenderId.value = null;
}
async function confirmReject(id: number) {
senderActionError.value = '';
try {
const note = rejectNoteBySenderId[id]?.trim();
await adminApi.rejectSmsSender(id, note ? note : undefined);
rejectOpenSenderId.value = null;
await loadSenders();
} catch (e) {
senderActionError.value = extractErrorMessage(e, 'Не удалось отклонить имя отправителя.');
}
}
async function disable(id: number) {
senderActionError.value = '';
try {
await adminApi.disableSmsSender(id);
await loadSenders();
} catch (e) {
senderActionError.value = extractErrorMessage(e, 'Не удалось отключить имя отправителя.');
}
}
// ─── Общий стоп-лист портала (строки листа 1.4, 1.5) ────────────────────────
// Номер вносится один раз — и перестаёт получать СМС у ВСЕХ клиентов сразу.
// Нужен для защиты договора с МТС: пришла жалоба — закрыли номер по всему порталу.
const globalOptouts = reactive<adminApi.AdminSmsGlobalOptout[]>([]);
const optoutsLoading = ref(false);
const optoutsError = ref('');
const optoutPhone = ref('');
const optoutReason = ref('');
const optoutSaving = ref(false);
async function loadGlobalOptouts() {
optoutsLoading.value = true;
optoutsError.value = '';
try {
const items = (await adminApi.fetchSmsGlobalOptouts()) ?? [];
globalOptouts.splice(0, globalOptouts.length, ...items);
} catch (e) {
optoutsError.value = extractErrorMessage(e, 'Не удалось загрузить общий стоп-лист.');
} finally {
optoutsLoading.value = false;
}
}
async function addGlobalOptout() {
const phone = optoutPhone.value.trim();
if (phone === '' || optoutSaving.value) return;
optoutSaving.value = true;
optoutsError.value = '';
try {
const reason = optoutReason.value.trim();
await adminApi.addSmsGlobalOptout(phone, reason === '' ? null : reason);
optoutPhone.value = '';
optoutReason.value = '';
await loadGlobalOptouts();
} catch (e) {
optoutsError.value = extractErrorMessage(e, 'Не удалось внести номер.');
} finally {
optoutSaving.value = false;
}
}
async function removeGlobalOptout(phone: string) {
optoutsError.value = '';
try {
await adminApi.deleteSmsGlobalOptout(phone);
await loadGlobalOptouts();
} catch (e) {
optoutsError.value = extractErrorMessage(e, 'Не удалось убрать номер.');
}
}
onMounted(() => {
loadTariffs();
loadSenders();
loadGlobalOptouts();
});
defineExpose({
tariffRows,
tariffsLoading,
tariffsSaving,
tariffsError,
nameFee,
graceDays,
settingsLoading,
settingsSaving,
settingsError,
settingsSuccess,
senders,
sendersLoading,
sendersError,
senderActionError,
rejectOpenSenderId,
rejectNoteBySenderId,
loadTariffs,
loadSenders,
addTariffRow,
removeTariffRow,
saveTariffs,
saveSettings,
approve,
openReject,
cancelReject,
confirmReject,
disable,
nameTypeLabel,
docUrl,
// Общий стоп-лист портала.
globalOptouts,
optoutsLoading,
optoutsError,
optoutPhone,
optoutReason,
optoutSaving,
loadGlobalOptouts,
addGlobalOptout,
removeGlobalOptout,
});
</script>
<template>
<v-container fluid class="admin-sms pa-6" data-testid="admin-sms-view">
<header class="page-head mb-4">
<h1 class="text-h4 page-title">СМС: тарифы и имена</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Ступени цены за СМС, плата за платное имя отправителя и заявки клиентов на согласование.
</p>
</header>
<!-- Блок 1: Тарифы (ступени) -->
<v-card variant="outlined" class="pa-4 mb-6">
<h2 class="text-h6 mb-3">Тарифы (ступени)</h2>
<v-alert
v-if="tariffsError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-tariffs-error"
>
{{ tariffsError }}
</v-alert>
<div v-if="tariffsLoading" class="d-flex justify-center pa-6">
<v-progress-circular indeterminate color="primary" data-testid="sms-tariffs-loading" />
</div>
<div v-else>
<table class="tariff-table mb-3">
<thead>
<tr>
<th>От (шт.)</th>
<th>Цена, /СМС</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in tariffRows" :key="idx" :data-testid="`sms-tariff-row-${idx}`">
<td>
<v-text-field
v-model.number="row.min_qty"
type="number"
min="1"
density="compact"
hide-details
:data-testid="`sms-tariff-min-qty-${idx}`"
/>
</td>
<td>
<v-text-field
v-model="row.price_rub"
type="number"
step="0.01"
min="0"
density="compact"
hide-details
:data-testid="`sms-tariff-price-${idx}`"
/>
</td>
<td>
<v-btn
icon="mdi-delete-outline"
variant="text"
density="compact"
:data-testid="`sms-tariff-remove-${idx}`"
@click="removeTariffRow(idx)"
/>
</td>
</tr>
</tbody>
</table>
<div class="d-flex ga-2">
<v-btn
variant="outlined"
prepend-icon="mdi-plus"
data-testid="sms-tariffs-add-row"
@click="addTariffRow"
>
Добавить ступень
</v-btn>
<v-btn
color="primary"
:loading="tariffsSaving"
data-testid="sms-tariffs-save"
@click="saveTariffs"
>
Сохранить тарифы
</v-btn>
</div>
</div>
</v-card>
<!-- Блок 2: Настройки имени -->
<v-card variant="outlined" class="pa-4 mb-6">
<h2 class="text-h6 mb-3">Настройки имени</h2>
<v-alert
v-if="settingsError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-settings-error"
>
{{ settingsError }}
</v-alert>
<v-alert
v-if="settingsSuccess"
type="success"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-settings-success"
>
Настройки сохранены.
</v-alert>
<v-row>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model="nameFee"
label="Плата за имя, ₽/мес за оператора"
type="number"
step="0.01"
min="0"
density="compact"
data-testid="sms-settings-name-fee"
/>
</v-col>
<v-col cols="12" sm="6" md="4">
<v-text-field
v-model.number="graceDays"
label="Долг до отключения, дней"
type="number"
min="0"
density="compact"
data-testid="sms-settings-grace-days"
/>
</v-col>
</v-row>
<v-btn color="primary" :loading="settingsSaving" data-testid="sms-settings-save" @click="saveSettings">
Сохранить настройки
</v-btn>
</v-card>
<!-- Блок 3: Имена на согласовании -->
<v-card variant="outlined" class="pa-4">
<h2 class="text-h6 mb-3">Имена на согласовании</h2>
<v-alert
v-if="sendersError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-senders-error"
>
{{ sendersError }}
</v-alert>
<v-alert
v-if="senderActionError"
type="error"
variant="tonal"
density="compact"
closable
class="mb-3"
data-testid="sms-sender-action-error"
>
{{ senderActionError }}
</v-alert>
<div v-if="sendersLoading" class="d-flex justify-center pa-6">
<v-progress-circular indeterminate color="primary" data-testid="sms-senders-loading" />
</div>
<p
v-else-if="senders.length === 0"
class="text-body-2 text-medium-emphasis ma-0 pa-4"
data-testid="sms-senders-empty"
>
Заявок на согласовании нет.
</p>
<v-table v-else density="comfortable">
<thead>
<tr>
<th>Тенант</th>
<th>Имя</th>
<th>Тип</th>
<th class="text-right">/мес</th>
<th></th>
</tr>
</thead>
<tbody>
<template v-for="sender in senders" :key="sender.id">
<tr :data-testid="`sms-sender-row-${sender.id}`">
<td>{{ sender.tenant_id }}</td>
<td class="font-weight-medium">{{ sender.name }}</td>
<td>{{ nameTypeLabel(sender.name_type) }}</td>
<td class="text-right">{{ sender.monthly_fee_rub }}</td>
<td>
<div class="d-flex ga-2 justify-end flex-wrap">
<v-btn
v-if="sender.consent_doc_original_name"
size="small"
color="primary"
variant="text"
prepend-icon="mdi-file-sign"
:href="docUrl(sender.id, 'consent')"
target="_blank"
:data-testid="`sms-sender-consent-${sender.id}`"
>
Разрешение
</v-btn>
<v-btn
v-if="sender.doc_original_name"
size="small"
color="primary"
variant="text"
prepend-icon="mdi-paperclip"
:href="docUrl(sender.id, 'basis')"
target="_blank"
:data-testid="`sms-sender-doc-${sender.id}`"
>
Документ-основание
</v-btn>
<span
v-if="!sender.doc_original_name && !sender.consent_doc_original_name"
class="text-medium-emphasis text-caption align-self-center"
>нет документов</span>
<v-btn
size="small"
color="success"
variant="tonal"
:data-testid="`sms-sender-approve-${sender.id}`"
@click="approve(sender.id)"
>
Подтвердить
</v-btn>
<v-btn
size="small"
color="warning"
variant="tonal"
:data-testid="`sms-sender-reject-${sender.id}`"
@click="openReject(sender.id)"
>
Отклонить
</v-btn>
<v-btn
size="small"
color="error"
variant="tonal"
:data-testid="`sms-sender-disable-${sender.id}`"
@click="disable(sender.id)"
>
Отключить
</v-btn>
</div>
</td>
</tr>
<tr v-if="rejectOpenSenderId === sender.id" :data-testid="`sms-sender-reject-form-${sender.id}`">
<td colspan="5">
<div class="d-flex ga-2 align-center pa-2">
<v-text-field
v-model="rejectNoteBySenderId[sender.id]"
label="Причина отказа (необязательно)"
density="compact"
hide-details
:data-testid="`sms-sender-reject-note-${sender.id}`"
/>
<v-btn
color="warning"
size="small"
:data-testid="`sms-sender-reject-confirm-${sender.id}`"
@click="confirmReject(sender.id)"
>
Отклонить
</v-btn>
<v-btn size="small" variant="text" @click="cancelReject">Отмена</v-btn>
</div>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-card>
<!-- Блок 4: Общий стоп-лист портала (строки листа 1.4, 1.5) -->
<v-card variant="outlined" class="pa-4 mb-6" data-testid="global-optouts-card">
<h2 class="text-h6 mb-1">Общий стоп-лист</h2>
<p class="text-body-2 text-medium-emphasis mb-3">
Номер отсюда не получит СМС ни от одного клиента портала. Сюда вносим номера по
жалобам клиент увидит причину «номер закрыт администрацией».
</p>
<v-alert v-if="optoutsError" type="error" density="compact" class="mb-3">
{{ optoutsError }}
</v-alert>
<div class="d-flex align-start ga-3 mb-3 flex-wrap">
<v-text-field
v-model="optoutPhone"
label="Номер"
density="comfortable"
variant="outlined"
hide-details
style="min-width: 220px"
data-testid="global-optout-phone"
/>
<v-text-field
v-model="optoutReason"
label="Зачем закрываем (видно только нам)"
density="comfortable"
variant="outlined"
hide-details
style="min-width: 320px"
data-testid="global-optout-reason"
/>
<v-btn
color="primary"
:loading="optoutSaving"
data-testid="global-optout-add"
@click="addGlobalOptout"
>
Закрыть номер
</v-btn>
</div>
<v-progress-linear v-if="optoutsLoading" indeterminate color="primary" class="mb-3" />
<v-table density="compact" data-testid="global-optouts-table">
<thead>
<tr>
<th>Номер</th>
<th>Зачем</th>
<th class="text-right"></th>
</tr>
</thead>
<tbody>
<tr
v-for="row in globalOptouts"
:key="row.phone"
:data-testid="`global-optout-row-${row.phone}`"
>
<td>{{ row.phone }}</td>
<td>{{ row.reason ?? '—' }}</td>
<td class="text-right">
<v-btn
icon="mdi-delete-outline"
variant="text"
size="small"
color="error"
:data-testid="`global-optout-delete-${row.phone}`"
@click="removeGlobalOptout(row.phone)"
/>
</td>
</tr>
<tr v-if="!optoutsLoading && globalOptouts.length === 0">
<td colspan="3" class="text-center text-medium-emphasis pa-6">
Закрытых номеров нет
</td>
</tr>
</tbody>
</v-table>
</v-card>
</v-container>
</template>
<style scoped>
.admin-sms {
max-width: 1400px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.tariff-table {
width: 100%;
border-collapse: collapse;
}
.tariff-table th,
.tariff-table td {
padding: 4px 8px;
text-align: left;
}
</style>