Files
portal/web/02-dashboard.html
T
2026-05-06 01:39:59 +07:00

1329 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Лидпоток — Дашборд</title>
<!-- ============================================================
Прил. Л — Прототип №2: Дашборд
Версия: v0.1 от 05.05.2026
Источники: brandbook.md v1.1, narrative §12 (KPI + графики),
§8.1 (14 статусов с цветами), §21.3 (low balance),
§1.6 (бизнес-модель), Прил. М §2.6 (виджет напоминаний)
Стек: HTML + CSS + JS, ApexCharts через CDN cdnjs.cloudflare.com
============================================================ -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap&subset=cyrillic" rel="stylesheet" />
<!-- ApexCharts через CDN (наш утверждённый source) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/apexcharts/3.45.2/apexcharts.min.js"></script>
<style>
/* ============ Брендовые токены ============ */
:root {
--teal-900: #04342C;
--teal-600: #0F6E56;
--teal-400: #1D9E75;
--teal-200: #5DCAA5;
--teal-100: #9FE1CB;
--teal-50: #E1F5EE;
--gray-900: #1A1A1A;
--gray-700: #444441;
--gray-500: #888780;
--gray-300: #B4B2A9;
--gray-200: #D3D1C7;
--gray-100: #F1EFE8;
--gray-50: #FAFAF8;
--white: #FFFFFF;
--success: #22C55E;
--warning: #F59E0B;
--danger: #EF4444;
--info: #3B82F6;
/* Цвета 14 статусов воронки (narrative §8.1) */
--status-new: #3B82F6; /* синий */
--status-viewed: #8B5CF6; /* фиолетовый */
--status-worked: #06B6D4; /* голубой */
--status-base: #888780; /* серый */
--status-missed: #F59E0B; /* амбер */
--status-negotiations: #EAB308; /* жёлтый */
--status-waiting_payment: #C084FC; /* сиреневый */
--status-partnership: #EC4899; /* розовый */
--status-paid: #22C55E; /* зелёный */
--status-closed: #888780; /* серый */
--status-test_drive: #14B8A6; /* бирюзовый */
--status-hot: #EF4444; /* красный */
--status-replacement: #F97316; /* оранжевый */
--status-final_missed: #444441; /* тёмно-серый */
--space-1: 4px; --space-2: 8px; --space-3: 12px;
--space-4: 16px; --space-6: 24px; --space-8: 32px; --space-12: 48px;
--radius-sm: 6px; --radius-md: 8px; --radius-lg: 12px; --radius-pill: 999px;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', Menlo, Monaco, Consolas, monospace;
--shadow-sm: 0 1px 2px rgba(4, 52, 44, 0.06);
--shadow-md: 0 4px 12px rgba(4, 52, 44, 0.08);
--shadow-lg: 0 12px 32px rgba(4, 52, 44, 0.12);
--sidebar-width: 240px;
--topbar-height: 60px;
}
/* ============ Reset ============ */
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--gray-900);
background: var(--gray-100);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button { font: inherit; cursor: pointer; }
input, select, button { font-family: inherit; }
a { color: var(--teal-600); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ============ Layout приложения ============ */
.app {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
grid-template-rows: var(--topbar-height) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar main";
min-height: 100vh;
}
/* -------- Боковое меню -------- */
.sidebar {
grid-area: sidebar;
background: var(--white);
border-right: 1px solid var(--gray-200);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
}
.sidebar-brand {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--gray-100);
flex-shrink: 0;
}
.brand-mark-circles {
display: flex; align-items: center; gap: 3px;
}
.brand-mark-circles span {
display: block; border-radius: 50%; background: var(--teal-600);
}
.brand-mark-circles span:nth-child(1) { width: 6px; height: 6px; opacity: 0.6; }
.brand-mark-circles span:nth-child(2) { width: 10px; height: 10px; opacity: 0.8; }
.brand-mark-circles span:nth-child(3) { width: 14px; height: 14px; }
.brand-name {
font-size: 16px;
font-weight: 600;
color: var(--teal-900);
letter-spacing: -0.01em;
}
.sidebar-nav {
flex: 1;
padding: 12px 8px;
overflow-y: auto;
}
.sidebar-section {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--gray-500);
padding: 16px 12px 8px;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 12px;
border-radius: var(--radius-md);
color: var(--gray-700);
font-size: 14px;
font-weight: 500;
transition: background-color .15s ease, color .15s ease;
text-decoration: none;
margin-bottom: 2px;
}
.sidebar-link:hover {
background: var(--gray-50);
color: var(--gray-900);
text-decoration: none;
}
.sidebar-link.is-active {
background: var(--teal-50);
color: var(--teal-600);
}
.sidebar-link svg { flex-shrink: 0; }
.sidebar-link .badge {
margin-left: auto;
background: var(--gray-100);
color: var(--gray-700);
padding: 2px 7px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
}
.sidebar-link.is-active .badge {
background: var(--teal-600);
color: var(--white);
}
.sidebar-foot {
padding: 16px;
border-top: 1px solid var(--gray-100);
flex-shrink: 0;
}
.sidebar-user {
display: flex; align-items: center; gap: 10px;
}
.avatar {
width: 32px; height: 32px;
border-radius: 50%;
background: var(--teal-600);
color: var(--white);
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 12px;
flex-shrink: 0;
}
.sidebar-user-info { flex: 1; min-width: 0; }
.sidebar-user-name {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sidebar-user-role {
font-size: 11px; color: var(--gray-500);
}
/* -------- Верхняя панель -------- */
.topbar {
grid-area: topbar;
background: var(--white);
border-bottom: 1px solid var(--gray-200);
display: flex;
align-items: center;
gap: 16px;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.topbar-search {
flex: 1;
max-width: 480px;
position: relative;
}
.topbar-search-input {
width: 100%;
height: 36px;
padding: 0 12px 0 38px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
background: var(--gray-50);
font-size: 13px;
transition: border-color .15s ease, background-color .15s ease;
}
.topbar-search-input::placeholder { color: var(--gray-500); }
.topbar-search-input:hover { background: var(--white); border-color: var(--gray-300); }
.topbar-search-input:focus {
outline: none;
background: var(--white);
border-color: var(--teal-600);
box-shadow: 0 0 0 3px rgba(15, 110, 86, 0.1);
}
.topbar-search svg {
position: absolute;
left: 12px; top: 50%; transform: translateY(-50%);
color: var(--gray-500);
pointer-events: none;
}
.topbar-actions {
display: flex; align-items: center; gap: 12px;
margin-left: auto;
}
/* Виджет баланса в шапке (narrative §1.6, §21) */
.balance-widget {
display: flex;
align-items: stretch;
background: var(--teal-50);
border: 1px solid var(--teal-100);
border-radius: var(--radius-md);
overflow: hidden;
}
.balance-cell {
padding: 6px 14px;
display: flex;
flex-direction: column;
gap: 1px;
cursor: pointer;
transition: background-color .15s ease;
}
.balance-cell:hover { background: var(--teal-100); }
.balance-cell + .balance-cell { border-left: 1px solid var(--teal-100); }
.balance-cell-label {
font-size: 10px;
color: var(--teal-900);
opacity: 0.7;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
}
.balance-cell-value {
font-size: 14px;
font-weight: 600;
color: var(--teal-900);
font-variant-numeric: tabular-nums;
}
.balance-cell.is-low .balance-cell-value { color: var(--danger); }
.icon-btn {
width: 36px; height: 36px;
border: 0;
background: transparent;
border-radius: var(--radius-md);
color: var(--gray-700);
display: flex; align-items: center; justify-content: center;
transition: background-color .15s ease, color .15s ease;
position: relative;
}
.icon-btn:hover { background: var(--gray-100); color: var(--gray-900); }
.icon-btn .dot {
position: absolute;
top: 8px; right: 8px;
width: 8px; height: 8px;
background: var(--danger);
border: 2px solid var(--white);
border-radius: 50%;
}
.topbar-user-trigger {
display: flex; align-items: center; gap: 8px;
padding: 4px 10px 4px 4px;
border: 0;
background: transparent;
border-radius: var(--radius-pill);
transition: background-color .15s ease;
}
.topbar-user-trigger:hover { background: var(--gray-100); }
.topbar-user-trigger .name {
font-size: 13px; font-weight: 500;
}
/* -------- Главная область -------- */
.main {
grid-area: main;
padding: 24px 32px 48px;
max-width: 1440px;
width: 100%;
}
/* Заголовок страницы + контролы */
.page-head {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
margin-bottom: 8px;
}
.page-title {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.page-subtitle {
font-size: 14px;
color: var(--gray-500);
margin-bottom: 24px;
}
.controls {
display: flex; align-items: center; gap: 8px;
}
.date-range {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
color: var(--gray-700);
transition: border-color .15s ease;
}
.date-range:hover { border-color: var(--gray-300); }
.date-range select {
border: 0; background: transparent; outline: none;
font: inherit; color: inherit; padding: 0;
cursor: pointer;
}
.btn-icon-text {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
color: var(--gray-700);
font-size: 13px;
font-weight: 500;
transition: border-color .15s ease, color .15s ease;
}
.btn-icon-text:hover { border-color: var(--gray-300); color: var(--gray-900); }
/* Алерт (низкий баланс) */
.alert-bar {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: #FEF3C7;
border: 1px solid #FDE68A;
border-radius: var(--radius-md);
margin-bottom: 24px;
color: #78350F;
font-size: 13px;
}
.alert-bar svg { flex-shrink: 0; margin-top: 1px; color: var(--warning); }
.alert-bar strong { color: #78350F; }
.alert-bar .alert-action {
margin-left: auto;
padding: 6px 14px;
background: var(--warning);
color: var(--white);
border: 0;
border-radius: var(--radius-md);
font-size: 12px;
font-weight: 500;
}
.alert-bar .alert-action:hover { background: #D97706; }
/* KPI Grid */
.kpi-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1280px) { .kpi-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } }
.kpi-card {
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color .15s ease, transform .05s ease;
}
.kpi-card:hover { border-color: var(--gray-300); }
.kpi-card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.kpi-card-label {
font-size: 12px;
color: var(--gray-500);
font-weight: 500;
}
.kpi-card-icon {
width: 28px; height: 28px;
border-radius: var(--radius-md);
background: var(--teal-50);
color: var(--teal-600);
display: flex; align-items: center; justify-content: center;
}
.kpi-card-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
margin-bottom: 4px;
}
.kpi-card-trend {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--radius-pill);
font-variant-numeric: tabular-nums;
}
.kpi-card-trend.is-up { background: #DCFCE7; color: #166534; }
.kpi-card-trend.is-down { background: #FEE2E2; color: #991B1B; }
.kpi-card-trend.is-flat { background: var(--gray-100); color: var(--gray-700); }
/* Двухколоночный grid под графики и таблицу */
.grid-2 {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 1100px) { .grid-2 { grid-template-columns: 1fr; } }
.card {
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
padding: 20px;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.card-meta {
font-size: 12px;
color: var(--gray-500);
}
/* График активности */
#chart-activity {
height: 260px;
}
/* Donut статусов */
#chart-statuses { height: 280px; }
/* Список последних сделок */
.deals-list { display: flex; flex-direction: column; }
.deal-row {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 12px;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--gray-100);
transition: background-color .15s ease;
margin: 0 -12px;
padding-left: 12px;
padding-right: 12px;
border-radius: var(--radius-md);
cursor: pointer;
}
.deal-row:last-child { border-bottom: 0; }
.deal-row:hover { background: var(--gray-50); }
.deal-id {
font-family: var(--font-mono);
font-size: 11px;
color: var(--gray-500);
font-weight: 500;
}
.deal-info { min-width: 0; }
.deal-name {
font-size: 13px;
font-weight: 500;
color: var(--gray-900);
margin-bottom: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.deal-meta {
font-size: 12px;
color: var(--gray-500);
display: flex; gap: 8px; align-items: center;
}
.deal-meta-dot { color: var(--gray-300); }
.deal-time {
font-size: 11px;
color: var(--gray-500);
white-space: nowrap;
}
/* Бейдж статуса */
.status-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
background: var(--gray-100);
color: var(--gray-700);
}
.status-chip::before {
content: ''; width: 6px; height: 6px; border-radius: 50%;
background: currentColor;
}
.status-chip.s-new { background: #DBEAFE; color: #1E40AF; }
.status-chip.s-viewed { background: #EDE9FE; color: #5B21B6; }
.status-chip.s-worked { background: #CFFAFE; color: #155E75; }
.status-chip.s-base { background: var(--gray-100); color: var(--gray-700); }
.status-chip.s-missed { background: #FEF3C7; color: #92400E; }
.status-chip.s-negotiations { background: #FEF9C3; color: #854D0E; }
.status-chip.s-waiting_payment { background: #F3E8FF; color: #6B21A8; }
.status-chip.s-partnership { background: #FCE7F3; color: #9D174D; }
.status-chip.s-paid { background: #DCFCE7; color: #166534; }
.status-chip.s-closed { background: var(--gray-100); color: var(--gray-700); }
.status-chip.s-test_drive { background: #CCFBF1; color: #115E59; }
.status-chip.s-hot { background: #FEE2E2; color: #991B1B; }
.status-chip.s-replacement { background: #FFEDD5; color: #9A3412; }
.status-chip.s-final_missed { background: var(--gray-100); color: var(--gray-900); }
/* Виджет напоминаний */
.reminders-list { display: flex; flex-direction: column; gap: 8px; }
.reminder {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
transition: border-color .15s ease;
}
.reminder:hover { border-color: var(--teal-200); }
.reminder.is-overdue {
border-color: #FECACA;
background: #FEF2F2;
}
.reminder-time {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--gray-700);
flex-shrink: 0;
min-width: 44px;
}
.reminder.is-overdue .reminder-time { color: var(--danger); }
.reminder-body { flex: 1; min-width: 0; }
.reminder-text {
font-size: 13px;
color: var(--gray-900);
margin-bottom: 2px;
}
.reminder-meta {
font-size: 11px;
color: var(--gray-500);
}
/* Hint без напоминаний */
.empty-state {
text-align: center;
padding: 24px 16px;
color: var(--gray-500);
font-size: 13px;
}
/* Spec dialog (общий с прототипом 01) */
.spec-toggle {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 100;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray-900);
color: var(--white);
border: 0;
box-shadow: var(--shadow-lg);
font-size: 18px;
font-weight: 600;
}
.spec-toggle:hover { background: var(--teal-600); }
.spec-overlay {
position: fixed;
inset: 0;
background: rgba(4, 52, 44, 0.6);
backdrop-filter: blur(2px);
display: none;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 99;
}
.spec-overlay.is-open { display: flex; }
.spec-card {
max-width: 720px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
background: var(--white);
border-radius: var(--radius-lg);
padding: 32px;
box-shadow: var(--shadow-lg);
}
.spec-card h2 {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.spec-card h3 {
margin: 24px 0 12px;
font-size: 13px;
font-weight: 600;
color: var(--teal-600);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.spec-card ul { margin: 0 0 16px; padding-left: 24px; }
.spec-card li { margin-bottom: 8px; font-size: 13px; line-height: 1.6; color: var(--gray-700); }
.spec-card code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--gray-100);
padding: 2px 6px;
border-radius: var(--radius-sm);
color: var(--teal-900);
}
.btn-secondary {
background: var(--white);
color: var(--gray-700);
border: 1px solid var(--gray-200);
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
transition: border-color .15s ease;
}
.btn-secondary:hover { border-color: var(--gray-300); color: var(--gray-900); }
/* Адаптация мобильная — скрываем sidebar (упрощение для прототипа) */
@media (max-width: 768px) {
.app {
grid-template-columns: 1fr;
grid-template-areas:
"topbar"
"main";
}
.sidebar { display: none; }
.main { padding: 16px; }
.balance-widget { display: none; }
.topbar-search { max-width: none; }
}
</style>
</head>
<body>
<div class="app">
<!-- ===== Боковое меню ===== -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-mark-circles" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<span class="brand-name">Лидпоток</span>
</div>
<nav class="sidebar-nav" aria-label="Главное меню">
<div class="sidebar-section">Работа</div>
<a href="#" class="sidebar-link is-active">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/>
<rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
</svg>
Дашборд
</a>
<a href="#" class="sidebar-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M3 12h18"/><path d="M3 18h18"/>
</svg>
Сделки
<span class="badge">12 899</span>
</a>
<a href="#" class="sidebar-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="6" height="18"/><rect x="11" y="3" width="6" height="12"/>
<rect x="19" y="3" width="2" height="6"/>
</svg>
Канбан
</a>
<a href="#" class="sidebar-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
Проекты
<span class="badge">7</span>
</a>
<a href="#" class="sidebar-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Аналитика
</a>
<div class="sidebar-section">Управление</div>
<a href="#" class="sidebar-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>
</svg>
Биллинг
</a>
<a href="#" class="sidebar-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Настройки
</a>
</nav>
<div class="sidebar-foot">
<div class="sidebar-user">
<div class="avatar">МП</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name">Михаил П.</div>
<div class="sidebar-user-role">Тариф «Профессиональный»</div>
</div>
</div>
</div>
</aside>
<!-- ===== Верхняя панель ===== -->
<header class="topbar">
<div class="topbar-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="search" class="topbar-search-input"
placeholder="Поиск по сделкам, проектам, телефонам…"
aria-label="Поиск" />
</div>
<div class="topbar-actions">
<!-- Виджет баланса (narrative §1.6) -->
<div class="balance-widget" role="status" aria-label="Баланс">
<button class="balance-cell" type="button" title="Пополнить рублёвый баланс">
<span class="balance-cell-label">Баланс</span>
<span class="balance-cell-value">12 450&nbsp;</span>
</button>
<button class="balance-cell is-low" type="button" title="Пополнить лиды">
<span class="balance-cell-label">Лиды</span>
<span class="balance-cell-value">8</span>
</button>
</div>
<button class="icon-btn" type="button" aria-label="Уведомления" title="Уведомления">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="dot"></span>
</button>
<button class="topbar-user-trigger" type="button" aria-label="Меню пользователя">
<div class="avatar">МП</div>
<span class="name">Михаил</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</header>
<!-- ===== Основное содержимое ===== -->
<main class="main">
<!-- Заголовок страницы + контролы периода -->
<div class="page-head">
<div>
<h1 class="page-title">Дашборд</h1>
<div class="page-subtitle">Сводка работы за выбранный период · обновлено 2 минуты назад</div>
</div>
<div class="controls">
<div class="date-range">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<select id="period-select" aria-label="Период">
<option value="today">Сегодня</option>
<option value="7d" selected>Последние 7 дней</option>
<option value="30d">Последние 30 дней</option>
<option value="month">Текущий месяц</option>
<option value="custom">Выбрать диапазон…</option>
</select>
</div>
<button class="btn-icon-text" type="button" title="Скачать отчёт за период">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Экспорт
</button>
</div>
</div>
<!-- Алерт «Низкий баланс» (narrative §21.3, ДЕФ low_balance_threshold = 10) -->
<div class="alert-bar" role="alert">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>
<strong>Лидов осталось 8</strong> — это ниже порога 10.
Рекомендуем пополнить баланс, чтобы не пропустить входящие лиды.
</span>
<button class="alert-action" type="button">Пополнить</button>
</div>
<!-- ===== KPI-карточки (narrative §12.2) ===== -->
<section class="kpi-grid" aria-label="Ключевые показатели">
<!-- 1. Лимит активных проектов -->
<article class="kpi-card">
<div class="kpi-card-head">
<span class="kpi-card-label">Лимит проектов</span>
<span class="kpi-card-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
</span>
</div>
<div class="kpi-card-value">450</div>
<div class="kpi-card-trend is-flat">7 активных проектов</div>
</article>
<!-- 2. Получено за период -->
<article class="kpi-card">
<div class="kpi-card-head">
<span class="kpi-card-label">Получено лидов</span>
<span class="kpi-card-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>
</svg>
</span>
</div>
<div class="kpi-card-value">487</div>
<div class="kpi-card-trend is-up">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 14l5-5 5 5z"/>
</svg>
+18.4% к прошлой неделе
</div>
</article>
<!-- 3. Конверсия -->
<article class="kpi-card">
<div class="kpi-card-head">
<span class="kpi-card-label">Конверсия в оплачено</span>
<span class="kpi-card-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</span>
</div>
<div class="kpi-card-value">14.2%</div>
<div class="kpi-card-trend is-down">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M7 10l5 5 5-5z"/>
</svg>
−2.1 п.п. к прошлой неделе
</div>
</article>
<!-- 4. Звонков (Post-MVP, показываем заглушку) -->
<article class="kpi-card" style="opacity: 0.6;">
<div class="kpi-card-head">
<span class="kpi-card-label">Звонков</span>
<span class="kpi-card-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
</span>
</div>
<div class="kpi-card-value"></div>
<div class="kpi-card-trend is-flat">Доступно с интеграцией телефонии</div>
</article>
<!-- 5. Активных напоминаний -->
<article class="kpi-card">
<div class="kpi-card-head">
<span class="kpi-card-label">Активные напоминания</span>
<span class="kpi-card-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="13" r="8"/><polyline points="12 9 12 13 14.5 14.5"/>
<path d="M5 3 2 6"/><path d="m22 6-3-3"/>
</svg>
</span>
</div>
<div class="kpi-card-value">14</div>
<div class="kpi-card-trend is-flat">3 просрочены, 5 на сегодня</div>
</article>
</section>
<!-- ===== График активности + Donut статусов ===== -->
<section class="grid-2">
<!-- График активности -->
<article class="card">
<header class="card-head">
<h2 class="card-title">Получено лидов по дням</h2>
<span class="card-meta">Последние 7 дней</span>
</header>
<div id="chart-activity" role="img" aria-label="График количества лидов по дням за последние 7 дней"></div>
</article>
<!-- Donut по статусам -->
<article class="card">
<header class="card-head">
<h2 class="card-title">Распределение по статусам</h2>
<span class="card-meta">487 сделок</span>
</header>
<div id="chart-statuses" role="img" aria-label="Круговая диаграмма распределения сделок по статусам"></div>
</article>
</section>
<!-- ===== Последние сделки + Напоминания ===== -->
<section class="grid-2">
<!-- Последние сделки -->
<article class="card">
<header class="card-head">
<h2 class="card-title">Последние сделки</h2>
<a href="#" class="card-meta">Все сделки →</a>
</header>
<div class="deals-list" id="deals-list">
<!-- Заполняется JS-ом ниже -->
</div>
</article>
<!-- Напоминания -->
<article class="card">
<header class="card-head">
<h2 class="card-title">Ближайшие напоминания</h2>
<a href="#" class="card-meta">Все →</a>
</header>
<div class="reminders-list" id="reminders-list">
<!-- Заполняется JS-ом ниже -->
</div>
</article>
</section>
</main>
</div>
<!-- ===== Spec-аннотация ===== -->
<button class="spec-toggle" id="spec-toggle" aria-label="Открыть спецификацию экрана">i</button>
<div class="spec-overlay" id="spec-overlay" role="dialog" aria-labelledby="spec-title">
<div class="spec-card">
<h2 id="spec-title">Спецификация экрана №2 — Дашборд</h2>
<p style="margin: 0 0 16px; font-size: 13px; color: var(--gray-500);">
Прил. Л v0.1, 05.05.2026. Источник истины: <code>brandbook.md</code> v1.1, narrative §12, §8.1, §21.3.
</p>
<h3>Структура экрана</h3>
<ul>
<li><strong>Sidebar</strong> — фиксированный, 240&nbsp;px, навигация по 7 разделам клиентского приложения.</li>
<li><strong>Topbar</strong> — поиск + виджет баланса (рубли + лиды) + уведомления + меню пользователя.</li>
<li><strong>Алерт «Низкий баланс»</strong> — показывается при <code>balance_leads ≤ low_balance_threshold</code> (default 10, см. <code>system_settings.low_balance_threshold_leads</code>).</li>
<li><strong>5 KPI-карточек</strong> — лимит проектов, получено за период, конверсия, звонки (Post-MVP), напоминания.</li>
<li><strong>2 графика</strong> — линейный по дням, donut по статусам (ApexCharts через CDN).</li>
<li><strong>2 списка</strong> — последние 5 сделок и ближайшие 4 напоминания.</li>
</ul>
<h3>API контракты (для backend)</h3>
<ul>
<li><code>GET /api/v1/dashboard/kpi?period=7d</code> → 5 показателей с динамикой к предыдущему периоду.</li>
<li><code>GET /api/v1/dashboard/activity?period=7d&granularity=day</code> → ряд для графика.</li>
<li><code>GET /api/v1/dashboard/statuses?period=7d</code> → распределение по 14 статусам.</li>
<li><code>GET /api/v1/deals?limit=5&sort=-received_at</code> → последние сделки.</li>
<li><code>GET /api/v1/reminders?status=upcoming&limit=4</code> → ближайшие напоминания (Биз-10, отдельная таблица <code>reminders</code>).</li>
<li>Кеш на стороне сервера 5&nbsp;мин для KPI и графиков (narrative §12).</li>
</ul>
<h3>14 статусов воронки (narrative §8.1)</h3>
<ul>
<li>Цвета зафиксированы как CSS-переменные <code>--status-{slug}</code>. Названия в UI — из <code>tenant_status_overrides</code>; системные (6 шт) не переименовываются.</li>
<li>Бейдж — <code>.status-chip.s-{slug}</code> с пастельным фоном и тёмным текстом из той же палитры (контраст ≥ 4.5:1).</li>
</ul>
<h3>Безопасность и приватность</h3>
<ul>
<li>Телефоны в списке последних сделок — <strong>маскированы</strong>: <code>+7 (909) ***-67-89</code> (последние 2 цифры). Полный — только в карточке после клика.</li>
<li>RLS работает прозрачно: все запросы выполняются под <code>crm_app_user</code> с <code>SET LOCAL app.current_tenant_id</code>.</li>
<li>Поиск в шапке по умолчанию ищет в пределах тенанта.</li>
</ul>
<h3>Доступность</h3>
<ul>
<li>Sidebar помечен <code>aria-label="Главное меню"</code>, активная ссылка — <code>aria-current="page"</code> (TODO в боевом коде).</li>
<li>Графики имеют <code>role="img"</code> и <code>aria-label</code> с человекочитаемым описанием.</li>
<li>Всё работает с клавиатуры; visible focus.</li>
<li>Контраст брендовых сочетаний — см. brandbook §3.4.</li>
</ul>
<h3>Что не реализовано в прототипе</h3>
<ul>
<li>Реальный скелетон-loading (для прода — fade-in после fetch).</li>
<li>WebSocket / SSE для realtime-обновления баланса и счётчиков (Post-MVP).</li>
<li>Кастомный date range picker (заменён на <code>&lt;select&gt;</code>).</li>
<li>Тёмная тема (готова в <code>brandbook §3.3</code>, но Vuetify-тема <code>lidpotokDark</code> подключается на v2).</li>
<li>Push-уведомления через колокольчик (требуют VAPID, см. <code>narrative §17.4</code>).</li>
</ul>
<button type="button" class="btn-secondary" id="spec-close" style="margin-top: 16px; width: 100%;">Закрыть</button>
</div>
</div>
<script>
// =========================================================================
// Прил. Л — прототип №2: stub-данные и инициализация графиков
// =========================================================================
// ---- Список статусов с цветами (narrative §8.1) ----
const STATUSES = {
new: { name: 'Новые', color: '#3B82F6' },
viewed: { name: 'Просмотрено', color: '#8B5CF6' },
worked: { name: 'Проработан', color: '#06B6D4' },
base: { name: 'База', color: '#888780' },
missed: { name: 'Недозвон', color: '#F59E0B' },
negotiations: { name: 'Переговоры', color: '#EAB308' },
waiting_payment: { name: 'Ожидаем оплаты', color: '#C084FC' },
partnership: { name: 'Партнёрка', color: '#EC4899' },
paid: { name: 'Оплачено', color: '#22C55E' },
closed: { name: 'Закрыто', color: '#888780' },
test_drive: { name: 'Тест-драйв', color: '#14B8A6' },
hot: { name: 'Горячий', color: '#EF4444' },
replacement: { name: 'На замену', color: '#F97316' },
final_missed: { name: 'Конечный недозвон', color: '#444441' },
};
// ---- График активности (линейный, 7 дней) ----
const activityChart = new ApexCharts(document.getElementById('chart-activity'), {
chart: {
type: 'area',
height: 260,
toolbar: { show: false },
animations: { enabled: true, speed: 400 },
fontFamily: 'Inter, sans-serif',
},
series: [{
name: 'Лиды',
data: [42, 58, 73, 51, 89, 96, 78],
}],
xaxis: {
categories: ['Чт 30', 'Пт 1', 'Сб 2', 'Вс 3', 'Пн 4', 'Вт 5', 'Ср 6'],
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: '#888780', fontSize: '11px', fontWeight: 500 } },
},
yaxis: {
labels: { style: { colors: '#888780', fontSize: '11px' }, formatter: v => Math.round(v) },
},
colors: ['#0F6E56'],
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1, opacityFrom: 0.35, opacityTo: 0.05, stops: [0, 100],
},
},
stroke: { curve: 'smooth', width: 2.5 },
dataLabels: { enabled: false },
grid: { borderColor: '#F1EFE8', strokeDashArray: 4, padding: { left: 0, right: 0 } },
tooltip: {
theme: 'light',
style: { fontSize: '12px' },
y: { formatter: v => v + ' лидов' },
},
markers: { size: 0, hover: { size: 5 } },
});
// ---- Donut статусов ----
const statusData = [
{ slug: 'new', count: 124 },
{ slug: 'viewed', count: 96 },
{ slug: 'worked', count: 78 },
{ slug: 'negotiations', count: 64 },
{ slug: 'waiting_payment', count: 42 },
{ slug: 'paid', count: 69 },
{ slug: 'missed', count: 31 },
{ slug: 'hot', count: 12 },
{ slug: 'closed', count: 21 },
{ slug: 'final_missed', count: 18 },
];
const statusesChart = new ApexCharts(document.getElementById('chart-statuses'), {
chart: {
type: 'donut',
height: 280,
fontFamily: 'Inter, sans-serif',
animations: { enabled: true, speed: 400 },
},
series: statusData.map(s => s.count),
labels: statusData.map(s => STATUSES[s.slug].name),
colors: statusData.map(s => STATUSES[s.slug].color),
stroke: { width: 2, colors: ['#FFFFFF'] },
legend: {
position: 'bottom',
fontSize: '11px',
itemMargin: { horizontal: 6, vertical: 4 },
markers: { width: 8, height: 8, radius: 8 },
labels: { colors: '#444441' },
},
dataLabels: { enabled: false },
plotOptions: {
pie: {
donut: {
size: '70%',
labels: {
show: true,
name: { fontSize: '12px', color: '#888780', offsetY: 16 },
value: {
fontSize: '24px', fontWeight: 700, color: '#04342C', offsetY: -8,
formatter: v => v,
},
total: {
show: true, label: 'Всего сделок',
fontSize: '11px', color: '#888780',
formatter: w => w.globals.seriesTotals.reduce((a, b) => a + b, 0),
},
},
},
},
},
tooltip: {
y: { formatter: v => v + ' сделок' },
},
});
// ---- Запуск графиков после готовности DOM ----
document.addEventListener('DOMContentLoaded', () => {
activityChart.render();
statusesChart.render();
});
// Если DOM уже готов — рендерим сразу
if (document.readyState !== 'loading') {
activityChart.render();
statusesChart.render();
}
// ---- Список последних сделок ----
const deals = [
{ id: 12416101, name: 'Александр К.', phone: '+7 (909) ***-67-89', project: 'Caranga', tag: 'B3', status: 'hot', time: '5 мин назад' },
{ id: 12416099, name: 'Мария В.', phone: '+7 (915) ***-12-04', project: 'drivez', tag: 'B1', status: 'negotiations', time: '17 мин назад' },
{ id: 12416094, name: 'Без имени', phone: '+7 (985) ***-44-56', project: 'carmoney', tag: 'B2', status: 'new', time: '32 мин назад' },
{ id: 12416088, name: 'Олег Д.', phone: '+7 (903) ***-90-21', project: 'Vashinvest', tag: 'B3', status: 'paid', time: '54 мин назад' },
{ id: 12416077, name: 'Ирина М.', phone: '+7 (916) ***-08-15', project: 'Caranga', tag: 'B3', status: 'waiting_payment', time: '1 ч назад' },
];
const dealsList = document.getElementById('deals-list');
deals.forEach(d => {
const row = document.createElement('div');
row.className = 'deal-row';
row.innerHTML = `
<span class="deal-id">#${d.id}</span>
<div class="deal-info">
<div class="deal-name">${d.name}</div>
<div class="deal-meta">
<span>${d.phone}</span>
<span class="deal-meta-dot">·</span>
<span>${d.project}</span>
<span class="deal-meta-dot">·</span>
<span>${d.tag}</span>
</div>
</div>
<span class="status-chip s-${d.status}">${STATUSES[d.status].name}</span>
<span class="deal-time">${d.time}</span>
`;
dealsList.appendChild(row);
});
// ---- Виджет напоминаний ----
const reminders = [
{ time: 'Сейчас', text: 'Перезвонить Александру К.', meta: 'Сделка #12416101 · Caranga', overdue: true },
{ time: '14:30', text: 'Отправить КП Марии В.', meta: 'Сделка #12416099 · drivez', overdue: false },
{ time: '16:00', text: 'Уточнить статус оплаты', meta: 'Сделка #12416077 · Caranga', overdue: false },
{ time: 'Завтра', text: 'Согласовать тест-драйв', meta: 'Сделка #12415988 · drivez', overdue: false },
];
const remindersList = document.getElementById('reminders-list');
reminders.forEach(r => {
const item = document.createElement('div');
item.className = 'reminder' + (r.overdue ? ' is-overdue' : '');
item.innerHTML = `
<span class="reminder-time">${r.time}</span>
<div class="reminder-body">
<div class="reminder-text">${r.text}</div>
<div class="reminder-meta">${r.meta}</div>
</div>
`;
remindersList.appendChild(item);
});
// ---- Period select — заглушка ----
document.getElementById('period-select').addEventListener('change', (e) => {
if (e.target.value === 'custom') {
alert('Откроется кастомный date range picker (на боевом — Vuetify v-date-picker в режиме range).');
e.target.value = '7d';
}
});
// ---- Spec dialog ----
const specToggle = document.getElementById('spec-toggle');
const specOverlay = document.getElementById('spec-overlay');
const specClose = document.getElementById('spec-close');
specToggle.addEventListener('click', () => specOverlay.classList.add('is-open'));
specClose.addEventListener('click', () => specOverlay.classList.remove('is-open'));
specOverlay.addEventListener('click', (e) => {
if (e.target === specOverlay) specOverlay.classList.remove('is-open');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') specOverlay.classList.remove('is-open');
});
</script>
</body>
</html>