feat(app): initialize analytical panel
This commit is contained in:
22
src/App.vue
Normal file
22
src/App.vue
Normal 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
8
src/main.ts
Normal 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
20
src/router/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
23
src/stores/dashboard.spec.ts
Normal file
23
src/stores/dashboard.spec.ts
Normal 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
62
src/stores/dashboard.ts
Normal 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
435
src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/views/DashboardView.vue
Normal file
62
src/views/DashboardView.vue
Normal 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
37
src/views/ReportsView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user