667befde96
A11y rescan Pattern A — Vuetify <v-app-bar-nav-icon class="d-md-none"> без accessible name. Pa11y/axe видит button в DOM даже на desktop где он hidden via CSS — флагает «button-name» violation на 9 AppLayout views (/dashboard, /deals, /kanban, /projects, /billing, /settings, /reports, /reminders, /admin/tenants). Fix: AppTopbar.vue:90-94 — `aria-label="Открыть меню навигации"`. Closes 9 of 14 authenticated routes' a11y violations (down 14→5 affected URLs after this commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
9.3 KiB
Vue
284 lines
9.3 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): topbar выделен из AppLayout.
|
||
* Crumb + search-trigger (заглушка ⌘K) + bell с notifications dropdown + user-chip menu.
|
||
* Stores: auth + notifications (используются напрямую, без prop-drilling).
|
||
*/
|
||
import { computed } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import { useNotificationsStore } from '../../stores/notifications';
|
||
|
||
defineProps<{
|
||
pageTitle: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
'toggle-drawer': [];
|
||
}>();
|
||
|
||
const auth = useAuthStore();
|
||
const notifications = useNotificationsStore();
|
||
const router = useRouter();
|
||
|
||
const unreadDisplay = computed(() => {
|
||
if (notifications.unreadCount === 0) return '';
|
||
if (notifications.unreadCount > 99) return '99+';
|
||
return String(notifications.unreadCount);
|
||
});
|
||
|
||
function eventIcon(event: string): string {
|
||
const map: Record<string, string> = {
|
||
new_lead: 'mdi-account-plus-outline',
|
||
reminder: 'mdi-clock-outline',
|
||
low_balance: 'mdi-wallet-outline',
|
||
zero_balance: 'mdi-alert-circle-outline',
|
||
topup_success: 'mdi-cash-plus',
|
||
invoice_paid: 'mdi-receipt-text-check-outline',
|
||
new_device_login: 'mdi-shield-account-outline',
|
||
marketing: 'mdi-bullhorn-outline',
|
||
};
|
||
return map[event] ?? 'mdi-bell-outline';
|
||
}
|
||
|
||
function formatRelative(iso: string | null): string {
|
||
if (!iso) return '';
|
||
const ms = Date.now() - new Date(iso).getTime();
|
||
const min = Math.floor(ms / 60_000);
|
||
if (min < 1) return 'только что';
|
||
if (min < 60) return `${min} мин назад`;
|
||
const hr = Math.floor(min / 60);
|
||
if (hr < 24) return `${hr} ч назад`;
|
||
const days = Math.floor(hr / 24);
|
||
return `${days} д назад`;
|
||
}
|
||
|
||
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
|
||
await notifications.markRead(id);
|
||
if (dealId !== null) {
|
||
// На MVP — push на DealsView (deep-link на конкретный drawer — отдельный коммит).
|
||
await router.push('/deals');
|
||
}
|
||
}
|
||
|
||
const userInitials = computed(() => {
|
||
const u = auth.user;
|
||
if (!u) return '?';
|
||
const first = u.first_name?.[0] ?? '';
|
||
const last = u.last_name?.[0] ?? '';
|
||
const initials = (first + last).toUpperCase();
|
||
return initials || u.email.slice(0, 2).toUpperCase();
|
||
});
|
||
|
||
const userShortName = computed(() => {
|
||
const u = auth.user;
|
||
if (!u) return 'Гость';
|
||
if (u.first_name && u.last_name) {
|
||
return `${u.first_name} ${u.last_name[0]}.`;
|
||
}
|
||
return u.first_name || u.email;
|
||
});
|
||
|
||
async function handleLogout(): Promise<void> {
|
||
await auth.logout();
|
||
await router.push('/login');
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
|
||
<v-app-bar-nav-icon
|
||
class="d-md-none"
|
||
aria-label="Открыть меню навигации"
|
||
@click="emit('toggle-drawer')"
|
||
/>
|
||
|
||
<div class="crumb">
|
||
<strong>{{ pageTitle }}</strong>
|
||
</div>
|
||
|
||
<v-spacer />
|
||
|
||
<v-btn variant="outlined" size="small" prepend-icon="mdi-magnify" class="searchbar mr-2" disabled>
|
||
Поиск
|
||
<template #append>
|
||
<kbd class="search-kbd">⌘K</kbd>
|
||
</template>
|
||
</v-btn>
|
||
|
||
<v-menu offset="8" :close-on-content-click="false" location="bottom end">
|
||
<template #activator="{ props: bellProps }">
|
||
<v-btn
|
||
v-bind="bellProps"
|
||
icon
|
||
size="small"
|
||
variant="text"
|
||
aria-label="Уведомления"
|
||
data-testid="notifications-btn"
|
||
>
|
||
<v-icon>mdi-bell-outline</v-icon>
|
||
<span
|
||
v-if="notifications.unreadCount > 0"
|
||
class="notification-pip"
|
||
data-testid="notifications-pip"
|
||
aria-hidden="true"
|
||
>
|
||
{{ unreadDisplay }}
|
||
</span>
|
||
</v-btn>
|
||
</template>
|
||
<v-card class="notifications-menu" min-width="360" max-width="420" elevation="3">
|
||
<div class="notifications-header">
|
||
<strong>Уведомления</strong>
|
||
<v-btn
|
||
v-if="notifications.unreadCount > 0"
|
||
size="x-small"
|
||
variant="text"
|
||
data-testid="mark-all-read-btn"
|
||
@click="notifications.markAllRead()"
|
||
>
|
||
Прочитать все
|
||
</v-btn>
|
||
</div>
|
||
<v-divider />
|
||
<div v-if="notifications.items.length === 0" class="notifications-empty">
|
||
<v-icon size="32" class="mb-2">mdi-bell-off-outline</v-icon>
|
||
<div>Нет уведомлений</div>
|
||
</div>
|
||
<v-list v-else density="compact" class="notifications-list" data-testid="notifications-list">
|
||
<v-list-item
|
||
v-for="item in notifications.sortedItems.slice(0, 10)"
|
||
:key="item.id"
|
||
:class="{ 'notification-unread': item.read_at === null }"
|
||
data-testid="notification-item"
|
||
@click="handleNotificationClick(item.id, item.deal_id)"
|
||
>
|
||
<template #prepend>
|
||
<v-icon :icon="eventIcon(item.event)" size="20" />
|
||
</template>
|
||
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||
<v-list-item-subtitle class="text-caption">
|
||
{{ item.body }}
|
||
</v-list-item-subtitle>
|
||
<template #append>
|
||
<span class="notification-time">{{ formatRelative(item.created_at) }}</span>
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card>
|
||
</v-menu>
|
||
|
||
<v-menu offset="8">
|
||
<template #activator="{ props }">
|
||
<v-btn v-bind="props" variant="text" size="small" class="user-chip ml-2" aria-label="Меню пользователя">
|
||
<v-avatar size="28" color="primary" class="mr-2">
|
||
<span class="text-caption">{{ userInitials }}</span>
|
||
</v-avatar>
|
||
<span class="text-body-2">{{ userShortName }}</span>
|
||
</v-btn>
|
||
</template>
|
||
<v-list density="compact" min-width="200">
|
||
<v-list-item v-if="auth.user" :title="auth.user.email" disabled />
|
||
<v-divider v-if="auth.user" />
|
||
<v-list-item :to="'/settings'" prepend-icon="mdi-cog-outline" title="Настройки" />
|
||
<v-list-item prepend-icon="mdi-logout" title="Выйти" @click="handleLogout" />
|
||
</v-list>
|
||
</v-menu>
|
||
</v-app-bar>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.app-topbar {
|
||
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%) !important;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||
color: #e8e2d4 !important;
|
||
}
|
||
.app-topbar :deep(.v-toolbar__content) {
|
||
padding-left: 240px;
|
||
color: #e8e2d4;
|
||
}
|
||
.app-topbar :deep(.v-icon) {
|
||
color: #b8b0a0;
|
||
}
|
||
.crumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 14px;
|
||
margin-left: 8px;
|
||
color: #e8e2d4;
|
||
}
|
||
.crumb strong {
|
||
color: var(--liderra-ivory);
|
||
font-weight: 600;
|
||
}
|
||
.searchbar {
|
||
text-transform: none;
|
||
color: #b8b0a0 !important;
|
||
border-color: rgba(255, 255, 255, 0.12) !important;
|
||
}
|
||
.search-kbd {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-size: 10px;
|
||
padding: 1px 5px;
|
||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||
border-radius: 3px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: #9b9484;
|
||
margin-left: 6px;
|
||
}
|
||
.user-chip :deep(.v-btn__content) {
|
||
color: #e8e2d4;
|
||
}
|
||
.notification-pip {
|
||
position: absolute;
|
||
top: 4px;
|
||
right: 4px;
|
||
min-width: 16px;
|
||
height: 16px;
|
||
padding: 0 5px;
|
||
border-radius: 8px;
|
||
background: #b94837;
|
||
color: #fff;
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
font-size: 10px;
|
||
line-height: 16px;
|
||
text-align: center;
|
||
font-weight: 600;
|
||
}
|
||
.notifications-menu {
|
||
background: #ffffff;
|
||
}
|
||
.notifications-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
}
|
||
.notifications-empty {
|
||
padding: 32px 16px;
|
||
text-align: center;
|
||
color: #66635c;
|
||
font-size: 13px;
|
||
}
|
||
.notifications-list {
|
||
max-height: 420px;
|
||
overflow-y: auto;
|
||
}
|
||
.notification-unread {
|
||
background: #f0f8f5;
|
||
}
|
||
.notification-time {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
font-size: 11px;
|
||
color: #66635c;
|
||
white-space: nowrap;
|
||
margin-left: 8px;
|
||
}
|
||
.user-chip {
|
||
text-transform: none;
|
||
}
|
||
</style>
|