Files
portal/app/resources/js/router/index.ts
T
Дмитрий 56ffdef079 feat(sales): экран «Менеджеры отдела» (фронт)
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>
2026-07-02 21:40:59 +03:00

516 lines
19 KiB
TypeScript
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.
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;
});