37493b7d02
Вкладка «Не писать этим» отдельной панелью SmsOptoutsPanel.vue: номер руками, пачкой из файла, удаление; непонятые строки показаны образцами, а не молча. Кнопка «Остановить» видна, пока рассылка в очереди, идёт или ждёт утра; после остановки строка показывает честный итог «Ушло N из M, списано X ₽» — деньги фактические, а не смета. Ключ заказа crypto.randomUUID() уходит с каждой отправкой и меняется после успеха. На ответ сервера «похоже, это повтор» экран задаёт вопрос словами сервера и повторяет только по согласию человека. В админке раздел «Общий стоп-лист»: список, внесение с причиной, удаление. Отказ получателя (страница по ссылке и приписка в тексте) в экранах отсутствует — отменён владельцем, см. В-30 приёмочного листа. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
661 lines
26 KiB
Vue
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>
|