148 lines
5.9 KiB
Vue
148 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Default-layout для авторизованных страниц (Dashboard, Сделки, Канбан и т.п.).
|
|
*
|
|
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост, R0.6 hard-стоп снят явным
|
|
* запросом заказчика 10.05.2026): структурно разделён на components/layout/
|
|
* {AppSidebar, AppTopbar}.vue. Лoad-cycle (notifications polling)
|
|
* + drawer state остаются здесь.
|
|
*
|
|
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
|
|
*/
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { RouterView, useRoute, useRouter } from 'vue-router';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useNotificationsStore } from '../stores/notifications';
|
|
import { useTenantStore } from '../stores/tenantStore';
|
|
import { usePolling } from '../composables/usePolling';
|
|
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
|
|
import AppSidebar from '../components/layout/AppSidebar.vue';
|
|
import AppTopbar from '../components/layout/AppTopbar.vue';
|
|
import AppBottomNav from '../components/layout/AppBottomNav.vue';
|
|
import AppMoreDrawer from '../components/layout/AppMoreDrawer.vue';
|
|
import { useLayoutTier } from '../composables/useLayoutTier';
|
|
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
|
import CommandPalette from '../components/layout/CommandPalette.vue';
|
|
import ChatWidget from '../components/support/ChatWidget.vue';
|
|
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
|
|
import ImpersonationSessionBanner from '../components/admin/ImpersonationSessionBanner.vue';
|
|
import WelcomeTour from '../components/layout/WelcomeTour.vue';
|
|
import GuidedTour from '../components/layout/GuidedTour.vue';
|
|
import { useTourLauncher } from '../composables/useTourLauncher';
|
|
|
|
const auth = useAuthStore();
|
|
const notifications = useNotificationsStore();
|
|
const tenant = useTenantStore();
|
|
const route = useRoute();
|
|
|
|
const { tier } = useLayoutTier();
|
|
const router = useRouter();
|
|
const tourLauncher = useTourLauncher(computed(() => route), router);
|
|
|
|
const drawerOpen = ref(true);
|
|
// «Ещё» на телефоне (разделы вне 5 нижних вкладок)
|
|
const moreOpen = ref(false);
|
|
|
|
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
|
|
// (sidebar и topbar — независимые, но navGroups совпадают по контракту).
|
|
const navItems = computed(() => [
|
|
{ title: 'Проекты', to: '/projects' },
|
|
{ title: 'Сделки', to: '/deals' },
|
|
{ title: 'Канбан', to: '/kanban' },
|
|
{ title: 'Дашборд', to: '/dashboard' },
|
|
{ title: 'Биллинг', to: '/billing' },
|
|
{ title: 'Отчёты', to: '/reports' },
|
|
{ title: 'Настройки', to: '/settings' },
|
|
]);
|
|
|
|
const currentPageTitle = computed(() => {
|
|
// Сначала короткий title из sidebar-nav (Дашборд/Сделки/…), затем — route.meta.title
|
|
// для страниц вне sidebar (напр. Импорт данных), и только потом fallback.
|
|
return (
|
|
navItems.value.find((i) => i.to === route.path)?.title ?? (route.meta.title as string | undefined) ?? 'Страница'
|
|
);
|
|
});
|
|
|
|
async function loadNotifications(): Promise<void> {
|
|
if (!auth.user) return;
|
|
await notifications.load(10);
|
|
}
|
|
|
|
async function loadBalanceStatus(): Promise<void> {
|
|
if (!auth.user) return;
|
|
await tenant.load();
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadNotifications();
|
|
void loadBalanceStatus();
|
|
void tourLauncher.checkQuery();
|
|
});
|
|
|
|
watch(
|
|
() => route.query.tour,
|
|
() => {
|
|
void tourLauncher.checkQuery();
|
|
},
|
|
);
|
|
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
|
|
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
|
|
</script>
|
|
|
|
<template>
|
|
<v-app>
|
|
<ImpersonationSessionBanner />
|
|
<AppSidebar v-model:drawer-open="drawerOpen" :tier="tier" />
|
|
<AppTopbar
|
|
:page-title="currentPageTitle"
|
|
@toggle-drawer="tier === 'phone' ? (moreOpen = !moreOpen) : (drawerOpen = !drawerOpen)"
|
|
/>
|
|
|
|
<v-main class="app-main" :data-tier="tier">
|
|
<BalanceFrozenBanner
|
|
:frozen="tenant.frozen"
|
|
:deficit-rub="tenant.deficitRub"
|
|
:deficit-leads="tenant.deficitLeads"
|
|
/>
|
|
<RouterView v-slot="{ Component, route: r }">
|
|
<Transition :name="(r.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
|
|
<component :is="Component" :key="r.fullPath" />
|
|
</Transition>
|
|
</RouterView>
|
|
</v-main>
|
|
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
|
|
<CommandPalette />
|
|
<ChatWidget />
|
|
<WelcomeTour />
|
|
|
|
<!-- Мобильная навигация: нижние вкладки + «Ещё» (только телефон) -->
|
|
<AppBottomNav v-if="tier === 'phone'" />
|
|
<AppMoreDrawer v-if="tier === 'phone'" v-model:open="moreOpen" />
|
|
<GuidedTour
|
|
v-if="tourLauncher.activeTour.value"
|
|
:steps="tourLauncher.activeTour.value.steps"
|
|
:active="true"
|
|
@finish="tourLauncher.finishTour()"
|
|
/>
|
|
</v-app>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.app-main {
|
|
background: #f6f3ec;
|
|
}
|
|
/* Отступ под навигацию по ярусу.
|
|
Десктоп — полный сайдбар 232px; планшет — узкая колонка-иконки (rail) 64px;
|
|
телефон — сайдбара нет, снизу панель-вкладки (запас под неё padding-bottom). */
|
|
.app-main[data-tier='desktop'] {
|
|
padding-left: 232px;
|
|
}
|
|
.app-main[data-tier='tablet'] {
|
|
padding-left: 64px;
|
|
}
|
|
.app-main[data-tier='phone'] {
|
|
padding-left: 0;
|
|
padding-bottom: 64px;
|
|
}
|
|
</style>
|