56ffdef079
Task 7.1b — SalesManagersView (начальник, #page-managers) поверх GET/POST /api/sales/managers. - Форма «Новый менеджер» (имя, e-mail, пароль, роль) → POST → snackbar + очистка + перезагрузка; ошибки (дубль email) через extractSalesErrorMessage. - Список менеджеров: роль-чип, клиентов, выплачено всего, статус (Активен/Отпуск). - api/sales.ts: listSalesManagers/createSalesManager. Роут /sales/managers со заглушки на экран. Vitest 6/6, фронт-набор 1089 без регрессий, ESLint чист. Фаза 7 завершена: начальник создаёт менеджеров (логин+пароль) и видит список отдела. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||
import { useAuthStore } from '../stores/auth';
|
||
import { useSalesAuthStore } from '../stores/salesAuth';
|
||
|
||
/**
|
||
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
|
||
* Laravel `Route::fallback` отдаёт SPA-shell для всех путей.
|
||
*
|
||
* Лейзи-импорты: каждый view подгружается отдельным chunk'ом.
|
||
*
|
||
* Auth-guard:
|
||
* - meta.requiresAuth=true → требует isAuthenticated, иначе redirect /login.
|
||
* - meta.guestOnly=true (auth-views) → если isAuthenticated, redirect /dashboard.
|
||
* - При первом заходе (cold start) — `fetchMe()` восстанавливает session-state.
|
||
*
|
||
* Dev-index badge: meta.devIndex/devLabel пробрасываются в layout-уровне
|
||
* (AppLayout/AuthLayout/AdminLayout) через `<DevIndexBadge>` для ручного фидбэка
|
||
* на localhost («элемент 16: бага X»). См. components/DevIndexBadge.vue.
|
||
*/
|
||
declare module 'vue-router' {
|
||
interface RouteMeta {
|
||
layout?: string;
|
||
title?: string;
|
||
requiresAuth?: boolean;
|
||
guestOnly?: boolean;
|
||
errorCode?: string;
|
||
devIndex?: number;
|
||
devLabel?: string;
|
||
transition?: string;
|
||
/** Портал продаж: требует salesAuth.token */
|
||
salesAuth?: boolean;
|
||
/** Портал продаж: только для начальника (role==='head') */
|
||
salesBossOnly?: boolean;
|
||
}
|
||
}
|
||
|
||
const routes: RouteRecordRaw[] = [
|
||
{
|
||
path: '/',
|
||
redirect: '/dashboard',
|
||
},
|
||
{
|
||
path: '/login',
|
||
name: 'login',
|
||
component: () => import('../views/auth/LoginView.vue'),
|
||
meta: { layout: 'auth', title: 'Вход', guestOnly: true, devIndex: 1, devLabel: 'Login' },
|
||
},
|
||
{
|
||
path: '/register',
|
||
name: 'register',
|
||
component: () => import('../views/auth/RegisterView.vue'),
|
||
meta: { layout: 'auth', title: 'Регистрация', guestOnly: true, devIndex: 2, devLabel: 'Register' },
|
||
},
|
||
{
|
||
path: '/confirm-email',
|
||
name: 'confirm-email',
|
||
component: () => import('../views/auth/ConfirmEmailView.vue'),
|
||
meta: { layout: 'auth', title: 'Подтверждение почты', guestOnly: true, devIndex: 6, devLabel: 'Confirm email' },
|
||
},
|
||
{
|
||
path: '/2fa',
|
||
name: '2fa',
|
||
component: () => import('../views/auth/TwoFactorView.vue'),
|
||
meta: { layout: 'auth', title: 'Двухфакторная проверка', devIndex: 3, devLabel: '2FA' },
|
||
},
|
||
{
|
||
path: '/forgot',
|
||
name: 'forgot',
|
||
component: () => import('../views/auth/ForgotPasswordView.vue'),
|
||
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true, devIndex: 4, devLabel: 'Forgot password' },
|
||
},
|
||
{
|
||
path: '/recovery-use',
|
||
name: 'recovery-use',
|
||
component: () => import('../views/auth/UseRecoveryCodeView.vue'),
|
||
meta: { layout: 'auth', title: 'Вход по резервному коду', devIndex: 7, devLabel: 'Use recovery' },
|
||
},
|
||
{
|
||
path: '/legal/:doc(offer|privacy|refund)',
|
||
name: 'legal',
|
||
component: () => import('../views/legal/LegalDocView.vue'),
|
||
meta: { layout: 'public', title: 'Правовые документы' },
|
||
},
|
||
{
|
||
path: '/pricing',
|
||
name: 'pricing',
|
||
component: () => import('../views/PricingView.vue'),
|
||
meta: { layout: 'public', title: 'Цены' },
|
||
},
|
||
{
|
||
path: '/reset/:token',
|
||
name: 'reset-password',
|
||
component: () => import('../views/auth/ResetPasswordView.vue'),
|
||
meta: { layout: 'auth', title: 'Новый пароль', guestOnly: true, devIndex: 5, devLabel: 'Reset password' },
|
||
},
|
||
{
|
||
path: '/dashboard',
|
||
name: 'dashboard',
|
||
component: () => import('../views/DashboardView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Дашборд',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 8,
|
||
devLabel: 'Dashboard',
|
||
},
|
||
},
|
||
{
|
||
path: '/deals',
|
||
name: 'deals',
|
||
component: () => import('../views/DealsView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Сделки',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 9,
|
||
devLabel: 'Сделки',
|
||
},
|
||
},
|
||
{
|
||
path: '/kanban',
|
||
name: 'kanban',
|
||
component: () => import('../views/KanbanView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Канбан',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 10,
|
||
devLabel: 'Канбан',
|
||
},
|
||
},
|
||
{
|
||
path: '/projects',
|
||
name: 'projects',
|
||
component: () => import('../views/ProjectsView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Проекты',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 16,
|
||
devLabel: 'Проекты',
|
||
},
|
||
},
|
||
{
|
||
path: '/billing',
|
||
name: 'billing',
|
||
component: () => import('../views/BillingView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Биллинг и тарифы',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 13,
|
||
devLabel: 'Биллинг',
|
||
},
|
||
},
|
||
{
|
||
path: '/settings',
|
||
name: 'settings',
|
||
component: () => import('../views/SettingsView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Настройки',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 14,
|
||
devLabel: 'Настройки',
|
||
},
|
||
},
|
||
{
|
||
path: '/reports',
|
||
name: 'reports',
|
||
component: () => import('../views/ReportsView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Отчёты',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 12,
|
||
devLabel: 'Отчёты',
|
||
},
|
||
},
|
||
{
|
||
path: '/import',
|
||
name: 'import',
|
||
component: () => import('../views/ImportView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Импорт данных',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 29,
|
||
devLabel: 'Импорт данных',
|
||
},
|
||
},
|
||
// Админка SaaS — отдельный layout с под-брендом ADMIN.
|
||
// TODO: дополнительный role-guard на super_admin.
|
||
{
|
||
path: '/admin',
|
||
redirect: '/admin/dashboard',
|
||
},
|
||
{
|
||
path: '/admin/dashboard',
|
||
name: 'admin-dashboard',
|
||
component: () => import('../views/admin/AdminDashboardView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Командный центр',
|
||
requiresAuth: true,
|
||
devIndex: 20,
|
||
devLabel: 'Admin Dashboard',
|
||
},
|
||
},
|
||
{
|
||
path: '/admin/tenants',
|
||
name: 'admin-tenants',
|
||
component: () => import('../views/admin/AdminTenantsView.vue'),
|
||
meta: { layout: 'admin', title: 'Тенанты', requiresAuth: true, devIndex: 21, devLabel: 'Admin Tenants' },
|
||
},
|
||
{
|
||
path: '/admin/tenants/:code',
|
||
name: 'admin-tenant-detail',
|
||
component: () => import('../views/admin/AdminTenantDetailView.vue'),
|
||
meta: { layout: 'admin', title: 'Тенант', requiresAuth: true, devIndex: 22, devLabel: 'Admin Tenant Detail' },
|
||
},
|
||
{
|
||
path: '/admin/billing',
|
||
name: 'admin-billing',
|
||
component: () => import('../views/admin/AdminBillingView.vue'),
|
||
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true, devIndex: 23, devLabel: 'Admin Billing' },
|
||
},
|
||
{
|
||
path: '/admin/invoices',
|
||
name: 'admin-invoices',
|
||
component: () => import('../views/admin/AdminInvoicesView.vue'),
|
||
meta: { layout: 'admin', title: 'Счета', requiresAuth: true, devLabel: 'Admin Invoices' },
|
||
},
|
||
{
|
||
path: '/admin/leads',
|
||
name: 'admin-leads',
|
||
component: () => import('../views/admin/AdminLeadsView.vue'),
|
||
meta: { layout: 'admin', title: 'Лиды', requiresAuth: true, devLabel: 'Admin Leads' },
|
||
},
|
||
{
|
||
path: '/admin/leads/:id',
|
||
name: 'admin-lead-detail',
|
||
component: () => import('../views/admin/AdminLeadDetailView.vue'),
|
||
meta: { layout: 'admin', title: 'Лид', requiresAuth: true, devLabel: 'Admin Lead Detail' },
|
||
},
|
||
{
|
||
path: '/admin/incidents',
|
||
name: 'admin-incidents',
|
||
component: () => import('../views/admin/AdminIncidentsView.vue'),
|
||
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
|
||
},
|
||
{
|
||
path: '/admin/incidents/:id',
|
||
name: 'admin-incident-detail',
|
||
component: () => import('../views/admin/AdminIncidentDetailView.vue'),
|
||
meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
|
||
},
|
||
{
|
||
path: '/admin/system',
|
||
name: 'admin-system',
|
||
component: () => import('../views/admin/AdminSystemView.vue'),
|
||
meta: { layout: 'admin', title: 'Система', requiresAuth: true, devIndex: 25, devLabel: 'Admin System' },
|
||
},
|
||
{
|
||
path: '/admin/pricing-tiers',
|
||
name: 'admin-pricing-tiers',
|
||
component: () => import('../views/admin/AdminPricingTiersView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Тарифная сетка',
|
||
requiresAuth: true,
|
||
devIndex: 27,
|
||
devLabel: 'Admin Pricing Tiers',
|
||
},
|
||
},
|
||
{
|
||
path: '/admin/supplier-prices',
|
||
name: 'admin-supplier-prices',
|
||
component: () => import('../views/admin/AdminSupplierPricesView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Цены поставщиков',
|
||
requiresAuth: true,
|
||
devIndex: 28,
|
||
devLabel: 'Admin Supplier Prices',
|
||
},
|
||
},
|
||
{
|
||
path: '/admin/impersonation',
|
||
name: 'admin-impersonation',
|
||
component: () => import('../views/admin/AdminImpersonationView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Impersonation',
|
||
requiresAuth: true,
|
||
devIndex: 26,
|
||
devLabel: 'Admin Impersonation',
|
||
},
|
||
},
|
||
{
|
||
path: '/admin/supplier-integration',
|
||
name: 'admin-supplier-integration',
|
||
component: () => import('../views/admin/AdminSupplierIntegrationView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Интеграция с поставщиком',
|
||
requiresAuth: true,
|
||
devIndex: 30,
|
||
devLabel: 'Admin Supplier Integration',
|
||
},
|
||
},
|
||
{
|
||
path: '/admin/supplier-projects',
|
||
name: 'admin-supplier-projects',
|
||
component: () => import('../views/admin/AdminSupplierProjectsView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Проекты у поставщика',
|
||
requiresAuth: true,
|
||
devIndex: 31,
|
||
devLabel: 'Admin Supplier Projects',
|
||
},
|
||
},
|
||
{
|
||
path: '/admin/pd-subject-requests',
|
||
name: 'admin-pd-subject-requests',
|
||
component: () => import('../views/admin/AdminPdSubjectRequestsView.vue'),
|
||
meta: {
|
||
layout: 'admin',
|
||
title: 'Обращения ПДн',
|
||
requiresAuth: true,
|
||
devIndex: 32,
|
||
devLabel: 'Admin PD Requests',
|
||
},
|
||
},
|
||
{
|
||
path: '/help',
|
||
name: 'help',
|
||
component: () => import('../views/HelpView.vue'),
|
||
meta: {
|
||
layout: 'app',
|
||
title: 'Помощь',
|
||
requiresAuth: true,
|
||
transition: 'ld-route-fadeup',
|
||
devIndex: 33,
|
||
devLabel: 'Помощь',
|
||
},
|
||
},
|
||
// ─── Портал отдела продаж (/sales) ───────────────────────────────────────
|
||
// Три группы:
|
||
// 1. /sales/login — страница входа (без guard'а).
|
||
// 2. Маршруты менеджера — требуют salesAuth.token.
|
||
// 3. Маршруты начальника — требуют salesAuth.role === 'head'.
|
||
// Guard реализован в beforeEach ниже через meta.salesAuth / meta.salesBossOnly.
|
||
{
|
||
path: '/sales/login',
|
||
name: 'sales-login',
|
||
component: () => import('../views/sales/SalesLoginView.vue'),
|
||
meta: { layout: 'sales-login', title: 'Вход — Отдел продаж' },
|
||
},
|
||
{
|
||
path: '/sales',
|
||
name: 'sales-overview',
|
||
component: () => import('../views/sales/SalesOverviewView.vue'),
|
||
meta: { layout: 'sales', title: 'Сводка', salesAuth: true },
|
||
},
|
||
{
|
||
path: '/sales/clients',
|
||
name: 'sales-clients',
|
||
component: () => import('../views/sales/SalesClientsView.vue'),
|
||
meta: { layout: 'sales', title: 'Мои клиенты', salesAuth: true },
|
||
},
|
||
{
|
||
path: '/sales/clients/:id',
|
||
name: 'sales-client-detail',
|
||
component: () => import('../views/sales/SalesClientCardView.vue'),
|
||
meta: { layout: 'sales', title: 'Карточка клиента', salesAuth: true },
|
||
props: true,
|
||
},
|
||
{
|
||
path: '/sales/attach',
|
||
name: 'sales-attach',
|
||
component: () => import('../views/sales/SalesAttachView.vue'),
|
||
meta: { layout: 'sales', title: 'Привязать клиента', salesAuth: true },
|
||
},
|
||
{
|
||
path: '/sales/income',
|
||
name: 'sales-income',
|
||
component: () => import('../views/sales/SalesIncomeView.vue'),
|
||
meta: { layout: 'sales', title: 'Мой доход', salesAuth: true },
|
||
},
|
||
// Маршруты начальника (boss-only)
|
||
{
|
||
path: '/sales/boss',
|
||
name: 'sales-boss',
|
||
component: () => import('../views/sales/SalesBossOverviewView.vue'),
|
||
meta: { layout: 'sales', title: 'Сводка отдела', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
{
|
||
path: '/sales/performance',
|
||
name: 'sales-performance',
|
||
component: () => import('../views/sales/SalesPerformanceView.vue'),
|
||
meta: { layout: 'sales', title: 'Результативность', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
{
|
||
path: '/sales/tariffs',
|
||
name: 'sales-tariffs',
|
||
component: () => import('../views/sales/SalesTariffsView.vue'),
|
||
meta: { layout: 'sales', title: 'Тарифы менеджеров', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
{
|
||
path: '/sales/invoices',
|
||
name: 'sales-invoices',
|
||
component: () => import('../views/sales/SalesInvoicesView.vue'),
|
||
meta: { layout: 'sales', title: 'Счета', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
{
|
||
path: '/sales/requests',
|
||
name: 'sales-requests',
|
||
component: () => import('../views/sales/SalesRequestsView.vue'),
|
||
meta: { layout: 'sales', title: 'Заявки на привязку', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
{
|
||
path: '/sales/payouts',
|
||
name: 'sales-payouts',
|
||
component: () => import('../views/sales/SalesPayoutsView.vue'),
|
||
meta: { layout: 'sales', title: 'Выплаты', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
{
|
||
path: '/sales/managers',
|
||
name: 'sales-managers',
|
||
component: () => import('../views/sales/SalesManagersView.vue'),
|
||
meta: { layout: 'sales', title: 'Менеджеры', salesAuth: true, salesBossOnly: true },
|
||
},
|
||
|
||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||
{
|
||
path: '/403',
|
||
name: 'forbidden',
|
||
component: () => import('../views/errors/ErrorView.vue'),
|
||
meta: { layout: 'error', errorCode: '403', title: 'Доступ запрещён', devIndex: 15, devLabel: 'Ошибка 403' },
|
||
},
|
||
{
|
||
path: '/500',
|
||
name: 'server-error',
|
||
component: () => import('../views/errors/ErrorView.vue'),
|
||
meta: { layout: 'error', errorCode: '500', title: 'Ошибка сервера', devIndex: 15, devLabel: 'Ошибка 500' },
|
||
},
|
||
{
|
||
path: '/:pathMatch(.*)*',
|
||
name: 'not-found',
|
||
component: () => import('../views/errors/ErrorView.vue'),
|
||
meta: { layout: 'error', errorCode: '404', title: 'Страница не найдена', devIndex: 15, devLabel: 'Ошибка 404' },
|
||
},
|
||
];
|
||
|
||
export const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes,
|
||
});
|
||
|
||
let authInitialized = false;
|
||
|
||
router.beforeEach(async (to) => {
|
||
const auth = useAuthStore();
|
||
|
||
// На первый переход — восстанавливаем session через /api/auth/me.
|
||
// Если cookie валиден — auth.user будет заполнен; иначе остаётся null.
|
||
// §6: на гостевых страницах (login/register/...) при холодном старте /me не
|
||
// дёргаем — у неавторизованного посетителя это давало 401-шум в консоли.
|
||
// authInitialized НЕ ставим — при переходе на защищённый роут сессия
|
||
// восстановится тогда (cold start защищённой страницы всё ещё зовёт /me).
|
||
if (!authInitialized && !to.meta.guestOnly) {
|
||
await auth.fetchMe();
|
||
authInitialized = true;
|
||
}
|
||
|
||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||
return { path: '/login', query: { redirect: to.fullPath } };
|
||
}
|
||
|
||
if (to.meta.guestOnly && auth.isAuthenticated) {
|
||
return { path: '/dashboard' };
|
||
}
|
||
|
||
// ─── Guard для портала отдела продаж ─────────────────────────────────────
|
||
if (to.meta.salesAuth) {
|
||
const salesAuth = useSalesAuthStore();
|
||
|
||
// Cold start: если токен есть в localStorage, но user не загружен — грузим.
|
||
if (salesAuth.token && !salesAuth.user) {
|
||
await salesAuth.fetchMe();
|
||
}
|
||
|
||
// Нет токена / нет user → /sales/login
|
||
if (!salesAuth.isAuthenticated) {
|
||
return { path: '/sales/login', query: { redirect: to.fullPath } };
|
||
}
|
||
|
||
// Boss-only маршрут: только начальник
|
||
if (to.meta.salesBossOnly && !salesAuth.isHead) {
|
||
return { path: '/sales' };
|
||
}
|
||
}
|
||
|
||
return true;
|
||
});
|