From 67ec34d619f72eb4f2ffb540a0c9afcd59d3e3d7 Mon Sep 17 00:00:00 2001 From: Gleb Korotkiy Date: Mon, 22 Jun 2026 12:33:23 +0300 Subject: [PATCH] refactor: align frontend with FSD architecture --- .gitea/workflows/ci-cd.yml | 12 +- .prettierignore | 2 + README.md | 106 +- bun.lock | 134 +- deploy/anal-front/compose.yml | 2 +- docs/architecture.md | 123 ++ docs/generated-api.md | 82 + ...s-competence-map-analytics-from-layouts.md | 32 +- ...s-competence-map-analytics-current-data.md | 30 +- docs/review.md | 1278 +++++++++++ docs/testing.md | 99 + orval.config.ts | 6 +- package.json | 6 + src/app/router/guards.ts | 32 + src/app/router/index.ts | 10 + src/app/router/routes.ts | 37 + src/app/styles/base.css | 123 ++ src/entities/user/model/useCurrentUser.ts | 4 +- src/main.ts | 2 +- src/pages/auth/model/index.ts | 2 + src/pages/auth/model/loginSchema.spec.ts | 23 + src/pages/auth/model/loginSchema.ts | 15 + src/pages/auth/model/redirect.ts | 7 + src/pages/auth/model/useAuthLogin.ts | 7 +- .../AuthPage/AuthLoginForm/AuthLoginForm.vue | 22 +- .../api/competenceMapAnalyticsApi.ts | 1 + src/pages/dashboard/api/index.ts | 4 + src/pages/dashboard/index.ts | 2 +- src/pages/dashboard/model/chartData.ts | 41 + src/pages/dashboard/model/constants.ts | 10 + .../dashboard/model/dashboardStore.spec.ts | 286 +++ src/pages/dashboard/model/dashboardStore.ts | 207 ++ src/pages/dashboard/model/emptyState.ts | 116 + src/pages/dashboard/model/error.ts | 25 + .../dashboard/model/filterDefinitions.ts | 67 + src/pages/dashboard/model/filterDraft.ts | 39 + src/pages/dashboard/model/filters.ts | 68 + src/pages/dashboard/model/formatters.ts | 69 + src/pages/dashboard/model/index.ts | 55 + src/pages/dashboard/model/queryKeys.ts | 9 + src/pages/dashboard/model/sorting.ts | 91 + src/pages/dashboard/model/tooltip.ts | 66 + src/pages/dashboard/model/types.ts | 155 ++ .../dashboard/ui/CompetenceMapFilters.vue | 284 +++ .../dashboard}/ui/CompetenceMapToolbar.vue | 0 src/pages/dashboard/ui/DashboardPage.vue | 487 +++++ .../dashboard/ui}/ReportsView.vue | 4 +- .../AssociationsCompositionPanel.vue | 124 ++ .../ui/components/ChartTooltipPortal.vue | 42 + .../ClusterSpecializationsPanel.vue | 67 + .../CompetenceMapFilterField.spec.ts | 107 + .../components/CompetenceMapFilterField.vue | 98 + .../ui/components/CompositionCard.vue | 79 + .../ui/components/DashboardKpiGrid.vue | 36 + .../ui/components/DashboardSkeletonGrid.vue | 39 + .../ui/components/DashboardStatePanel.vue | 19 + .../dashboard/ui/components/DashboardTabs.vue | 52 + .../ui/components/EmployeeStatsCard.vue | 29 + .../ui/components/RegionRankingCard.vue | 102 + .../ui/components/SezCompositionPanel.vue | 151 ++ .../components/TechnoparkCompositionPanel.vue | 156 ++ .../ui/components/TopIndustriesCard.vue | 197 ++ .../dashboard/ui/components/TrendCard.vue | 261 +++ src/pages/dashboard/ui/components/index.ts | 11 + src/pages/dashboard/ui/index.ts | 1 + src/pages/dashboard/ui/styles/dashboard.css | 5 + .../styles/sections/charts-and-industries.css | 323 +++ .../ui/styles/sections/composition.css | 366 ++++ .../dashboard/ui/styles/sections/layout.css | 494 +++++ .../ui/styles/sections/responsive.css | 171 ++ .../dashboard/ui/styles/sections/tables.css | 414 ++++ src/router/index.ts | 70 - src/shared/api/index.ts | 9 +- .../ui/components/AppHeader/AppHeader.vue | 18 +- .../AppHeader/components/AppHeaderActions.vue | 16 +- .../components/AppHeaderBranding.vue | 35 +- .../ui/components/AppSidebar/AppSidebar.vue | 122 +- src/shared/ui/components/AppSidebar/index.ts | 8 +- src/shared/ui/components/index.ts | 8 +- src/shared/ui/index.ts | 9 +- src/stores/dashboard.spec.ts | 43 - src/stores/dashboard.ts | 629 ------ src/style.css | 1898 +---------------- src/views/DashboardView.vue | 1712 --------------- src/widgets/competence-map-filters/index.ts | 1 - .../ui/CompetenceMapFilters.vue | 470 ---- .../competence-map-filters/ui/index.ts | 1 - src/widgets/competence-map-toolbar/index.ts | 1 - .../competence-map-toolbar/ui/index.ts | 1 - .../protected-app-layout/model/index.ts | 2 + .../useProtectedAppSidebarNavigation.ts} | 105 +- .../model/useProtectedLogout.ts} | 8 +- .../ui/ProtectedAppLayout.vue | 42 +- steiger.config.ts | 20 + vite.config.ts | 1 + 95 files changed, 7802 insertions(+), 5053 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/generated-api.md create mode 100644 docs/review.md create mode 100644 docs/testing.md create mode 100644 src/app/router/guards.ts create mode 100644 src/app/router/index.ts create mode 100644 src/app/router/routes.ts create mode 100644 src/app/styles/base.css create mode 100644 src/pages/auth/model/loginSchema.spec.ts create mode 100644 src/pages/auth/model/loginSchema.ts create mode 100644 src/pages/auth/model/redirect.ts create mode 100644 src/pages/dashboard/api/competenceMapAnalyticsApi.ts create mode 100644 src/pages/dashboard/api/index.ts create mode 100644 src/pages/dashboard/model/chartData.ts create mode 100644 src/pages/dashboard/model/constants.ts create mode 100644 src/pages/dashboard/model/dashboardStore.spec.ts create mode 100644 src/pages/dashboard/model/dashboardStore.ts create mode 100644 src/pages/dashboard/model/emptyState.ts create mode 100644 src/pages/dashboard/model/error.ts create mode 100644 src/pages/dashboard/model/filterDefinitions.ts create mode 100644 src/pages/dashboard/model/filterDraft.ts create mode 100644 src/pages/dashboard/model/filters.ts create mode 100644 src/pages/dashboard/model/formatters.ts create mode 100644 src/pages/dashboard/model/index.ts create mode 100644 src/pages/dashboard/model/queryKeys.ts create mode 100644 src/pages/dashboard/model/sorting.ts create mode 100644 src/pages/dashboard/model/tooltip.ts create mode 100644 src/pages/dashboard/model/types.ts create mode 100644 src/pages/dashboard/ui/CompetenceMapFilters.vue rename src/{widgets/competence-map-toolbar => pages/dashboard}/ui/CompetenceMapToolbar.vue (100%) create mode 100644 src/pages/dashboard/ui/DashboardPage.vue rename src/{views => pages/dashboard/ui}/ReportsView.vue (98%) create mode 100644 src/pages/dashboard/ui/components/AssociationsCompositionPanel.vue create mode 100644 src/pages/dashboard/ui/components/ChartTooltipPortal.vue create mode 100644 src/pages/dashboard/ui/components/ClusterSpecializationsPanel.vue create mode 100644 src/pages/dashboard/ui/components/CompetenceMapFilterField.spec.ts create mode 100644 src/pages/dashboard/ui/components/CompetenceMapFilterField.vue create mode 100644 src/pages/dashboard/ui/components/CompositionCard.vue create mode 100644 src/pages/dashboard/ui/components/DashboardKpiGrid.vue create mode 100644 src/pages/dashboard/ui/components/DashboardSkeletonGrid.vue create mode 100644 src/pages/dashboard/ui/components/DashboardStatePanel.vue create mode 100644 src/pages/dashboard/ui/components/DashboardTabs.vue create mode 100644 src/pages/dashboard/ui/components/EmployeeStatsCard.vue create mode 100644 src/pages/dashboard/ui/components/RegionRankingCard.vue create mode 100644 src/pages/dashboard/ui/components/SezCompositionPanel.vue create mode 100644 src/pages/dashboard/ui/components/TechnoparkCompositionPanel.vue create mode 100644 src/pages/dashboard/ui/components/TopIndustriesCard.vue create mode 100644 src/pages/dashboard/ui/components/TrendCard.vue create mode 100644 src/pages/dashboard/ui/components/index.ts create mode 100644 src/pages/dashboard/ui/index.ts create mode 100644 src/pages/dashboard/ui/styles/dashboard.css create mode 100644 src/pages/dashboard/ui/styles/sections/charts-and-industries.css create mode 100644 src/pages/dashboard/ui/styles/sections/composition.css create mode 100644 src/pages/dashboard/ui/styles/sections/layout.css create mode 100644 src/pages/dashboard/ui/styles/sections/responsive.css create mode 100644 src/pages/dashboard/ui/styles/sections/tables.css delete mode 100644 src/router/index.ts delete mode 100644 src/stores/dashboard.spec.ts delete mode 100644 src/stores/dashboard.ts delete mode 100644 src/views/DashboardView.vue delete mode 100644 src/widgets/competence-map-filters/index.ts delete mode 100644 src/widgets/competence-map-filters/ui/CompetenceMapFilters.vue delete mode 100644 src/widgets/competence-map-filters/ui/index.ts delete mode 100644 src/widgets/competence-map-toolbar/index.ts delete mode 100644 src/widgets/competence-map-toolbar/ui/index.ts rename src/{shared/ui/components/AppSidebar/useAppSidebarNavigation.ts => widgets/protected-app-layout/model/useProtectedAppSidebarNavigation.ts} (75%) rename src/{shared/api/useLogout.ts => widgets/protected-app-layout/model/useProtectedLogout.ts} (78%) create mode 100644 steiger.config.ts diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 54aaf9f..4edd651 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -4,8 +4,8 @@ on: push: branches: - dev - - "feature/**" - - "codex/**" + - 'feature/**' + - 'codex/**' pull_request: branches: - dev @@ -16,9 +16,9 @@ concurrency: cancel-in-progress: true env: - WEB_IMAGE: "anal-front-web" - DEPLOY_DOMAIN: "anal.dev.nii-ecos.ru" - DEPLOY_PATH: "/opt/anal-front" + WEB_IMAGE: 'anal-front-web' + DEPLOY_DOMAIN: 'anal.dev.nii-ecos.ru' + DEPLOY_PATH: '/opt/anal-front' jobs: quality: @@ -87,7 +87,7 @@ jobs: - name: E2E env: - CI: "1" + CI: '1' PLAYWRIGHT_HTML_OPEN: never run: bun run test:e2e -- --reporter=line diff --git a/.prettierignore b/.prettierignore index 7305614..7751735 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ playwright-report test-results node_modules bun.lock +src/shared/api/generated-api +src/shared/model/generated-zod diff --git a/README.md b/README.md index 90567f9..d7d8c63 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,116 @@ # Analytical Panel -Vue 3 application scaffolded with Vite and Bun. +Frontend-приложение аналитической панели карты компетенций. ## Stack -- Vue 3 +- Vue 3 + `')).toBe( + '<script>alert("x")</script>', + ) + }) +}) diff --git a/src/pages/dashboard/model/dashboardStore.ts b/src/pages/dashboard/model/dashboardStore.ts new file mode 100644 index 0000000..7d5e227 --- /dev/null +++ b/src/pages/dashboard/model/dashboardStore.ts @@ -0,0 +1,207 @@ +import { computed, ref, watch } from 'vue' +import { useQuery } from '@tanstack/vue-query' +import { defineStore } from 'pinia' +import { getCompetenceMapAnalytics, getCompetenceMapAnalyticsFilters } from '../api' +import { + createCompositionDonutColors, + createCompositionDonutData, + createTrendChartData, + createTrendYearLabels, +} from './chartData' +import { DASHBOARD_QUERY_GC_TIME, DASHBOARD_QUERY_STALE_TIME } from './constants' +import { createEmptyAnalyticsResponse, createEmptyFiltersResponse } from './emptyState' +import { resolveApiErrorMessage } from './error' +import { + defaultAnalyticsFilters, + normalizeAnalyticsFilters, + normalizeAppliedFilters, + toApiParams, +} from './filters' +import { dashboardAnalyticsQueryKeys } from './queryKeys' +import type { + AnalyticsFilters, + AssociationTypeFilterOption, + CompetenceMapAnalyticsFiltersResponse, + CompetenceMapAnalyticsResponse, +} from './types' + +export { + createCompositionDonutColors, + createCompositionDonutData, + createTrendChartData, + createTrendYearLabels, +} from './chartData' +export { associationTypeColorMap } from './constants' +export { + createEmptyAnalyticsResponse, + createEmptyFiltersResponse, + createEmptyMainBlock, + createEmptyTopIndustries, +} from './emptyState' +export { resolveApiErrorMessage } from './error' +export { + defaultAnalyticsFilters, + normalizeAnalyticsFilters, + normalizeAppliedFilters, + toApiParams, +} from './filters' +export { dashboardAnalyticsQueryKeys } from './queryKeys' +export type * from './types' + +export const useDashboardStore = defineStore('dashboard', () => { + const requestedFilters = ref({ ...defaultAnalyticsFilters }) + const apiParams = computed(() => toApiParams(requestedFilters.value)) + + const analyticsQuery = useQuery({ + queryKey: computed(() => dashboardAnalyticsQueryKeys.analytics(requestedFilters.value)), + queryFn: ({ signal }) => getCompetenceMapAnalytics(apiParams.value, { signal }), + staleTime: DASHBOARD_QUERY_STALE_TIME, + gcTime: DASHBOARD_QUERY_GC_TIME, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }) + + const filtersQuery = useQuery({ + queryKey: computed(() => dashboardAnalyticsQueryKeys.filters(requestedFilters.value)), + queryFn: ({ signal }) => getCompetenceMapAnalyticsFilters(apiParams.value, { signal }), + staleTime: DASHBOARD_QUERY_STALE_TIME, + gcTime: DASHBOARD_QUERY_GC_TIME, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + }) + + const analyticsQueryResponse = computed(() => + analyticsQuery.data.value?.status === 200 + ? (analyticsQuery.data.value.data as CompetenceMapAnalyticsResponse) + : null, + ) + + const filtersQueryResponse = computed(() => + filtersQuery.data.value?.status === 200 + ? (filtersQuery.data.value.data as CompetenceMapAnalyticsFiltersResponse) + : null, + ) + const analyticsResponse = computed(() => analyticsQueryResponse.value) + const filtersApiResponse = computed(() => filtersQueryResponse.value) + const cachedAssociationTypes = ref([]) + + watch( + filtersApiResponse, + (response) => { + if (response) { + cachedAssociationTypes.value = response.association_types + } + }, + { immediate: true }, + ) + + const analytics = computed( + () => analyticsResponse.value ?? createEmptyAnalyticsResponse(requestedFilters.value), + ) + + const filtersResponse = computed(() => { + if (filtersApiResponse.value) { + return filtersApiResponse.value + } + + return { + ...createEmptyFiltersResponse( + analyticsResponse.value?.applied_filters ?? requestedFilters.value, + ), + association_types: cachedAssociationTypes.value, + } + }) + + const appliedFilters = computed(() => + normalizeAppliedFilters( + analyticsResponse.value?.applied_filters ?? + filtersApiResponse.value?.applied_filters ?? + requestedFilters.value, + ), + ) + const kpis = computed(() => analytics.value.kpis) + const mainBlock = computed(() => analytics.value.main_block) + const regionRanking = computed(() => analytics.value.region_ranking) + const topIndustries = computed(() => analytics.value.top_industries) + const trendBlock = computed(() => analytics.value.trend_block) + const employeeStats = computed(() => analytics.value.employee_stats) + + const associationsCompositionBlock = computed(() => + mainBlock.value.type === 'all_associations_composition' ? mainBlock.value : null, + ) + const compositionDonutData = computed(() => + createCompositionDonutData(associationsCompositionBlock.value), + ) + const compositionDonutColors = computed(() => + createCompositionDonutColors(associationsCompositionBlock.value), + ) + const trendChartData = computed(() => createTrendChartData(trendBlock.value)) + const trendYearLabels = computed(() => createTrendYearLabels(trendBlock.value)) + + const isLoading = computed(() => analyticsQuery.isPending.value || filtersQuery.isPending.value) + const isFetching = computed( + () => analyticsQuery.isFetching.value || filtersQuery.isFetching.value, + ) + const isAnalyticsFetching = computed(() => analyticsQuery.isFetching.value) + const isFiltersFetching = computed(() => filtersQuery.isFetching.value) + const isError = computed(() => analyticsQuery.isError.value || filtersQuery.isError.value) + const errorMessage = computed(() => { + if (analyticsQuery.error.value) { + return resolveApiErrorMessage(analyticsQuery.error.value) + } + + if (filtersQuery.error.value) { + return resolveApiErrorMessage(filtersQuery.error.value) + } + + return '' + }) + const hasAnalyticsData = computed(() => analyticsResponse.value !== null) + const hasFiltersData = computed(() => filtersApiResponse.value !== null) + + function applyFilters(filters: Partial) { + requestedFilters.value = normalizeAnalyticsFilters({ + ...appliedFilters.value, + ...filters, + }) + } + + function resetFilters() { + requestedFilters.value = { ...defaultAnalyticsFilters } + } + + function refetch() { + void analyticsQuery.refetch() + void filtersQuery.refetch() + } + + return { + analytics, + appliedFilters, + associationsCompositionBlock, + compositionDonutColors, + compositionDonutData, + employeeStats, + errorMessage, + filtersResponse, + hasAnalyticsData, + hasFiltersData, + isAnalyticsFetching, + isError, + isFiltersFetching, + isFetching, + isLoading, + kpis, + mainBlock, + regionRanking, + resetFilters, + topIndustries, + trendBlock, + trendChartData, + trendYearLabels, + applyFilters, + refetch, + } +}) diff --git a/src/pages/dashboard/model/emptyState.ts b/src/pages/dashboard/model/emptyState.ts new file mode 100644 index 0000000..eef71c6 --- /dev/null +++ b/src/pages/dashboard/model/emptyState.ts @@ -0,0 +1,116 @@ +import type { + AnalyticsFilters, + AssociationType, + CompetenceMapAnalyticsFiltersResponse, + CompetenceMapAnalyticsResponse, + MainBlock, + SortDescriptor, + TopIndustriesBlock, +} from './types' + +const emptySort: SortDescriptor = { + field: 'rank', + direction: 'asc', +} + +export const createEmptyMainBlock = (associationType: AssociationType): MainBlock => { + switch (associationType) { + case 'sez': + return { + type: 'sez_types_and_priority_directions', + title: '', + sez_types: { + title: '', + total_sez_count: 0, + segments: [], + }, + priority_directions: { + title: '', + sort: emptySort, + ranking_method: 'ordinal', + rows: [], + }, + } + case 'technoparks': + return { + type: 'technopark_area_distribution', + title: '', + total_technoparks_count: 0, + area_segments: [], + technoparks: { + sort: emptySort, + rows: [], + }, + } + case 'clusters': + return { + type: 'cluster_specializations', + title: '', + specializations: { + sort: emptySort, + ranking_method: 'ordinal', + rows: [], + }, + } + case 'all': + return { + type: 'all_associations_composition', + title: '', + total_associations_count: 0, + segments: [], + } + } +} + +export const createEmptyTopIndustries = (associationType: AssociationType): TopIndustriesBlock => { + if (associationType === 'all') { + return { + title: '', + mode: 'stacked_by_association_type', + association_type: null, + max_value: 0, + rows: [], + } + } + + return { + title: '', + mode: 'single_association_type', + association_type: associationType, + max_value: 0, + rows: [], + } +} + +export const createEmptyAnalyticsResponse = ( + filters: AnalyticsFilters, +): CompetenceMapAnalyticsResponse => ({ + generated_at: '', + data_actual_at: '', + association_type: filters.association_type, + applied_filters: filters, + kpis: [], + main_block: createEmptyMainBlock(filters.association_type), + region_ranking: { + title: '', + sort: emptySort, + ranking_method: 'ordinal', + rows: [], + }, + top_industries: createEmptyTopIndustries(filters.association_type), + trend_block: null, + employee_stats: null, +}) + +export const createEmptyFiltersResponse = ( + filters: AnalyticsFilters, +): CompetenceMapAnalyticsFiltersResponse => ({ + generated_at: '', + applied_filters: filters, + association_types: [], + federal_districts: [], + regions: [], + industries: [], + integrated_structures: [], + organizations: [], +}) diff --git a/src/pages/dashboard/model/error.ts b/src/pages/dashboard/model/error.ts new file mode 100644 index 0000000..4bef7d4 --- /dev/null +++ b/src/pages/dashboard/model/error.ts @@ -0,0 +1,25 @@ +import { isAxiosError } from 'axios' + +export const resolveApiErrorMessage = (error: unknown): string => { + if (isAxiosError(error)) { + const detail = error.response?.data?.detail + + if (typeof detail === 'string' && detail.length > 0) { + return detail + } + + if (error.response?.status === 401) { + return 'Не удалось загрузить данные: требуется авторизация.' + } + + if (error.response?.status) { + return `Не удалось загрузить данные. Код ответа: ${error.response.status}.` + } + } + + if (error instanceof Error && error.message.length > 0) { + return error.message + } + + return 'Не удалось загрузить данные.' +} diff --git a/src/pages/dashboard/model/filterDefinitions.ts b/src/pages/dashboard/model/filterDefinitions.ts new file mode 100644 index 0000000..935b7b2 --- /dev/null +++ b/src/pages/dashboard/model/filterDefinitions.ts @@ -0,0 +1,67 @@ +import { ALL_FILTER_VALUE, type DashboardDialogFilterKey } from './filterDraft' +import type { AnalyticsFilterOptionBase, CompetenceMapAnalyticsFiltersResponse } from './types' + +export interface DashboardFilterOption { + disabled: boolean + label: string + value: string +} + +export interface DashboardFilterDefinition { + id: DashboardDialogFilterKey + label: string + options: DashboardFilterOption[] + placeholder: string +} + +const allFilterOption: DashboardFilterOption = { + label: 'Все', + value: ALL_FILTER_VALUE, + disabled: false, +} + +const toDashboardFilterOption = (option: AnalyticsFilterOptionBase): DashboardFilterOption => ({ + label: option.label, + value: option.id, + disabled: option.disabled, +}) + +const withAllOption = (options: AnalyticsFilterOptionBase[]): DashboardFilterOption[] => [ + allFilterOption, + ...options.map(toDashboardFilterOption), +] + +export const createDashboardFilterDefinitions = ( + filtersResponse: CompetenceMapAnalyticsFiltersResponse, +): DashboardFilterDefinition[] => [ + { + id: 'federal_district_id', + label: 'Федеральный округ', + placeholder: 'Выберите округ', + options: withAllOption(filtersResponse.federal_districts), + }, + { + id: 'region_id', + label: 'Субъект', + placeholder: 'Выберите субъект', + options: withAllOption(filtersResponse.regions), + }, + { + id: 'industry_id', + label: 'Отрасль', + placeholder: 'Выберите отрасль', + options: withAllOption(filtersResponse.industries), + }, + { + id: 'integrated_structure_id', + label: 'Интегрированная структура', + placeholder: 'Выберите структуру', + options: withAllOption(filtersResponse.integrated_structures), + }, + { + id: 'organization_id', + label: 'Организация', + placeholder: 'Выберите организацию', + options: withAllOption(filtersResponse.organizations), + }, +] diff --git a/src/pages/dashboard/model/filterDraft.ts b/src/pages/dashboard/model/filterDraft.ts new file mode 100644 index 0000000..363cba3 --- /dev/null +++ b/src/pages/dashboard/model/filterDraft.ts @@ -0,0 +1,39 @@ +import type { AnalyticsFilterKey, AnalyticsFilters } from './types' + +export const ALL_FILTER_VALUE = 'all' + +export type DashboardDialogFilterKey = Exclude +export type DashboardDraftFilters = Record + +export const defaultDashboardDraftFilters: DashboardDraftFilters = { + federal_district_id: ALL_FILTER_VALUE, + region_id: ALL_FILTER_VALUE, + industry_id: ALL_FILTER_VALUE, + integrated_structure_id: ALL_FILTER_VALUE, + organization_id: ALL_FILTER_VALUE, +} + +export const dashboardDialogFilterIds = Object.keys( + defaultDashboardDraftFilters, +) as DashboardDialogFilterKey[] + +export const toDashboardDraftFilters = (filters: AnalyticsFilters): DashboardDraftFilters => ({ + federal_district_id: filters.federal_district_id ?? ALL_FILTER_VALUE, + region_id: filters.region_id ?? ALL_FILTER_VALUE, + industry_id: filters.industry_id ?? ALL_FILTER_VALUE, + integrated_structure_id: filters.integrated_structure_id ?? ALL_FILTER_VALUE, + organization_id: filters.organization_id ?? ALL_FILTER_VALUE, +}) + +export const toNullableDashboardFilterValue = (value: string): string | null => + value === ALL_FILTER_VALUE ? null : value + +export const toDashboardAnalyticsFilters = ( + filters: DashboardDraftFilters, +): Partial => ({ + federal_district_id: toNullableDashboardFilterValue(filters.federal_district_id), + region_id: toNullableDashboardFilterValue(filters.region_id), + industry_id: toNullableDashboardFilterValue(filters.industry_id), + integrated_structure_id: toNullableDashboardFilterValue(filters.integrated_structure_id), + organization_id: toNullableDashboardFilterValue(filters.organization_id), +}) diff --git a/src/pages/dashboard/model/filters.ts b/src/pages/dashboard/model/filters.ts new file mode 100644 index 0000000..47e1166 --- /dev/null +++ b/src/pages/dashboard/model/filters.ts @@ -0,0 +1,68 @@ +import type { + ApiV1CompetenceMapAnalyticsFiltersListParams, + ApiV1CompetenceMapAnalyticsListParams, +} from '@/shared/api' +import type { AnalyticsFilters, AssociationType } from './types' + +export type CompetenceMapAnalyticsParams = ApiV1CompetenceMapAnalyticsListParams & + ApiV1CompetenceMapAnalyticsFiltersListParams + +export const defaultAnalyticsFilters: AnalyticsFilters = { + association_type: 'all', + federal_district_id: null, + region_id: null, + industry_id: null, + integrated_structure_id: null, + organization_id: null, +} + +const isAssociationType = (value: unknown): value is AssociationType => + value === 'all' || value === 'sez' || value === 'technoparks' || value === 'clusters' + +const normalizeNullableFilter = (value: string | null | undefined): string | null => + value && value !== 'all' ? value : null + +export const normalizeAnalyticsFilters = ( + filters: Partial = {}, +): AnalyticsFilters => ({ + association_type: isAssociationType(filters.association_type) + ? filters.association_type + : defaultAnalyticsFilters.association_type, + federal_district_id: normalizeNullableFilter(filters.federal_district_id), + region_id: normalizeNullableFilter(filters.region_id), + industry_id: normalizeNullableFilter(filters.industry_id), + integrated_structure_id: normalizeNullableFilter(filters.integrated_structure_id), + organization_id: normalizeNullableFilter(filters.organization_id), +}) + +export const normalizeAppliedFilters = ( + filters: Partial = {}, +): AnalyticsFilters => normalizeAnalyticsFilters(filters) + +export const toApiParams = (filters: AnalyticsFilters): CompetenceMapAnalyticsParams => { + const params: CompetenceMapAnalyticsParams = { + association_type: filters.association_type, + } + + if (filters.federal_district_id) { + params.federal_district_id = filters.federal_district_id + } + + if (filters.region_id) { + params.region_id = filters.region_id + } + + if (filters.industry_id) { + params.industry_id = filters.industry_id + } + + if (filters.integrated_structure_id) { + params.integrated_structure_id = filters.integrated_structure_id + } + + if (filters.organization_id) { + params.organization_id = filters.organization_id + } + + return params +} diff --git a/src/pages/dashboard/model/formatters.ts b/src/pages/dashboard/model/formatters.ts new file mode 100644 index 0000000..a8f6a95 --- /dev/null +++ b/src/pages/dashboard/model/formatters.ts @@ -0,0 +1,69 @@ +import type { AnalyticsKpi } from './types' + +export const getNumericValue = (value: unknown): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0 + +export const formatNumber = (value: number | null | undefined): string => + typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString('ru-RU') : '—' + +export const capitalizeFirstLetter = (value: string): string => { + if (!value) { + return value + } + + return `${value[0]?.toLocaleUpperCase('ru-RU') ?? ''}${value.slice(1)}` +} + +export const formatMetricValue = ( + metric: Pick & { value: number | null }, +): string => { + if (typeof metric.value !== 'number' || !Number.isFinite(metric.value)) { + return '—' + } + + const formattedValue = formatNumber(metric.value) + + if (metric.value_type === 'percent') { + return `${formattedValue}%` + } + + return formattedValue +} + +export const formatKpiValue = (kpi: AnalyticsKpi): string => formatMetricValue(kpi) + +export const formatAxisValue = (value: number): string => value.toLocaleString('ru-RU') + +export const getRuPluralForm = (value: number, forms: [string, string, string]): string => { + const absoluteValue = Math.abs(value) + const lastTwoDigits = absoluteValue % 100 + const lastDigit = absoluteValue % 10 + + if (lastTwoDigits >= 11 && lastTwoDigits <= 14) { + return forms[2] + } + + if (lastDigit === 1) { + return forms[0] + } + + if (lastDigit >= 2 && lastDigit <= 4) { + return forms[1] + } + + return forms[2] +} + +export const formatOrganizationsText = (value: number): string => + `${formatNumber(value)} ${getRuPluralForm(value, [ + 'организация', + 'организации', + 'организаций', + ])} ОПК` + +export const formatGrowthPercent = (value: number | null | undefined): string => + typeof value === 'number' && Number.isFinite(value) + ? `${value > 0 ? '+' : ''}${Math.trunc(value)}%` + : '' + +export const formatAreaUnit = (unit: string): string => (unit === 'ha' ? 'га' : unit) diff --git a/src/pages/dashboard/model/index.ts b/src/pages/dashboard/model/index.ts new file mode 100644 index 0000000..9d413eb --- /dev/null +++ b/src/pages/dashboard/model/index.ts @@ -0,0 +1,55 @@ +export { + createCompositionDonutColors, + createCompositionDonutData, + createTrendChartData, + createTrendYearLabels, +} from './chartData' +export { associationTypeColorMap } from './constants' +export { + defaultAnalyticsFilters, + normalizeAnalyticsFilters, + normalizeAppliedFilters, + toApiParams, +} from './filters' +export { + ALL_FILTER_VALUE, + dashboardDialogFilterIds, + defaultDashboardDraftFilters, + toDashboardAnalyticsFilters, + toDashboardDraftFilters, + toNullableDashboardFilterValue, +} from './filterDraft' +export { createDashboardFilterDefinitions } from './filterDefinitions' +export { + capitalizeFirstLetter, + formatAreaUnit, + formatAxisValue, + formatGrowthPercent, + formatKpiValue, + formatMetricValue, + formatNumber, + formatOrganizationsText, + getNumericValue, + getRuPluralForm, +} from './formatters' +export { + defaultSortState, + getNextSortDirection, + getSortAria, + isSortActive, + isSortDescending, + sortRows, +} from './sorting' +export { dashboardAnalyticsQueryKeys } from './queryKeys' +export { buildChartTooltipTemplate, createChartTooltipContent, escapeHtml } from './tooltip' +export { useDashboardStore } from './dashboardStore' +export type { DashboardDialogFilterKey, DashboardDraftFilters } from './filterDraft' +export type { DashboardFilterDefinition, DashboardFilterOption } from './filterDefinitions' +export type { SortDirection, SortState } from './sorting' +export type { + ChartTooltipContent, + ChartTooltipRow, + ChartTooltipState, + UiKitDonutTooltipDatum, +} from './tooltip' +export type * from './types' diff --git a/src/pages/dashboard/model/queryKeys.ts b/src/pages/dashboard/model/queryKeys.ts new file mode 100644 index 0000000..075577b --- /dev/null +++ b/src/pages/dashboard/model/queryKeys.ts @@ -0,0 +1,9 @@ +import { normalizeAnalyticsFilters } from './filters' +import type { AnalyticsFilters } from './types' + +export const dashboardAnalyticsQueryKeys = { + analytics: (filters: AnalyticsFilters) => + ['competence-map-analytics', normalizeAnalyticsFilters(filters)] as const, + filters: (filters: AnalyticsFilters) => + ['competence-map-analytics-filters', normalizeAnalyticsFilters(filters)] as const, +} diff --git a/src/pages/dashboard/model/sorting.ts b/src/pages/dashboard/model/sorting.ts new file mode 100644 index 0000000..ea587fd --- /dev/null +++ b/src/pages/dashboard/model/sorting.ts @@ -0,0 +1,91 @@ +export type SortDirection = 'asc' | 'desc' + +export type SortState = { + direction: SortDirection + field: string +} + +export const defaultSortState = { + field: 'rank', + direction: 'asc', +} satisfies SortState + +const getSortableFieldValue = (row: unknown, field: string): number | string | null => { + const value = (row as Record)[field] + + if (typeof value === 'number' || typeof value === 'string') { + return value + } + + return null +} + +const compareSortableValues = ( + leftValue: number | string | null, + rightValue: number | string | null, +): number => { + if (leftValue === null && rightValue === null) { + return 0 + } + + if (leftValue === null) { + return 1 + } + + if (rightValue === null) { + return -1 + } + + if (typeof leftValue === 'number' && typeof rightValue === 'number') { + return leftValue - rightValue + } + + return String(leftValue).localeCompare(String(rightValue), 'ru-RU', { + numeric: true, + sensitivity: 'base', + }) +} + +const getRowRank = (row: unknown): number => { + const rank = (row as { rank?: unknown }).rank + + return typeof rank === 'number' ? rank : 0 +} + +export const sortRows = (rows: readonly Row[], sort: SortState): Row[] => + [...rows].sort((leftRow, rightRow) => { + const comparison = compareSortableValues( + getSortableFieldValue(leftRow, sort.field), + getSortableFieldValue(rightRow, sort.field), + ) + + if (comparison !== 0) { + return sort.direction === 'asc' ? comparison : -comparison + } + + return getRowRank(leftRow) - getRowRank(rightRow) + }) + +export const getNextSortDirection = (currentSort: SortState, field: string): SortDirection => { + if (currentSort.field !== field) { + return 'desc' + } + + return currentSort.direction === 'desc' ? 'asc' : 'desc' +} + +export const getSortAria = ( + sort: SortState, + field: string, +): 'ascending' | 'descending' | 'none' => { + if (sort.field !== field) { + return 'none' + } + + return sort.direction === 'asc' ? 'ascending' : 'descending' +} + +export const isSortActive = (sort: SortState, field: string): boolean => sort.field === field + +export const isSortDescending = (sort: SortState, field: string): boolean => + isSortActive(sort, field) && sort.direction === 'desc' diff --git a/src/pages/dashboard/model/tooltip.ts b/src/pages/dashboard/model/tooltip.ts new file mode 100644 index 0000000..881fb2b --- /dev/null +++ b/src/pages/dashboard/model/tooltip.ts @@ -0,0 +1,66 @@ +export type ChartTooltipRow = { + color?: string + label: string + value: string +} + +export type ChartTooltipContent = { + rows: ChartTooltipRow[] + title: string +} + +export type ChartTooltipState = ChartTooltipContent & { + visible: boolean + x: number + y: number +} + +export type UiKitDonutTooltipDatum = { + label: string + value: number + [key: string]: unknown +} + +const htmlEscapeMap: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +} + +export const escapeHtml = (value: string): string => + value.replace(/[&<>"']/g, (character) => htmlEscapeMap[character] ?? character) + +export const buildChartTooltipTemplate = (title: string, rows: ChartTooltipRow[]): string => ` +
+
${escapeHtml(title)}
+
+ ${rows + .map( + (row) => ` +
+ + ${ + row.color + ? `` + : '' + } + ${escapeHtml(row.label)} + + ${escapeHtml(row.value)} +
+ `, + ) + .join('')} +
+
+ ` + +export const createChartTooltipContent = ( + title: string, + rows: ChartTooltipRow[], +): ChartTooltipContent => ({ + rows, + title, +}) diff --git a/src/pages/dashboard/model/types.ts b/src/pages/dashboard/model/types.ts new file mode 100644 index 0000000..05c2c4c --- /dev/null +++ b/src/pages/dashboard/model/types.ts @@ -0,0 +1,155 @@ +import type { + ApiV1CompetenceMapAnalyticsFiltersList200, + ApiV1CompetenceMapAnalyticsFiltersList200AssociationTypesItem, + ApiV1CompetenceMapAnalyticsFiltersList200FederalDistrictsItem, + ApiV1CompetenceMapAnalyticsFiltersList200IndustriesItem, + ApiV1CompetenceMapAnalyticsFiltersList200IntegratedStructuresItem, + ApiV1CompetenceMapAnalyticsFiltersList200OrganizationsItem, + ApiV1CompetenceMapAnalyticsFiltersList200RegionsItem, + ApiV1CompetenceMapAnalyticsList200, + ApiV1CompetenceMapAnalyticsList200KpisItem, + ApiV1CompetenceMapAnalyticsList200MainBlock, + ApiV1CompetenceMapAnalyticsList200MainBlockAreaSegmentsItem, + ApiV1CompetenceMapAnalyticsList200MainBlockPriorityDirections, + ApiV1CompetenceMapAnalyticsList200MainBlockPriorityDirectionsRowsItem, + ApiV1CompetenceMapAnalyticsList200MainBlockSegmentsItem, + ApiV1CompetenceMapAnalyticsList200MainBlockSezTypes, + ApiV1CompetenceMapAnalyticsList200MainBlockSpecializations, + ApiV1CompetenceMapAnalyticsList200MainBlockSpecializationsRowsItem, + ApiV1CompetenceMapAnalyticsList200MainBlockTechnoparks, + ApiV1CompetenceMapAnalyticsList200MainBlockTechnoparksRowsItem, + ApiV1CompetenceMapAnalyticsList200RegionRanking, + ApiV1CompetenceMapAnalyticsList200RegionRankingRowsItem, + ApiV1CompetenceMapAnalyticsList200TopIndustries, + ApiV1CompetenceMapAnalyticsList200TopIndustriesRowsItem, + ApiV1CompetenceMapAnalyticsList200TrendBlock, + ApiV1CompetenceMapAnalyticsList200TrendBlockPointsItem, +} from '@/shared/api' + +export type AssociationType = 'all' | 'sez' | 'technoparks' | 'clusters' +export type SpecificAssociationType = Exclude +export type AnalyticsValueType = 'integer' | 'percent' | 'decimal' +export type SortDirection = 'asc' | 'desc' + +export interface AnalyticsFilters { + association_type: AssociationType + federal_district_id: string | null + region_id: string | null + industry_id: string | null + integrated_structure_id: string | null + organization_id: string | null +} + +export type AnalyticsFilterKey = keyof AnalyticsFilters +export type AnalyticsKpi = ApiV1CompetenceMapAnalyticsList200KpisItem + +export interface SortDescriptor { + direction: SortDirection + field: string +} + +export type AnalyticsFilterOptionBase = { + disabled: boolean + id: string + label: string +} + +export type AssociationTypeFilterOption = + ApiV1CompetenceMapAnalyticsFiltersList200AssociationTypesItem & { + id: AssociationType + } +export type FederalDistrictFilterOption = + ApiV1CompetenceMapAnalyticsFiltersList200FederalDistrictsItem +export type RegionFilterOption = ApiV1CompetenceMapAnalyticsFiltersList200RegionsItem +export type IndustryFilterOption = ApiV1CompetenceMapAnalyticsFiltersList200IndustriesItem +export type IntegratedStructureFilterOption = + ApiV1CompetenceMapAnalyticsFiltersList200IntegratedStructuresItem +export type OrganizationFilterOption = ApiV1CompetenceMapAnalyticsFiltersList200OrganizationsItem +export type CompetenceMapAnalyticsFiltersResponse = ApiV1CompetenceMapAnalyticsFiltersList200 & { + applied_filters: AnalyticsFilters + association_types: AssociationTypeFilterOption[] +} + +export type AssociationsCompositionSegment = + ApiV1CompetenceMapAnalyticsList200MainBlockSegmentsItem & { + association_type: SpecificAssociationType + } +export type AssociationsCompositionBlock = ApiV1CompetenceMapAnalyticsList200MainBlock & { + segments: AssociationsCompositionSegment[] + title: string + total_associations_count: number + type: 'all_associations_composition' +} +export type SezTypeSegment = ApiV1CompetenceMapAnalyticsList200MainBlockSezTypes['segments'][number] +export type SezPriorityDirectionRow = + ApiV1CompetenceMapAnalyticsList200MainBlockPriorityDirectionsRowsItem +export type SezTypesAndPriorityDirectionsBlock = ApiV1CompetenceMapAnalyticsList200MainBlock & { + priority_directions: ApiV1CompetenceMapAnalyticsList200MainBlockPriorityDirections + sez_types: ApiV1CompetenceMapAnalyticsList200MainBlockSezTypes + title: string + type: 'sez_types_and_priority_directions' +} +export type TechnoparkAreaSegment = ApiV1CompetenceMapAnalyticsList200MainBlockAreaSegmentsItem +export type TechnoparkAreaRow = ApiV1CompetenceMapAnalyticsList200MainBlockTechnoparksRowsItem +export type TechnoparkAreaDistributionBlock = ApiV1CompetenceMapAnalyticsList200MainBlock & { + area_segments: TechnoparkAreaSegment[] + technoparks: ApiV1CompetenceMapAnalyticsList200MainBlockTechnoparks + title: string + total_technoparks_count: number + type: 'technopark_area_distribution' +} +export type ClusterSpecializationRow = + ApiV1CompetenceMapAnalyticsList200MainBlockSpecializationsRowsItem +export type ClusterSpecializationsBlock = ApiV1CompetenceMapAnalyticsList200MainBlock & { + specializations: ApiV1CompetenceMapAnalyticsList200MainBlockSpecializations + title: string + type: 'cluster_specializations' +} +export type MainBlock = + | AssociationsCompositionBlock + | SezTypesAndPriorityDirectionsBlock + | TechnoparkAreaDistributionBlock + | ClusterSpecializationsBlock + +export interface CompositionDonutDataPoint { + [key: string]: number | string + association_type: SpecificAssociationType + associations_count: number + label: string + opk_organizations_count: number + value: number +} + +export type RegionRankingRow = ApiV1CompetenceMapAnalyticsList200RegionRankingRowsItem +export type RegionRankingBlock = ApiV1CompetenceMapAnalyticsList200RegionRanking +export type StackedTopIndustryRow = ApiV1CompetenceMapAnalyticsList200TopIndustriesRowsItem +export type SingleTopIndustryRow = ApiV1CompetenceMapAnalyticsList200TopIndustriesRowsItem +export type TopIndustryRow = StackedTopIndustryRow | SingleTopIndustryRow +export type StackedTopIndustriesBlock = ApiV1CompetenceMapAnalyticsList200TopIndustries & { + association_type: null + mode: 'stacked_by_association_type' +} +export type SingleTopIndustriesBlock = ApiV1CompetenceMapAnalyticsList200TopIndustries & { + association_type: SpecificAssociationType + mode: 'single_association_type' +} +export type TopIndustriesBlock = StackedTopIndustriesBlock | SingleTopIndustriesBlock +export type OpkParticipationTrendPoint = ApiV1CompetenceMapAnalyticsList200TrendBlockPointsItem +export type ClusterCreationTrendPoint = ApiV1CompetenceMapAnalyticsList200TrendBlockPointsItem +export type OpkParticipationTrendBlock = ApiV1CompetenceMapAnalyticsList200TrendBlock & { + type: 'opk_participation_by_year' +} +export type ClusterCreationTrendBlock = ApiV1CompetenceMapAnalyticsList200TrendBlock & { + type: 'cluster_creation_dynamics' +} +export type TrendBlock = OpkParticipationTrendBlock | ClusterCreationTrendBlock +export type EmployeeStatsBlock = NonNullable +export type CompetenceMapAnalyticsResponse = ApiV1CompetenceMapAnalyticsList200 & { + applied_filters: AnalyticsFilters + association_type: AssociationType + employee_stats: EmployeeStatsBlock | null + main_block: MainBlock + region_ranking: RegionRankingBlock + top_industries: TopIndustriesBlock + trend_block: TrendBlock | null +} diff --git a/src/pages/dashboard/ui/CompetenceMapFilters.vue b/src/pages/dashboard/ui/CompetenceMapFilters.vue new file mode 100644 index 0000000..3cb973b --- /dev/null +++ b/src/pages/dashboard/ui/CompetenceMapFilters.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/src/widgets/competence-map-toolbar/ui/CompetenceMapToolbar.vue b/src/pages/dashboard/ui/CompetenceMapToolbar.vue similarity index 100% rename from src/widgets/competence-map-toolbar/ui/CompetenceMapToolbar.vue rename to src/pages/dashboard/ui/CompetenceMapToolbar.vue diff --git a/src/pages/dashboard/ui/DashboardPage.vue b/src/pages/dashboard/ui/DashboardPage.vue new file mode 100644 index 0000000..c2bcd61 --- /dev/null +++ b/src/pages/dashboard/ui/DashboardPage.vue @@ -0,0 +1,487 @@ + + + diff --git a/src/views/ReportsView.vue b/src/pages/dashboard/ui/ReportsView.vue similarity index 98% rename from src/views/ReportsView.vue rename to src/pages/dashboard/ui/ReportsView.vue index 0ea47dd..4fe5a08 100644 --- a/src/views/ReportsView.vue +++ b/src/pages/dashboard/ui/ReportsView.vue @@ -1,8 +1,8 @@ + + diff --git a/src/pages/dashboard/ui/components/ChartTooltipPortal.vue b/src/pages/dashboard/ui/components/ChartTooltipPortal.vue new file mode 100644 index 0000000..e99a991 --- /dev/null +++ b/src/pages/dashboard/ui/components/ChartTooltipPortal.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/pages/dashboard/ui/components/ClusterSpecializationsPanel.vue b/src/pages/dashboard/ui/components/ClusterSpecializationsPanel.vue new file mode 100644 index 0000000..1174b60 --- /dev/null +++ b/src/pages/dashboard/ui/components/ClusterSpecializationsPanel.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/pages/dashboard/ui/components/CompetenceMapFilterField.spec.ts b/src/pages/dashboard/ui/components/CompetenceMapFilterField.spec.ts new file mode 100644 index 0000000..84cb3be --- /dev/null +++ b/src/pages/dashboard/ui/components/CompetenceMapFilterField.spec.ts @@ -0,0 +1,107 @@ +/* eslint-disable vue/one-component-per-file */ +import { defineComponent, h, inject, provide } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import CompetenceMapFilterField from './CompetenceMapFilterField.vue' +import type { DashboardFilterDefinition } from '../../model/filterDefinitions' + +const SELECT_UPDATE_INJECTION_KEY = 'select-update' + +const SelectStub = defineComponent({ + props: { + modelValue: { + type: String, + required: true, + }, + }, + emits: ['update:modelValue'], + setup(_props, { emit, slots }) { + provide(SELECT_UPDATE_INJECTION_KEY, (value: string) => { + emit('update:modelValue', value) + }) + + return () => h('div', { 'data-test': 'select' }, slots.default?.()) + }, +}) + +const SelectItemStub = defineComponent({ + props: { + disabled: { + type: Boolean, + default: false, + }, + value: { + type: String, + required: true, + }, + }, + setup(props, { slots }) { + const updateValue = inject<(value: string) => void>(SELECT_UPDATE_INJECTION_KEY) + + return () => + h( + 'button', + { + type: 'button', + class: 'select-option', + disabled: props.disabled, + onClick: () => updateValue?.(props.value), + }, + slots.default?.(), + ) + }, +}) + +const filter = { + id: 'region_id', + label: 'Субъект', + placeholder: 'Выберите субъект', + options: [ + { + label: 'Все', + value: 'all', + disabled: false, + }, + { + label: 'Москва', + value: '77', + disabled: false, + }, + { + label: 'Недоступный регион', + value: 'blocked', + disabled: true, + }, + ], +} satisfies DashboardFilterDefinition + +describe('CompetenceMapFilterField', () => { + it('renders options and emits selected value through v-model', async () => { + const wrapper = mount(CompetenceMapFilterField, { + props: { + disabled: false, + filter, + modelValue: 'all', + }, + global: { + stubs: { + Select: SelectStub, + SelectContent: { template: '
' }, + SelectItem: SelectItemStub, + SelectTrigger: { template: '' }, + SelectValue: { props: ['placeholder'], template: '{{ placeholder }}' }, + }, + }, + }) + + expect(wrapper.text()).toContain('Субъект') + expect(wrapper.text()).toContain('Все') + expect(wrapper.text()).toContain('Москва') + expect(wrapper.findAll('.select-option').at(2)?.attributes('disabled')).toBeDefined() + + await wrapper.findAll('.select-option').at(1)?.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([['77']]) + }) +}) diff --git a/src/pages/dashboard/ui/components/CompetenceMapFilterField.vue b/src/pages/dashboard/ui/components/CompetenceMapFilterField.vue new file mode 100644 index 0000000..1b4ffd9 --- /dev/null +++ b/src/pages/dashboard/ui/components/CompetenceMapFilterField.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/src/pages/dashboard/ui/components/CompositionCard.vue b/src/pages/dashboard/ui/components/CompositionCard.vue new file mode 100644 index 0000000..82ad40b --- /dev/null +++ b/src/pages/dashboard/ui/components/CompositionCard.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/pages/dashboard/ui/components/DashboardKpiGrid.vue b/src/pages/dashboard/ui/components/DashboardKpiGrid.vue new file mode 100644 index 0000000..12fd143 --- /dev/null +++ b/src/pages/dashboard/ui/components/DashboardKpiGrid.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/pages/dashboard/ui/components/DashboardSkeletonGrid.vue b/src/pages/dashboard/ui/components/DashboardSkeletonGrid.vue new file mode 100644 index 0000000..8d038c7 --- /dev/null +++ b/src/pages/dashboard/ui/components/DashboardSkeletonGrid.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/pages/dashboard/ui/components/DashboardStatePanel.vue b/src/pages/dashboard/ui/components/DashboardStatePanel.vue new file mode 100644 index 0000000..b9d24d4 --- /dev/null +++ b/src/pages/dashboard/ui/components/DashboardStatePanel.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/pages/dashboard/ui/components/DashboardTabs.vue b/src/pages/dashboard/ui/components/DashboardTabs.vue new file mode 100644 index 0000000..7f55848 --- /dev/null +++ b/src/pages/dashboard/ui/components/DashboardTabs.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/pages/dashboard/ui/components/EmployeeStatsCard.vue b/src/pages/dashboard/ui/components/EmployeeStatsCard.vue new file mode 100644 index 0000000..a5dfc93 --- /dev/null +++ b/src/pages/dashboard/ui/components/EmployeeStatsCard.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/pages/dashboard/ui/components/RegionRankingCard.vue b/src/pages/dashboard/ui/components/RegionRankingCard.vue new file mode 100644 index 0000000..c39bd4d --- /dev/null +++ b/src/pages/dashboard/ui/components/RegionRankingCard.vue @@ -0,0 +1,102 @@ + + + diff --git a/src/pages/dashboard/ui/components/SezCompositionPanel.vue b/src/pages/dashboard/ui/components/SezCompositionPanel.vue new file mode 100644 index 0000000..4f0894f --- /dev/null +++ b/src/pages/dashboard/ui/components/SezCompositionPanel.vue @@ -0,0 +1,151 @@ + + + diff --git a/src/pages/dashboard/ui/components/TechnoparkCompositionPanel.vue b/src/pages/dashboard/ui/components/TechnoparkCompositionPanel.vue new file mode 100644 index 0000000..45202ab --- /dev/null +++ b/src/pages/dashboard/ui/components/TechnoparkCompositionPanel.vue @@ -0,0 +1,156 @@ + + + diff --git a/src/pages/dashboard/ui/components/TopIndustriesCard.vue b/src/pages/dashboard/ui/components/TopIndustriesCard.vue new file mode 100644 index 0000000..6cfd81f --- /dev/null +++ b/src/pages/dashboard/ui/components/TopIndustriesCard.vue @@ -0,0 +1,197 @@ + + + diff --git a/src/pages/dashboard/ui/components/TrendCard.vue b/src/pages/dashboard/ui/components/TrendCard.vue new file mode 100644 index 0000000..9d7d832 --- /dev/null +++ b/src/pages/dashboard/ui/components/TrendCard.vue @@ -0,0 +1,261 @@ + + + diff --git a/src/pages/dashboard/ui/components/index.ts b/src/pages/dashboard/ui/components/index.ts new file mode 100644 index 0000000..92347b5 --- /dev/null +++ b/src/pages/dashboard/ui/components/index.ts @@ -0,0 +1,11 @@ +export { default as ChartTooltipPortal } from './ChartTooltipPortal.vue' +export { default as CompositionCard } from './CompositionCard.vue' +export { default as CompetenceMapFilterField } from './CompetenceMapFilterField.vue' +export { default as DashboardKpiGrid } from './DashboardKpiGrid.vue' +export { default as DashboardSkeletonGrid } from './DashboardSkeletonGrid.vue' +export { default as DashboardStatePanel } from './DashboardStatePanel.vue' +export { default as DashboardTabs } from './DashboardTabs.vue' +export { default as EmployeeStatsCard } from './EmployeeStatsCard.vue' +export { default as RegionRankingCard } from './RegionRankingCard.vue' +export { default as TopIndustriesCard } from './TopIndustriesCard.vue' +export { default as TrendCard } from './TrendCard.vue' diff --git a/src/pages/dashboard/ui/index.ts b/src/pages/dashboard/ui/index.ts new file mode 100644 index 0000000..46d9a3e --- /dev/null +++ b/src/pages/dashboard/ui/index.ts @@ -0,0 +1 @@ +export { default as DashboardPage } from './DashboardPage.vue' diff --git a/src/pages/dashboard/ui/styles/dashboard.css b/src/pages/dashboard/ui/styles/dashboard.css new file mode 100644 index 0000000..ce3cbab --- /dev/null +++ b/src/pages/dashboard/ui/styles/dashboard.css @@ -0,0 +1,5 @@ +@import './sections/layout.css'; +@import './sections/composition.css'; +@import './sections/tables.css'; +@import './sections/charts-and-industries.css'; +@import './sections/responsive.css'; diff --git a/src/pages/dashboard/ui/styles/sections/charts-and-industries.css b/src/pages/dashboard/ui/styles/sections/charts-and-industries.css new file mode 100644 index 0000000..6f2d8d0 --- /dev/null +++ b/src/pages/dashboard/ui/styles/sections/charts-and-industries.css @@ -0,0 +1,323 @@ +.analytics-chart-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + gap: 8px 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.analytics-chart-legend li { + display: inline-flex; + align-items: center; + gap: 7px; + color: rgb(2 36 86 / 0.62); + font-size: 13px; + line-height: 18px; + font-weight: 700; + white-space: nowrap; +} + +.analytics-chart-legend__marker { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 999px; +} + +.analytics-chart-legend__marker.is-clusters { + background: var(--competence-map-clusters-color); +} + +.analytics-chart-legend__marker.is-technoparks { + background: var(--competence-map-technoparks-color); +} + +.analytics-chart-legend__marker.is-sez { + background: var(--competence-map-sez-color); +} + +.trend-chart-box { + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.trend-chart-box > * { + height: 100%; +} + +.trend-chart-box .unovis-xy-container { + --vis-axis-tick-label-font-size: 14px; + height: 100% !important; + cursor: crosshair; +} + +.line-area-chart-interactive { + position: relative; + min-height: 0; + height: 100%; +} + +.line-area-chart-active-layer { + position: absolute; + z-index: 6; + overflow: visible; + pointer-events: none; +} + +.line-area-chart-active-marker { + stroke: #fff; + stroke-width: 3px; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 5px 10px rgb(2 36 86 / 0.22)); +} + +.cluster-trend-chart-scroll { + position: relative; + min-width: 0; + min-height: 0; + height: 100%; + padding-bottom: 18px; +} + +.cluster-trend-chart-scroll::before { + position: absolute; + z-index: 0; + top: 42px; + right: 6px; + bottom: 44px; + left: 6px; + pointer-events: none; + content: ''; + background: repeating-linear-gradient( + to bottom, + rgb(2 36 86 / 0.11) 0, + rgb(2 36 86 / 0.11) 1px, + transparent 1px, + transparent 25% + ); +} + +.cluster-trend-chart-scroll [data-slot='scroll-area-viewport'] { + position: relative; + z-index: 1; + height: 100%; + padding-bottom: 12px; +} + +.cluster-trend-chart-scroll [data-slot='scroll-area-viewport'] > * { + min-height: 100%; + height: 100%; +} + +.cluster-trend-chart-scroll [data-slot='scroll-area-scrollbar'] { + right: 6px; + bottom: 4px; + left: 6px; + height: 4px; +} + +.cluster-trend-chart-scroll [data-slot='scroll-area-thumb'] { + border-radius: 999px; + background: var(--Infographic-Main-Dark-Blue, #022456); +} + +.cluster-trend-chart { + display: grid; + min-height: 0; + height: 100%; + grid-auto-flow: column; + grid-auto-columns: minmax(104px, 1fr); + gap: 18px; + align-items: end; + padding: 42px 6px 0; +} + +.cluster-trend-chart__item { + position: relative; + display: grid; + height: 100%; + min-width: 0; + grid-template-rows: minmax(120px, 1fr) auto; + align-items: end; + gap: 6px; + border-radius: 14px; + outline: none; + text-align: center; + cursor: pointer; +} + +.cluster-trend-chart__labels { + position: absolute; + right: 0; + left: 0; + display: grid; + gap: 0; + justify-items: center; + transform: translateY(-3px); + transition: transform 160ms ease; +} + +.cluster-trend-chart__value { + color: var(--Infographic-Main-Red, #c90237); + font-size: 18px; + line-height: 22px; + font-weight: 800; +} + +.cluster-trend-chart__growth { + min-height: 16px; + color: var(--Infographic-Main-Red, #c90237); + font-size: 13px; + line-height: 16px; + font-weight: 800; +} + +.cluster-trend-chart__bar-wrap { + position: relative; + display: flex; + min-height: 150px; + height: 100%; + align-items: flex-end; +} + +.cluster-trend-chart__bar { + display: block; + width: 100%; + min-height: 12px; + border-radius: 10px 10px 0 0; + background: var(--Infographic-Main-Dark-Blue, #022456); + transition: + filter 160ms ease, + transform 160ms ease, + opacity 160ms ease; + transform-origin: bottom center; +} + +.cluster-trend-chart__item:hover .cluster-trend-chart__bar, +.cluster-trend-chart__item:focus-visible .cluster-trend-chart__bar { + filter: drop-shadow(0 8px 14px rgb(2 36 86 / 0.18)); + opacity: 0.94; + transform: scaleY(1.025); +} + +.cluster-trend-chart__item:hover .cluster-trend-chart__labels, +.cluster-trend-chart__item:focus-visible .cluster-trend-chart__labels { + transform: translateY(-8px); +} + +.cluster-trend-chart__item:focus-visible { + box-shadow: 0 0 0 3px rgb(2 36 86 / 0.12); +} + +.cluster-trend-chart__year { + color: rgb(2 36 86 / 0.38); + font-size: 18px; + line-height: 22px; + font-weight: 700; +} + +.cluster-specializations-table { + font-size: 13px; + line-height: 18px; +} + +.cluster-specializations-table th:nth-child(2), +.cluster-specializations-table td:nth-child(2) { + width: auto; +} + +.cluster-specializations-table th:nth-child(3), +.cluster-specializations-table td:nth-child(3) { + width: 220px; + text-align: right; +} + +.cluster-specializations-table .analytics-table-sort-button { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +.industry-list { + display: grid; + gap: 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.industry-row { + display: grid; + gap: 7px; +} + +.industry-row__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: baseline; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 13px; + line-height: 18px; + font-weight: 700; +} + +.industry-row__header span { + min-width: 0; + overflow-wrap: anywhere; +} + +.industry-row__header strong { + color: rgb(2 36 86 / 0.78); + font-weight: 800; +} + +.industry-row__bar { + display: flex; + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgb(2 36 86 / 0.08); +} + +.industry-row__bar--single .industry-row__segment { + border-radius: 999px; +} + +.industry-row__segment { + display: block; + min-width: 2px; + height: 100%; + outline: none; + cursor: pointer; + transition: + filter 160ms ease, + opacity 160ms ease, + transform 160ms ease; + transform-origin: center; +} + +.industry-row__segment:first-child { + border-radius: 999px 0 0 999px; +} + +.industry-row__segment:last-child { + border-radius: 0 999px 999px 0; +} + +.industry-row__segment:hover, +.industry-row__segment:focus-visible { + filter: drop-shadow(0 5px 8px rgb(2 36 86 / 0.16)); + opacity: 0.9; + transform: scaleY(1.35); +} + +.industry-row__segment:focus-visible { + box-shadow: 0 0 0 3px rgb(2 36 86 / 0.12); +} diff --git a/src/pages/dashboard/ui/styles/sections/composition.css b/src/pages/dashboard/ui/styles/sections/composition.css new file mode 100644 index 0000000..59dff91 --- /dev/null +++ b/src/pages/dashboard/ui/styles/sections/composition.css @@ -0,0 +1,366 @@ +.composition-layout { + display: grid; + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); + gap: 20px; + align-items: center; + min-height: 290px; +} + +.composition-chart-panel { + min-width: 0; +} + +.composition-chart-panel-is-donut { + height: 280px; +} + +.composition-legend { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.composition-legend-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-width: 0; +} + +.composition-legend-marker { + width: 12px; + height: 12px; + border-radius: 999px; +} + +.composition-legend-label, +.composition-legend-value { + color: #022456; + font-size: 14px; + line-height: 18px; +} + +.composition-legend-label { + min-width: 0; +} + +.composition-legend-value { + color: rgb(2 36 86 / 62%); +} + +.composition-tooltip { + display: grid; + min-width: 360px; + max-width: 390px; + gap: 12px; + padding: 16px; + border: 1px solid rgb(2 36 86 / 0.08); + border-radius: 16px; + background: rgb(255 255 255 / 0.96); + color: var(--Infographic-Main-Dark-Blue, #022456); + font-family: 'Clear Sans', var(--font-sans, ui-sans-serif, system-ui, sans-serif); + box-shadow: + 0 10px 24px rgb(225 229 239 / 0.86), + 0 24px 48px rgb(201 202 208 / 0.5); +} + +.composition-tooltip__title { + padding-bottom: 10px; + border-bottom: 1px solid rgb(2 36 86 / 0.08); + color: rgb(2 36 86 / 0.62); + font-size: 14px; + line-height: 18px; + font-weight: 700; +} + +.composition-tooltip__rows { + display: grid; + gap: 10px; +} + +.composition-tooltip__row { + display: grid; + grid-template-columns: minmax(120px, auto) minmax(0, 1fr); + gap: 14px; + align-items: baseline; +} + +.composition-tooltip__label { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 7px; + color: rgb(2 36 86 / 0.62); + font-size: 13px; + line-height: 18px; + font-weight: 700; +} + +.composition-tooltip__label span:last-child { + min-width: 0; +} + +.composition-tooltip__marker { + width: 9px; + height: 9px; + flex: 0 0 auto; + border-radius: 999px; +} + +.composition-tooltip__value { + min-width: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 14px; + line-height: 18px; + font-weight: 800; + white-space: nowrap; +} + +.chart-hover-tooltip { + position: fixed; + z-index: 80; + max-width: min(390px, calc(100vw - 32px)); + pointer-events: none; + transform: translate3d(0, 0, 0); +} + +.analytics-split-layout { + display: grid; + grid-template-columns: minmax(0, 300px) minmax(0, 1fr); + gap: 22px; + align-items: start; +} + +.analytics-split-layout--sez { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + align-items: start; + height: auto; +} + +.analytics-split-layout--technoparks { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + align-items: start; + height: 100%; +} + +.sez-donut-panel { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + min-height: 0; + align-items: center; + align-content: start; + gap: 14px 20px; +} + +.sez-donut-panel .analytics-subtitle { + grid-column: 1 / -1; + margin-bottom: 0; +} + +.sez-donut-chart-box { + width: min(100%, 340px); + height: 320px; + min-width: 0; + min-height: 0; + justify-self: center; +} + +.technopark-donut-panel { + display: grid; + grid-template-columns: minmax(0, 300px) minmax(0, 1fr); + align-content: start; + align-items: center; + gap: 14px 20px; +} + +.technopark-donut-panel .analytics-subtitle { + grid-column: 1 / -1; + margin-bottom: 0; +} + +.technopark-donut-chart-box { + height: 300px; + min-width: 0; + min-height: 0; +} + +.area-segment-legend { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.area-segment-legend__item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-width: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 12px; + line-height: 16px; + font-weight: 700; +} + +.area-segment-legend__marker { + width: 10px; + height: 10px; + border-radius: 999px; +} + +.area-segment-legend__label { + min-width: 0; + overflow-wrap: anywhere; +} + +.area-segment-legend__value { + color: rgb(2 36 86 / 0.68); + font-weight: 800; + white-space: nowrap; +} + +.analytics-subtitle { + margin: 0 0 14px; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 15px; + line-height: 19px; + font-weight: 800; + letter-spacing: 0; +} + +.analytics-ranked-panel { + display: grid; + min-width: 0; + gap: 14px; +} + +.analytics-ranked-panel .analytics-subtitle { + margin-bottom: 0; +} + +.analytics-table-scroll { + min-width: 0; + max-height: min(360px, 46vh); + padding-right: 24px; +} + +.analytics-table-scroll--reports { + max-height: min(620px, 62vh); +} + +.industry-list-scroll { + min-width: 0; + max-height: min(410px, 48vh); + padding-right: 24px; +} + +.analytics-table-scroll [data-slot='scroll-area-viewport'], +.industry-list-scroll [data-slot='scroll-area-viewport'] { + padding-right: 16px; +} + +.analytics-table-scroll [data-slot='scroll-area-scrollbar'], +.industry-list-scroll [data-slot='scroll-area-scrollbar'] { + top: 0; + right: 4px; + bottom: 0; + width: 4px; +} + +.analytics-table-scroll [data-slot='scroll-area-thumb'], +.industry-list-scroll [data-slot='scroll-area-thumb'] { + border-radius: 999px; + background: #022456; +} + +.analytics-ranked-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.analytics-ranked-list__item { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-width: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 13px; + line-height: 18px; + font-weight: 700; +} + +.analytics-ranked-list__rank { + color: rgb(2 36 86 / 0.5); + text-align: right; +} + +.analytics-ranked-list__label { + min-width: 0; + overflow-wrap: anywhere; +} + +.analytics-ranked-list__value { + color: rgb(2 36 86 / 0.74); + font-weight: 800; +} + +.metric-distribution-list { + display: grid; + gap: 16px; + margin: 0; + padding: 0; + list-style: none; +} + +.metric-distribution-list__item { + display: grid; + gap: 7px; +} + +.metric-distribution-list__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: baseline; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 13px; + line-height: 18px; + font-weight: 700; +} + +.metric-distribution-list__header span { + min-width: 0; + overflow-wrap: anywhere; +} + +.metric-distribution-list__header strong { + color: rgb(2 36 86 / 0.78); + font-weight: 800; +} + +.metric-distribution-list__bar { + display: flex; + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + background: rgb(2 36 86 / 0.08); +} + +.metric-distribution-list__bar span { + display: block; + min-width: 2px; + height: 100%; + border-radius: 999px; +} diff --git a/src/pages/dashboard/ui/styles/sections/layout.css b/src/pages/dashboard/ui/styles/sections/layout.css new file mode 100644 index 0000000..7597f1d --- /dev/null +++ b/src/pages/dashboard/ui/styles/sections/layout.css @@ -0,0 +1,494 @@ +.dashboard-content-section { + display: flex; + width: min(100%, 1490px); + min-height: calc(100svh - 230px); + flex-direction: column; + gap: 24px; + margin: 0 auto; + padding: 32px; + border-radius: 24px; + background: var(--Infographic-Base-White, #ffffff); + box-shadow: + 0 3px 7px #e1e5ef, + 0 24px 48px rgb(227 231 239 / 60%); +} + +.page-header { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.page-header-actions { + display: flex; + align-items: center; + gap: 14px; +} + +.page-header h1 { + margin: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-family: 'Clear Sans', var(--font-sans, ui-sans-serif, system-ui, sans-serif); + font-size: 24px; + line-height: 20px; + font-weight: 700; + letter-spacing: 0; +} + +.association-type-tabs { + min-width: 0; +} + +.association-type-tabs__list { + width: 100%; + min-width: 0; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px; + padding: 4px 0 0; +} + +.association-type-tabs__trigger { + min-height: 42px; + gap: 8px; + padding: 0 2px 8px; + color: rgb(2 36 86 / 0.58); + font-size: 15px; + line-height: 18px; + font-weight: 800; + white-space: nowrap; +} + +.association-type-tabs__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.association-type-tabs__count { + display: inline-flex; + min-width: 30px; + height: 22px; + align-items: center; + justify-content: center; + padding: 0 9px; + border-radius: 999px; + background: rgb(2 36 86 / 0.07); + color: rgb(2 36 86 / 0.62); + font-size: 12px; + line-height: 1; + font-weight: 800; +} + +.association-type-tabs__trigger[data-state='active'] .association-type-tabs__count { + background: rgb(201 2 55 / 0.1); + color: var(--Infographic-Main-Red, #c90237); +} + +.association-type-tabs-skeleton { + min-width: 0; + padding: 4px 0 0; +} + +.association-type-tabs-skeleton .association-type-tabs__list { + display: flex; +} + +.association-type-tabs__trigger--loading { + position: relative; + display: inline-flex; + align-items: center; + cursor: default; +} + +.association-type-tabs__trigger--loading.is-active::after { + content: ''; + position: absolute; + right: 2px; + bottom: 0; + left: 2px; + height: 2px; + border-radius: 999px; + background: var(--Infographic-Main-Dark-Blue, #022456); +} + +.association-type-tabs__count--skeleton { + width: 34px; + min-width: 34px; + padding: 0; + color: transparent; + background: linear-gradient(90deg, rgb(2 36 86 / 0.06), rgb(2 36 86 / 0.14), rgb(2 36 86 / 0.06)); + background-size: 220% 100%; + animation: skeleton-pulse 1400ms ease-in-out infinite; +} + +@keyframes skeleton-pulse { + from { + background-position: 120% 0; + } + + to { + background-position: -120% 0; + } +} + +.analytics-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.analytics-kpi-card { + display: grid; + min-height: 128px; + align-content: start; + gap: 10px; + padding: 22px 20px; + border: 1px solid rgb(2 36 86 / 0.08); + border-radius: 18px; + background: linear-gradient(180deg, rgb(245 247 250 / 0.78), rgb(255 255 255 / 0.94)); + box-shadow: 0 4px 14px rgb(2 36 86 / 0.06); +} + +.analytics-kpi-card--skeleton, +.analytics-card--skeleton-block { + overflow: hidden; + border-color: rgb(2 36 86 / 0.05); + background: linear-gradient( + 90deg, + rgb(232 237 244 / 0.78), + rgb(248 250 253 / 0.96), + rgb(232 237 244 / 0.78) + ); + background-size: 220% 100%; + animation: skeleton-pulse 1400ms ease-in-out infinite; +} + +.analytics-kpi-card__value { + color: var(--Infographic-Main-Red, #c90237); + font-size: clamp(32px, 3.2vw, 44px); + line-height: 1; + font-weight: 800; + letter-spacing: 0; +} + +.analytics-kpi-card__label { + max-width: 230px; + color: rgb(2 36 86 / 0.7); + font-size: 13px; + line-height: 16px; + font-weight: 700; +} + +.dashboard-state-panel { + display: grid; + min-height: 320px; + align-content: center; + justify-items: center; + gap: 14px; + padding: 40px; + border: 1px solid rgb(2 36 86 / 0.08); + border-radius: 22px; + background: linear-gradient(180deg, rgb(245 247 250 / 0.72), rgb(255 255 255 / 0.94)); + text-align: center; +} + +.dashboard-state-panel h2 { + margin: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 20px; + line-height: 24px; + font-weight: 800; +} + +.dashboard-state-panel p { + max-width: 560px; + margin: 0; + color: rgb(2 36 86 / 0.68); + font-size: 14px; + line-height: 20px; + font-weight: 700; +} + +.dashboard-state-panel__button { + min-height: 42px; + padding: 0 18px; + border: 0; + border-radius: 10px; + background: var(--Infographic-Main-Dark-Blue, #022456); + color: #ffffff; + cursor: pointer; + font-size: 14px; + line-height: 18px; + font-weight: 800; +} + +.analytics-dashboard-grid { + display: grid; + flex: 1 1 auto; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 20px; + align-items: start; +} + +.analytics-card { + min-width: 0; + min-height: 0; + padding: 0; + overflow: visible; + border-radius: 22px; + box-shadow: 0 1px 4px 0 rgb(205 212 224 / 0.42); + transform: none !important; + transition: none !important; +} + +.analytics-card:hover, +.analytics-card:focus-within { + box-shadow: 0 1px 4px 0 rgb(205 212 224 / 0.42) !important; + transform: none !important; +} + +.analytics-card--skeleton-block, +.analytics-card--skeleton-block:hover, +.analytics-card--skeleton-block:focus-within { + background: linear-gradient( + 90deg, + rgb(232 237 244 / 0.78), + rgb(248 250 253 / 0.96), + rgb(232 237 244 / 0.78) + ); + background-size: 220% 100%; + box-shadow: 0 1px 4px 0 rgb(205 212 224 / 0.42) !important; + animation: skeleton-pulse 1400ms ease-in-out infinite; +} + +.analytics-card--composition, +.analytics-card--ranking { + min-height: 398px; +} + +.analytics-card--composition { + grid-column: span 6; +} + +.analytics-card--ranking { + grid-column: span 6; +} + +.analytics-card--trend, +.analytics-card--employees, +.analytics-card--industries { + grid-column: span 6; +} + +.analytics-card--trend { + min-height: 338px; +} + +.analytics-card--industries { + min-height: 300px; +} + +.analytics-card--employees { + min-height: 280px; +} + +.analytics-dashboard-column { + display: contents; +} + +.analytics-card--composition { + order: 1; +} + +.analytics-card--ranking { + order: 2; +} + +.analytics-card--trend, +.analytics-card--employees { + order: 3; +} + +.analytics-card--industries { + order: 4; +} + +.analytics-dashboard-grid.is-all { + grid-template-rows: auto minmax(0, 1fr); + align-items: stretch; +} + +.analytics-dashboard-grid.is-all .analytics-card--composition, +.analytics-dashboard-grid.is-all .analytics-card--ranking, +.analytics-dashboard-grid.is-all .analytics-card--trend, +.analytics-dashboard-grid.is-all .analytics-card--industries { + align-self: stretch; +} + +.analytics-dashboard-grid.is-clusters, +.analytics-dashboard-grid.is-sez { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; +} + +.analytics-dashboard-grid.is-clusters .analytics-dashboard-column, +.analytics-dashboard-grid.is-sez .analytics-dashboard-column { + display: grid; + min-width: 0; + height: 100%; + grid-template-rows: auto minmax(0, 1fr); + gap: 20px; +} + +.analytics-dashboard-grid.is-clusters .analytics-dashboard-column--primary, +.analytics-dashboard-grid.is-sez .analytics-dashboard-column--primary { + grid-column: 1; +} + +.analytics-dashboard-grid.is-clusters .analytics-dashboard-column--secondary, +.analytics-dashboard-grid.is-sez .analytics-dashboard-column--secondary { + grid-column: 2; +} + +.analytics-dashboard-grid.is-clusters .analytics-card--composition, +.analytics-dashboard-grid.is-clusters .analytics-card--ranking, +.analytics-dashboard-grid.is-clusters .analytics-card--trend, +.analytics-dashboard-grid.is-clusters .analytics-card--industries, +.analytics-dashboard-grid.is-sez .analytics-card--composition, +.analytics-dashboard-grid.is-sez .analytics-card--ranking, +.analytics-dashboard-grid.is-sez .analytics-card--employees, +.analytics-dashboard-grid.is-sez .analytics-card--industries { + grid-column: auto; + min-height: 0; + align-self: start; +} + +.analytics-dashboard-grid.is-technoparks { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; +} + +.analytics-dashboard-grid.is-technoparks .analytics-dashboard-column { + display: grid; + min-width: 0; + height: 100%; + grid-template-rows: auto minmax(0, 1fr); + gap: 20px; +} + +.analytics-dashboard-grid.is-technoparks .analytics-dashboard-column--primary { + grid-column: 1; +} + +.analytics-dashboard-grid.is-technoparks .analytics-dashboard-column--secondary { + grid-column: 2; +} + +.analytics-dashboard-grid.is-technoparks .analytics-card--composition, +.analytics-dashboard-grid.is-technoparks .analytics-card--ranking, +.analytics-dashboard-grid.is-technoparks .analytics-card--employees, +.analytics-dashboard-grid.is-technoparks .analytics-card--industries { + grid-column: auto; + min-height: 0; + align-self: start; +} + +.analytics-dashboard-grid.is-clusters .analytics-card--trend, +.analytics-dashboard-grid.is-clusters .analytics-card--industries, +.analytics-dashboard-grid.is-sez .analytics-card--employees, +.analytics-dashboard-grid.is-sez .analytics-card--industries, +.analytics-dashboard-grid.is-technoparks .analytics-card--employees, +.analytics-dashboard-grid.is-technoparks .analytics-card--industries { + align-self: stretch; +} + +.analytics-dashboard-grid .analytics-card--skeleton-block.analytics-card--composition, +.analytics-dashboard-grid .analytics-card--skeleton-block.analytics-card--ranking { + min-height: 398px; +} + +.analytics-dashboard-grid .analytics-card--skeleton-block.analytics-card--trend { + min-height: 338px; +} + +.analytics-dashboard-grid .analytics-card--skeleton-block.analytics-card--industries { + min-height: 300px; +} + +.analytics-dashboard-grid .analytics-card--skeleton-block.analytics-card--employees { + min-height: 280px; +} + +.analytics-card__content { + display: grid; + height: 100%; + gap: 20px; + padding: 24px; + overflow: visible; +} + +.analytics-card--composition .analytics-card__content, +.analytics-card--ranking .analytics-card__content, +.analytics-card--employees .analytics-card__content, +.analytics-card--trend .analytics-card__content { + align-content: start; +} + +.analytics-card--trend .analytics-card__content { + grid-template-rows: auto minmax(0, 1fr); +} + +.analytics-card--industries .analytics-card__content { + align-content: start; +} + +.analytics-dashboard-grid.is-sez .analytics-card--composition .analytics-card__content { + grid-template-rows: auto minmax(0, 1fr); +} + +.analytics-dashboard-grid.is-technoparks .analytics-card--composition .analytics-card__content { + grid-template-rows: auto minmax(0, 1fr); +} + +.analytics-dashboard-grid.is-sez .analytics-card--employees .analytics-card__content { + grid-template-rows: auto minmax(0, 1fr); +} + +.analytics-dashboard-grid.is-technoparks .analytics-card--employees .analytics-card__content { + grid-template-rows: auto minmax(0, 1fr); +} + +.analytics-dashboard-grid.is-sez .analytics-card--employees .employee-stat-grid, +.analytics-dashboard-grid.is-technoparks .analytics-card--employees .employee-stat-grid { + align-self: start; +} + +.analytics-card__header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.analytics-card__header--with-legend { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.analytics-card__title { + min-width: 0; + max-width: 720px; + margin: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 20px; + line-height: 24px; + font-weight: 800; + letter-spacing: 0; + overflow-wrap: anywhere; +} diff --git a/src/pages/dashboard/ui/styles/sections/responsive.css b/src/pages/dashboard/ui/styles/sections/responsive.css new file mode 100644 index 0000000..2651367 --- /dev/null +++ b/src/pages/dashboard/ui/styles/sections/responsive.css @@ -0,0 +1,171 @@ +@media (min-width: 1500px) { + .ranking-table th:nth-child(2), + .ranking-table td:nth-child(2) { + white-space: nowrap; + } +} + +@media (max-width: 1120px) { + .app-shell { + grid-template-columns: 260px minmax(0, 1fr); + } + + .analytics-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .analytics-dashboard-grid { + grid-template-columns: 1fr; + } + + .analytics-dashboard-grid.is-clusters, + .analytics-dashboard-grid.is-sez, + .analytics-dashboard-grid.is-technoparks { + grid-template-columns: 1fr; + } + + .analytics-dashboard-grid.is-clusters .analytics-dashboard-column, + .analytics-dashboard-grid.is-sez .analytics-dashboard-column, + .analytics-dashboard-grid.is-technoparks .analytics-dashboard-column { + display: contents; + } + + .analytics-card--composition, + .analytics-card--ranking, + .analytics-card--trend, + .analytics-card--employees, + .analytics-card--industries { + grid-column: 1 / -1; + } + + .composition-layout { + grid-template-columns: 1fr; + } + + .analytics-split-layout { + grid-template-columns: 1fr; + } + + .composition-chart-panel-is-donut { + height: 260px; + } +} + +@media (max-width: 760px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + height: auto; + gap: 20px; + padding: 22px; + } + + .content { + padding: 24px 18px 32px; + } + + .dashboard-content-section { + min-height: auto; + padding: 22px; + border-radius: 18px; + } + + .page-header { + display: grid; + } + + .page-header-actions { + justify-content: space-between; + } + + .association-type-tabs__list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 14px; + } + + .association-type-tabs__trigger { + justify-content: flex-start; + white-space: normal; + } + + .analytics-kpi-grid { + grid-template-columns: 1fr; + } + + .analytics-card__header, + .analytics-card__header--with-legend { + display: grid; + grid-template-columns: 1fr; + } + + .analytics-chart-legend { + justify-content: flex-start; + } + + .employee-stat-grid { + grid-template-columns: 1fr; + } + + .composition-layout { + gap: 16px; + min-height: auto; + } + + .sez-donut-panel, + .technopark-donut-panel { + grid-template-columns: 1fr; + } + + .sez-donut-chart-box, + .technopark-donut-chart-box { + width: min(100%, 280px); + height: 260px; + } + + .composition-chart-panel-is-donut { + height: 240px; + } + + .composition-legend { + gap: 8px; + } + + .composition-legend-label, + .composition-legend-value { + font-size: 13px; + } + + .ranking-table { + min-width: 0; + font-size: 10px; + line-height: 13px; + } + + .ranking-table__rank-col { + width: 36px; + } + + .ranking-table th:first-child, + .ranking-table td:first-child { + width: 36px; + min-width: 36px; + max-width: 36px; + } + + .ranking-table__metric-col { + width: 42px; + } + + .ranking-table th, + .ranking-table td { + padding: 4px 3px; + } + + .trend-chart-box .unovis-xy-container { + --vis-axis-tick-label-font-size: 10px; + } +} diff --git a/src/pages/dashboard/ui/styles/sections/tables.css b/src/pages/dashboard/ui/styles/sections/tables.css new file mode 100644 index 0000000..bc9c588 --- /dev/null +++ b/src/pages/dashboard/ui/styles/sections/tables.css @@ -0,0 +1,414 @@ +.analytics-table { + width: 100%; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 12px; + line-height: 16px; +} + +.analytics-table th, +.analytics-table td { + text-align: left; + vertical-align: top; +} + +.analytics-table th { + padding: 6px 7px; +} + +.analytics-table td { + padding: 8px 7px; +} + +.analytics-table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--Infographic-Base-White, #ffffff); + color: rgb(2 36 86 / 0.66); + font-weight: 800; +} + +.analytics-table th:hover { + background: var(--Infographic-Base-White, #ffffff); +} + +.analytics-table tbody td { + transition: background-color 160ms ease; +} + +.analytics-table tbody tr:hover td { + background: rgb(243 243 243 / 0.7); +} + +.analytics-table tbody tr:hover td:first-child { + border-radius: 10px 0 0 10px; +} + +.analytics-table tbody tr:hover td:last-child { + border-radius: 0 10px 10px 0; +} + +.analytics-table tbody tr.analytics-table-empty-row:hover, +.ranking-table tbody tr.ranking-table-empty-row:hover { + background: transparent; +} + +.analytics-table tbody tr.analytics-table-empty-row:hover td, +.ranking-table tbody tr.ranking-table-empty-row:hover td { + background: transparent; +} + +.analytics-table th:first-child, +.analytics-table td:first-child { + width: 44px; + min-width: 44px; + max-width: 44px; + padding-right: 0; + padding-left: 0; + color: rgb(2 36 86 / 0.5); + text-align: center; +} + +.analytics-table th:last-child, +.analytics-table td:last-child { + text-align: right; + white-space: nowrap; +} + +.analytics-table__primary { + display: block; + min-width: 0; + overflow-wrap: anywhere; +} + +.analytics-table-empty-row td, +.ranking-table-empty-row td { + padding: 22px 8px; + color: rgb(2 36 86 / 0.48); + font-weight: 700; + text-align: center !important; + white-space: normal !important; +} + +.sez-priority-table th:nth-child(3), +.sez-priority-table td:nth-child(3) { + width: 170px; +} + +.analytics-table-header-label { + display: inline-flex; + width: 100%; + min-width: 0; + min-height: 32px; + align-items: center; + border-radius: 10px; + padding: 6px 10px; + transition: background-color 160ms ease; +} + +.analytics-table th:first-child .analytics-table-header-label { + justify-content: center; +} + +.analytics-table th:last-child .analytics-table-header-label { + justify-content: flex-end; +} + +.analytics-table th:hover .analytics-table-header-label { + background: rgb(232 236 242 / 0.9); +} + +.analytics-table-sort-button { + display: inline-flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: 6px; + border: 0; + background: transparent; + border-radius: 10px; + min-height: 32px; + padding: 6px 10px; + color: inherit; + font: inherit; + font-weight: inherit; + text-align: right; + transition: background-color 160ms ease; + cursor: pointer; +} + +.analytics-table th:hover .analytics-table-sort-button, +.analytics-table-sort-button:hover, +.analytics-table-sort-button:focus-visible { + background: rgb(232 236 242 / 0.9); +} + +.analytics-table-sort-button span { + flex: 0 1 auto; + min-width: 0; + overflow: visible; + text-overflow: clip; + overflow-wrap: normal; + white-space: nowrap; +} + +.analytics-table-sort-button svg { + width: 15px; + min-width: 15px; + height: 15px; + flex: 0 0 15px; + color: rgb(2 36 86 / 0.42); + stroke-width: 2.25; + transform-origin: center; + transition: + color 160ms ease, + transform 160ms ease; +} + +.analytics-table-sort-button:hover svg, +.analytics-table-sort-button.is-active svg { + color: rgb(2 36 86 / 0.68); +} + +.analytics-table-sort-button.is-desc svg { + transform: rotate(180deg); +} + +.employee-stat-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} + +.employee-stat-card { + display: grid; + min-height: 130px; + align-content: start; + gap: 10px; + padding: 20px; + border: 1px solid rgb(2 36 86 / 0.08); + border-radius: 16px; + background: linear-gradient(180deg, rgb(245 247 250 / 0.72), rgb(255 255 255 / 0.94)); +} + +.employee-stat-card__value { + color: var(--Infographic-Main-Red, #c90237); + font-size: 34px; + line-height: 1; + font-weight: 800; +} + +.employee-stat-card__label { + color: rgb(2 36 86 / 0.7); + font-size: 13px; + line-height: 17px; + font-weight: 700; +} + +.ranking-table-shell { + min-width: 0; + height: min(338px, 44vh); + min-height: 260px; + padding-right: 24px; +} + +.ranking-table-shell--compact .ranking-table { + width: min(100%, 620px); + margin-right: auto; + margin-left: 0; +} + +.ranking-table-shell [data-slot='scroll-area-viewport'] { + padding-right: 16px; +} + +.ranking-table-shell [data-slot='scroll-area-scrollbar'] { + top: 0; + right: 4px; + bottom: 0; + width: 4px; +} + +.ranking-table-shell [data-slot='scroll-area-thumb'] { + border-radius: 999px; + background: #022456; +} + +.ranking-table { + width: 100%; + min-width: 560px; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + color: var(--Infographic-Main-Dark-Blue, #022456); + font-size: 12.5px; + line-height: 17px; +} + +.ranking-table__rank-col { + width: 44px; +} + +.ranking-table__region-col { + width: auto; +} + +.ranking-table__metric-col { + width: 136px; +} + +.analytics-dashboard-grid.is-clusters .ranking-table__region-col, +.analytics-dashboard-grid.is-sez .ranking-table__region-col, +.analytics-dashboard-grid.is-technoparks .ranking-table__region-col { + width: auto; +} + +.analytics-dashboard-grid.is-clusters .ranking-table__metric-col, +.analytics-dashboard-grid.is-sez .ranking-table__metric-col, +.analytics-dashboard-grid.is-technoparks .ranking-table__metric-col { + width: 136px; +} + +.ranking-table th, +.ranking-table td { + text-align: left; + vertical-align: middle; +} + +.ranking-table th { + padding: 6px 8px; +} + +.ranking-table td { + padding: 8px 8px; +} + +.ranking-table th { + position: sticky; + top: 0; + z-index: 1; + background: var(--Infographic-Base-White, #ffffff); + color: var(--Infographic-Main-Dark-Blue, #022456); + font-weight: 800; +} + +.ranking-table th:hover { + background: var(--Infographic-Base-White, #ffffff); +} + +.ranking-table th:first-child, +.ranking-table td:first-child { + width: 44px; + min-width: 44px; + max-width: 44px; + padding-right: 0; + padding-left: 0; + color: rgb(2 36 86 / 0.56); + text-align: center; +} + +.ranking-table th:nth-child(n + 3), +.ranking-table td:nth-child(n + 3) { + text-align: right; +} + +.ranking-table-header-label { + display: inline-flex; + width: 100%; + min-width: 0; + min-height: 32px; + align-items: center; + border-radius: 10px; + padding: 6px 10px; + transition: background-color 160ms ease; +} + +.ranking-table th:first-child .ranking-table-header-label { + justify-content: center; +} + +.ranking-table th:nth-child(n + 3) .ranking-table-header-label { + justify-content: flex-end; +} + +.ranking-table th:hover .ranking-table-header-label { + background: rgb(232 236 242 / 0.96); +} + +.ranking-table-sort-button { + display: inline-flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: 6px; + border: 0; + background: transparent; + border-radius: 10px; + min-height: 32px; + padding: 6px 10px; + color: inherit; + font: inherit; + font-weight: inherit; + text-align: right; + transition: background-color 160ms ease; + cursor: pointer; +} + +.ranking-table th:hover .ranking-table-sort-button, +.ranking-table-sort-button:hover, +.ranking-table-sort-button:focus-visible { + background: rgb(232 236 242 / 0.96); +} + +.ranking-table-sort-button span { + flex: 0 1 auto; + min-width: 0; + overflow: visible; + text-overflow: clip; + overflow-wrap: normal; + white-space: nowrap; +} + +.ranking-table-sort-button svg { + width: 15px; + min-width: 15px; + height: 15px; + flex: 0 0 15px; + color: rgb(2 36 86 / 0.42); + stroke-width: 2.25; + transform-origin: center; + transition: + color 160ms ease, + transform 160ms ease; +} + +.ranking-table-sort-button:hover svg, +.ranking-table-sort-button.is-active svg { + color: rgb(2 36 86 / 0.68); +} + +.ranking-table-sort-button.is-desc svg { + transform: rotate(180deg); +} + +.ranking-table tbody td { + transition: background-color 160ms ease; +} + +.ranking-table tbody tr:hover td { + background: rgb(243 243 243 / 0.7); +} + +.ranking-table tbody tr:hover td:first-child { + border-radius: 10px 0 0 10px; +} + +.ranking-table tbody tr:hover td:last-child { + border-radius: 0 10px 10px 0; +} diff --git a/src/router/index.ts b/src/router/index.ts deleted file mode 100644 index 4ed55d4..0000000 --- a/src/router/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router' -import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' -import { ensureAuthorizedSession } from '@/shared/api' - -export const router = createRouter({ - history: createWebHistory(), - routes: [ - { - path: '/', - redirect: { name: 'dashboard' }, - }, - { - path: '/auth', - name: 'auth', - component: () => import('@/pages/auth').then((module) => module.AuthPage), - meta: { - title: 'Вход', - }, - }, - { - path: '/', - component: () => import('@/app/layouts').then((module) => module.ProtectedRouteLayout), - meta: { - requiresAuth: true, - }, - children: [ - { - path: 'dashboard', - name: 'dashboard', - component: () => import('@/pages/dashboard').then((module) => module.DashboardPage), - meta: { - title: 'Аналитическая панель', - }, - }, - { - path: 'reports', - redirect: { name: 'dashboard' }, - }, - ], - }, - ] satisfies RouteRecordRaw[], -}) - -const hasAuthRequirement = (to: RouteLocationNormalized): boolean => - to.matched.some((routeRecord) => routeRecord.meta.requiresAuth) - -router.beforeEach(async (to) => { - const isAuthorized = await ensureAuthorizedSession() - - if (hasAuthRequirement(to) && !isAuthorized) { - return { - name: 'auth', - query: { - redirect: to.fullPath, - }, - } - } - - if (to.name === 'auth' && isAuthorized) { - return { name: 'dashboard' } - } - - return true -}) - -router.afterEach((to) => { - const title = typeof to.meta.title === 'string' ? to.meta.title : null - - document.title = title ? `${title} | Карта компетенций` : 'Карта компетенций' -}) diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index c3fd7bc..b5e52c6 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,5 +1,12 @@ export { ensureAuthorizedSession } from './authSession' export { customInstance, refreshAccessToken } from './axiosInstance' +export { api_v1_competence_map_analytics_filters_list as getCompetenceMapAnalyticsFilters } from './generated-api/competence-map-analytics' +export { api_v1_competence_map_analytics_list as getCompetenceMapAnalytics } from './generated-api/competence-map-analytics' +export { + api_v1_users_login_createMutation as createUserLoginMutation, + api_v1_users_logout_createMutation as createUserLogoutMutation, +} from './generated-api/authentication' +export { api_v1_users_me_list as getCurrentUser } from './generated-api/user' export { normalizeApiError, resolveApiErrorMessage } from './error' export { clearAuthTokens, @@ -9,4 +16,4 @@ export { setAuthTokens, setRefreshToken, } from './tokenStore' -export { useLogout } from './useLogout' +export type * from './generated-api/endpoints.schemas' diff --git a/src/shared/ui/components/AppHeader/AppHeader.vue b/src/shared/ui/components/AppHeader/AppHeader.vue index 912e4ff..d79aa6f 100644 --- a/src/shared/ui/components/AppHeader/AppHeader.vue +++ b/src/shared/ui/components/AppHeader/AppHeader.vue @@ -16,13 +16,27 @@ const props = defineProps<{ roleLabel?: string | null role?: AppHeaderUserRole | null isProfileIdentityLoading?: boolean + isLogoutDisabled?: boolean + isMainPage?: boolean + mainPagePath?: string + mainPageTopAnchor?: string +}>() + +defineEmits<{ + brandMainPageClick: [] + logout: [] }>()