5f209a2fcc
Раунд 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>
266 lines
9.1 KiB
Vue
266 lines
9.1 KiB
Vue
<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>
|