Files
portal/app/resources/js/components/layout/AppTopbar.vue
T
Дмитрий 667befde96 fix(a11y): add aria-label to mobile nav-icon button (closes Pattern A)
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>
2026-05-14 10:06:52 +03:00

284 lines
9.3 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">
/**
* 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>