feat(app): initialize analytical panel

This commit is contained in:
Gleb Korotkiy
2026-06-19 17:15:05 +03:00
commit 75e9616bcb
25 changed files with 2994 additions and 0 deletions

22
src/App.vue Normal file
View File

@@ -0,0 +1,22 @@
<template>
<div class="app-shell">
<aside class="sidebar" aria-label="Основная навигация">
<div class="brand">
<span class="brand-mark" aria-hidden="true">AP</span>
<span>
<strong>Analytical Panel</strong>
<small>операционный контур</small>
</span>
</div>
<nav class="nav">
<RouterLink to="/">Обзор</RouterLink>
<RouterLink to="/reports">Отчеты</RouterLink>
</nav>
</aside>
<main class="content">
<RouterView />
</main>
</div>
</template>

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(createPinia()).use(router).mount('#app')

20
src/router/index.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue'
import ReportsView from '@/views/ReportsView.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'dashboard',
component: DashboardView,
},
{
path: '/reports',
name: 'reports',
component: ReportsView,
},
],
})

View File

@@ -0,0 +1,23 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useDashboardStore } from './dashboard'
describe('dashboard store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('keeps segment shares normalized', () => {
const store = useDashboardStore()
expect(store.totalSegmentShare).toBe(100)
})
it('returns ready reports only', () => {
const store = useDashboardStore()
expect(store.readyReports).toHaveLength(1)
expect(store.readyReports[0].status).toBe('ready')
})
})

62
src/stores/dashboard.ts Normal file
View File

@@ -0,0 +1,62 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export interface Metric {
label: string
value: string
delta: string
tone: 'positive' | 'warning' | 'neutral'
}
export interface Segment {
name: string
value: number
color: string
}
export interface Report {
name: string
owner: string
status: 'ready' | 'draft' | 'review'
updatedAt: string
}
export const useDashboardStore = defineStore('dashboard', () => {
const metrics = ref<Metric[]>([
{ label: 'Выручка', value: '128,4 млн ₽', delta: '+12,8%', tone: 'positive' },
{ label: 'Маржинальность', value: '34,6%', delta: '+2,1 п.п.', tone: 'positive' },
{ label: 'Заявки', value: '1 482', delta: '-3,4%', tone: 'warning' },
{ label: 'SLA', value: '97,2%', delta: 'норма', tone: 'neutral' },
])
const segments = ref<Segment[]>([
{ name: 'Производство', value: 44, color: '#1f7a5c' },
{ name: 'Логистика', value: 27, color: '#246b9f' },
{ name: 'Продажи', value: 18, color: '#b56219' },
{ name: 'Сервис', value: 11, color: '#6d5b99' },
])
const reports = ref<Report[]>([
{ name: 'Еженедельная динамика', owner: 'Аналитика', status: 'ready', updatedAt: '19.06.2026' },
{ name: 'План-факт по регионам', owner: 'Финансы', status: 'review', updatedAt: '18.06.2026' },
{
name: 'Операционная загрузка',
owner: 'Производство',
status: 'draft',
updatedAt: '17.06.2026',
},
])
const readyReports = computed(() => reports.value.filter((report) => report.status === 'ready'))
const totalSegmentShare = computed(() =>
segments.value.reduce((total, segment) => total + segment.value, 0),
)
return {
metrics,
segments,
reports,
readyReports,
totalSegmentShare,
}
})

435
src/style.css Normal file
View File

@@ -0,0 +1,435 @@
:root {
--bg: #f4f6f8;
--surface: #ffffff;
--surface-soft: #eef2f5;
--text: #1e252c;
--muted: #65717d;
--border: #d8e0e7;
--accent: #246b9f;
--positive: #1f7a5c;
--warning: #b56219;
--neutral: #566171;
--danger: #a44747;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100svh;
margin: 0;
}
a {
color: inherit;
}
button,
input,
textarea,
select {
font: inherit;
}
#app {
min-height: 100svh;
}
.app-shell {
display: grid;
grid-template-columns: 264px minmax(0, 1fr);
min-height: 100svh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 32px;
padding: 24px;
background: #17212b;
color: #f6f8fa;
}
.brand {
display: flex;
gap: 12px;
align-items: center;
}
.brand-mark {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border-radius: 8px;
background: #f0b35a;
color: #17212b;
font-size: 13px;
font-weight: 800;
}
.brand strong,
.brand small {
display: block;
}
.brand strong {
font-size: 15px;
line-height: 1.3;
}
.brand small {
margin-top: 2px;
color: #b7c3ce;
font-size: 12px;
}
.nav {
display: grid;
gap: 8px;
}
.nav a {
display: flex;
min-height: 40px;
align-items: center;
border-radius: 8px;
padding: 0 12px;
color: #c6d0da;
text-decoration: none;
}
.nav a:hover,
.nav a.router-link-active {
background: #243241;
color: #ffffff;
}
.content {
display: grid;
align-content: start;
gap: 24px;
padding: 32px;
}
.page-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
}
.page-header h1 {
margin: 4px 0 0;
font-size: 32px;
line-height: 1.15;
font-weight: 750;
letter-spacing: 0;
}
.page-header time {
color: var(--muted);
font-size: 14px;
white-space: nowrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.metric-card,
.panel {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}
.metric-card {
display: grid;
gap: 10px;
min-height: 132px;
padding: 18px;
}
.metric-card span {
color: var(--muted);
font-size: 14px;
}
.metric-card strong {
font-size: 28px;
line-height: 1.1;
}
.metric-card small {
width: fit-content;
border-radius: 999px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
}
.positive {
background: #e4f3ed;
color: var(--positive);
}
.warning {
background: #fff0dc;
color: var(--warning);
}
.neutral {
background: var(--surface-soft);
color: var(--neutral);
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 16px;
}
.panel {
padding: 20px;
}
.panel-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.panel-heading h2 {
margin: 0;
font-size: 20px;
line-height: 1.2;
letter-spacing: 0;
}
.panel-heading span {
color: var(--muted);
font-size: 14px;
font-weight: 700;
}
.segments {
display: grid;
gap: 16px;
}
.segment-row {
display: grid;
grid-template-columns: 148px minmax(80px, 1fr) 44px;
gap: 14px;
align-items: center;
}
.segment-label {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 14px;
}
.segment-label span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 50%;
}
.segment-track {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: var(--surface-soft);
}
.segment-track span {
display: block;
height: 100%;
}
.segment-row strong {
color: var(--muted);
font-size: 14px;
text-align: right;
}
.report-list {
display: grid;
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.report-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--border);
padding-bottom: 12px;
}
.report-list li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.report-list strong,
.report-list span {
display: block;
}
.report-list strong {
font-size: 14px;
}
.report-list span {
margin-top: 3px;
color: var(--muted);
font-size: 13px;
}
.status {
display: inline-flex;
min-width: 72px;
justify-content: center;
border-radius: 999px;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
}
.status.ready {
background: #e4f3ed;
color: var(--positive);
}
.status.review {
background: #e6f0f8;
color: var(--accent);
}
.status.draft {
background: #f7e8e7;
color: var(--danger);
}
.reports-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.reports-table th,
.reports-table td {
border-bottom: 1px solid var(--border);
padding: 14px 12px;
text-align: left;
}
.reports-table th {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.reports-table tr:last-child td {
border-bottom: 0;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 1;
gap: 16px;
padding: 16px;
}
.nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.content {
padding: 20px;
}
.metric-grid,
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.page-header {
display: grid;
}
.metric-grid {
gap: 12px;
}
.segment-row {
grid-template-columns: 1fr 44px;
}
.segment-track {
grid-column: 1 / -1;
grid-row: 2;
}
.reports-table {
min-width: 620px;
}
.panel {
overflow-x: auto;
}
}

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useDashboardStore } from '@/stores/dashboard'
const dashboard = useDashboardStore()
</script>
<template>
<section class="page-header">
<div>
<p class="eyebrow">Обзор</p>
<h1>Аналитическая панель</h1>
</div>
<time datetime="2026-06-19">19 июня 2026</time>
</section>
<section class="metric-grid" aria-label="Ключевые показатели">
<article v-for="metric in dashboard.metrics" :key="metric.label" class="metric-card">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small :class="metric.tone">{{ metric.delta }}</small>
</article>
</section>
<section class="dashboard-grid">
<article class="panel">
<div class="panel-heading">
<h2>Структура портфеля</h2>
<span>{{ dashboard.totalSegmentShare }}%</span>
</div>
<div class="segments">
<div v-for="segment in dashboard.segments" :key="segment.name" class="segment-row">
<div class="segment-label">
<span class="dot" :style="{ backgroundColor: segment.color }" aria-hidden="true"></span>
<span>{{ segment.name }}</span>
</div>
<div class="segment-track" aria-hidden="true">
<span :style="{ width: `${segment.value}%`, backgroundColor: segment.color }"></span>
</div>
<strong>{{ segment.value }}%</strong>
</div>
</div>
</article>
<article class="panel">
<div class="panel-heading">
<h2>Готовые отчеты</h2>
<span>{{ dashboard.readyReports.length }}/{{ dashboard.reports.length }}</span>
</div>
<ul class="report-list">
<li v-for="report in dashboard.reports" :key="report.name">
<div>
<strong>{{ report.name }}</strong>
<span>{{ report.owner }}</span>
</div>
<small :class="['status', report.status]">{{ report.status }}</small>
</li>
</ul>
</article>
</section>
</template>

37
src/views/ReportsView.vue Normal file
View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useDashboardStore } from '@/stores/dashboard'
const dashboard = useDashboardStore()
</script>
<template>
<section class="page-header">
<div>
<p class="eyebrow">Реестр</p>
<h1>Отчеты</h1>
</div>
</section>
<section class="panel">
<table class="reports-table">
<thead>
<tr>
<th scope="col">Название</th>
<th scope="col">Владелец</th>
<th scope="col">Статус</th>
<th scope="col">Обновлен</th>
</tr>
</thead>
<tbody>
<tr v-for="report in dashboard.reports" :key="report.name">
<td>{{ report.name }}</td>
<td>{{ report.owner }}</td>
<td>
<span :class="['status', report.status]">{{ report.status }}</span>
</td>
<td>{{ report.updatedAt }}</td>
</tr>
</tbody>
</table>
</section>
</template>