Files
portal/app/resources/js/components/layout/AppTopbar.vue
T
Дмитрий bc24420ad4 style(ui): Sprint 5B — prettier-формат затронутых файлов
Регрессия full: prettier --check на 5 файлах, тронутых Sprint 5B
(T2/T3/T4). Whitespace-only, 0 изменений поведения — Vitest 67/67
на затронутых спеках. Pre-existing prettier-дрейф 28 НЕ-5B файлов
оставлен (вне scope спринта).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:03:36 +03:00

289 lines
9.5 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';
import { useCommandPalette } from '../../composables/useCommandPalette';
defineProps<{
pageTitle: string;
}>();
const emit = defineEmits<{
'toggle-drawer': [];
}>();
const auth = useAuthStore();
const notifications = useNotificationsStore();
const router = useRouter();
const { openPalette } = useCommandPalette();
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) {
// Audit F3: deep-link на конкретный drawer через ?openId=.
await router.push({ path: '/deals', query: { openId: dealId } });
}
}
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"
data-testid="topbar-search-btn"
@click="openPalette"
>
Поиск
<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>