Files
portal/app/resources/js/views/settings/NotificationsTab.vue
T
Дмитрий 5f209a2fcc fix(ui): косметика UI-аудита — даты дд.мм.гггг, инлайн-валидация, формат денег, aria, тосты, статус-метки, админка
Раунд 2 минор-фиксы (Playwright-аудит):
- RuDateField (новый): даты дд.мм.гггг через ru date-picker вместо нативного
  <input type=date> (показывал мм/дд/гггг на en-локали) — Отчёты + Сделки.
- BalanceCapacityIndicator: разделитель тысяч «1 000 ₽», эмодзи→mdi.
- dealsApiMapper/DealDetailBody: статус-смена в активности русскими метками
  (было «viewed → new» сырыми слагами).
- ProfileTab: инлайн-валидация Имя/Фамилия (под полем, как в Реквизитах).
- RequisitesTab: проверка формата телефона на клиенте.
- ApiTab: eye-toggle с aria-label (показать/скрыть ключ и секрет).
- DashboardView: «3 / 0» → скрываем «/ N» и «лимит тарифа» при лимите 0.
- KanbanView: тост-подтверждение при смене статуса (+ цветной фейл-тост).
- NotificationsTab: убран жаргон «users.notification_preferences в БД».
- Админка: TenantsTable «ИНН не указан» вместо пустого «ИНН »; PricingTiers
  epoch-дата «1970»→«начала» + ru-формат цены; Incidents empty-state «Инцидентов
  нет»; SupplierIntegration/PdSubjectRequests — window.confirm/alert → v-dialog/snackbar.

Верификация: type-check, build, Playwright (даты дд.мм.гггг подтверждены).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:08:51 +03:00

266 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Settings → Уведомления. Матрица 7 событий × 3 канала (inapp/push/email)
* + sound_enabled. Соответствует schema.sql:699 users.notification_preferences.
*
* События (точно по schema-default):
* new_lead / low_balance / zero_balance /
* topup_success / invoice_paid / new_device_login / marketing.
*
* Каналы (точно по schema):
* inapp (bell-icon в UI) / push (web-push, Post-MVP) / email (Unisender Go).
*
* Push-канал на MVP пока не доставляется (требует ServiceWorker + VAPID), но
* preferences записываются для будущей активации без миграции данных.
*/
import { computed, ref, watch } from 'vue';
import { useAuthStore } from '../../stores/auth';
import {
type NotificationChannel,
type NotificationEventKey,
type NotificationPreferences,
updateNotificationPreferences,
} from '../../api/auth';
interface NotifEvent {
id: NotificationEventKey;
label: string;
description?: string;
}
const EVENTS: NotifEvent[] = [
{ id: 'new_lead', label: 'Новый лид', description: 'Поступил новый лид через webhook или manual-создание.' },
{ id: 'low_balance', label: 'Низкий баланс', description: 'Осталось < 3 дней работы по текущим ценам.' },
{ id: 'zero_balance', label: 'Нулевой баланс', description: 'Лиды отклоняются — нет средств для оплаты.' },
{ id: 'topup_success', label: 'Пополнение успешно', description: 'Платёж зачислен на баланс.' },
{ id: 'invoice_paid', label: 'Счёт оплачен', description: 'Тарифный счёт списан.' },
{ id: 'new_device_login', label: 'Новое устройство', description: 'Вход с незнакомого устройства / IP.' },
{ id: 'marketing', label: 'Анонсы и промо', description: 'Новости платформы, скидки, новые функции.' },
];
interface ChannelKey {
id: NotificationChannel;
label: string;
description: string;
}
const CHANNELS: ChannelKey[] = [
{ id: 'inapp', label: 'В приложении', description: 'Колокольчик в интерфейсе.' },
{ id: 'email', label: 'Email', description: 'На адрес в профиле.' },
];
const auth = useAuthStore();
function buildPrefs(): NotificationPreferences {
const userPrefs = auth.user?.notification_preferences ?? {};
const next: NotificationPreferences = {};
for (const e of EVENTS) {
next[e.id] = {};
for (const c of CHANNELS) {
const stored = userPrefs[e.id]?.[c.id];
next[e.id]![c.id] = typeof stored === 'boolean' ? stored : false;
}
}
return next;
}
// Инициализация синхронно в setup() — иначе v-if="prefs[e.id]" блокирует
// рендер чекбоксов до onMounted, и тесты mount-then-find падают.
const prefs = ref<NotificationPreferences>(buildPrefs());
const soundEnabled = ref<boolean>(auth.user?.sound_enabled ?? true);
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref(false);
// Snapshot оригинальных значений из auth.user — для определения dirty.
// Replacing с deep-clone чтобы JSON.stringify сравнение работало стабильно.
const originalPrefs = ref<NotificationPreferences>(buildPrefs());
const originalSound = ref<boolean>(auth.user?.sound_enabled ?? true);
const dirty = computed(
() =>
JSON.stringify(prefs.value) !== JSON.stringify(originalPrefs.value) ||
soundEnabled.value !== originalSound.value,
);
function readFromUser(): void {
prefs.value = buildPrefs();
soundEnabled.value = auth.user?.sound_enabled ?? true;
originalPrefs.value = buildPrefs();
originalSound.value = auth.user?.sound_enabled ?? true;
}
watch(() => auth.user?.id, readFromUser);
async function save(): Promise<void> {
if (saving.value) return;
saving.value = true;
saveSuccess.value = false;
saveError.value = false;
try {
const updatedUser = await updateNotificationPreferences({
prefs: prefs.value,
sound_enabled: soundEnabled.value,
});
// Синхронизируем auth.user без повторного /me-вызова.
auth.user = { ...auth.user!, ...updatedUser };
// Обновляем snapshot — dirty снова false.
originalPrefs.value = buildPrefs();
originalSound.value = auth.user.sound_enabled ?? true;
saveSuccess.value = true;
} catch {
saveError.value = true;
} finally {
saving.value = false;
}
}
function reset(): void {
readFromUser();
}
const canSave = computed(() => dirty.value && !saving.value && auth.isAuthenticated);
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Уведомления</h2>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="notifications-save-success"
@click:close="saveSuccess = false"
>
Настройки сохранены.
</v-alert>
<v-alert
v-if="saveError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="notifications-save-error"
@click:close="saveError = false"
>
Не удалось сохранить настройки. Попробуйте позже.
</v-alert>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">События × каналы</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Выберите, о каких событиях и какими каналами вас уведомлять.
</p>
<div class="prefs-table">
<div class="prefs-head">
<div class="prefs-cell event-col">Событие</div>
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col" :title="ch.description">
{{ ch.label }}
</div>
</div>
<div v-for="e in EVENTS" :key="e.id" class="prefs-row" :data-event="e.id">
<div class="prefs-cell event-col">
<div>{{ e.label }}</div>
<div v-if="e.description" class="event-desc">{{ e.description }}</div>
</div>
<div v-for="ch in CHANNELS" :key="ch.id" class="prefs-cell ch-col">
<v-checkbox
v-if="prefs[e.id]"
v-model="prefs[e.id]![ch.id]"
density="compact"
hide-details
color="primary"
:data-testid="`pref-${e.id}-${ch.id}`"
/>
</div>
</div>
</div>
</v-card>
<v-card variant="outlined" class="pa-4 mb-4">
<h3 class="text-subtitle-2 mb-3">Звуковые алерты</h3>
<v-switch
v-model="soundEnabled"
color="primary"
hide-details
data-testid="sound-enabled-switch"
label="Воспроизводить звук при новом in-app уведомлении"
/>
</v-card>
<div class="actions-row">
<v-btn color="primary" :loading="saving" :disabled="!canSave" data-testid="save-btn" @click="save">
Сохранить
</v-btn>
<v-btn variant="outlined" :disabled="!dirty || saving" data-testid="reset-btn" @click="reset">
Отменить
</v-btn>
</div>
</div>
</template>
<style scoped>
.tab-title {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
.prefs-table {
border: 1px solid #e8e3d6;
border-radius: 8px;
overflow: hidden;
}
.prefs-head,
.prefs-row {
display: grid;
grid-template-columns: 1fr 110px 130px;
align-items: center;
}
.prefs-head {
background: #f6f3ec;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #66635c;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.prefs-row {
border-top: 1px solid #f0ede4;
}
.prefs-cell {
padding: 10px 12px;
}
.event-col {
color: #081319;
font-size: 13px;
}
.event-desc {
font-size: 11px;
color: #66635c;
margin-top: 2px;
}
.ch-col {
text-align: center;
display: flex;
justify-content: center;
}
.actions-row {
display: flex;
gap: 12px;
margin-top: 8px;
}
</style>