Библиотека
📊
Забери boilerplate-репо в Telegram Подпишись на канал и заполни короткую анкету — все 3 промпта + код откроются в нашем приложении
Открыть в Telegram →
Гайд · DIY · Бизнес-дашборд

Дашборд бизнеса за 3 промпта

Один экран — весь бизнес. Выручка / расходы / клиенты / задачи + алёрты. 3 промпта, ~3.5 часа. Готовый boilerplate-репо — клонируй и подставляй ключи.

Формат: 3 промпта + boilerplate Время: ~35 минут на чтение Стек: Claude Code · Next.js 15 · Recharts 3 · Postgres · Vercel Бюджет: Free (Vercel free tier) + ~$5/мес VPS

Семь вещей, которые ты получаешь из гайда

1

Промпт 1: сбор данных

~2 часа

Раньше, чтобы увидеть как работает бизнес, нужно было открыть четыре вкладки: Google Sheets с продажами, Stripe/Юкасса с платежами, TG-бот с клиентскими сообщениями, и ещё какой-нибудь Notion с задачами. Теперь один промпт — и всё это течёт в одну базу.

Два часа — потому что нужно выдать Claude ключи и подтвердить схему таблиц. Если схема с твоего проекта уже есть — быстрее. Claude через MCP сам пишет файлы, ставит зависимости и запускает тест.

Промпт целиком (копипастить в Claude Code)

Напиши Python-сервис на FastAPI. Задача — собирать данные из трёх источников каждый
час через APScheduler.

Проект: {PROJECT_NAME}
База данных: PostgreSQL, хост {DB_HOST}, порт 5432, база {DB_NAME}, пользователь {DB_USER}

=== ИСТОЧНИК 1: Google Sheets ===
Используй библиотеки google-auth + gspread.
Файл credentials: {GOOGLE_CREDENTIALS_PATH}
ID таблицы: {GOOGLE_SHEET_ID}
Лист: "Sales"
Столбцы: date (YYYY-MM-DD), amount (float), client_id (str), status (str).
Читай ВСЕ строки за последние 30 дней.
Пиши в таблицу revenue с upsert по (date, client_id, source='sheets').

=== ИСТОЧНИК 2: Stripe + Юкасса ===
Stripe:
  - API ключ из env STRIPE_SECRET_KEY
  - Эндпоинт GET /v1/charges?created[gte]={unix_yesterday}&limit=100
  - Суммируй только статус "succeeded", сумма в копейках → делить на 100
  - Записывай в revenue (date, amount, source='stripe', external_id=charge.id)

Юкасса:
  - Base URL: https://api.yookassa.ru/v3/
  - Аутентификация: HTTP Basic Auth (YUKASSA_SHOP_ID как username, YUKASSA_SECRET_KEY как password)
  - Эндпоинт GET /payments?created_at.gte={iso_yesterday}&limit=100
  - Суммируй статус "succeeded" и "waiting_for_capture"
  - Записывай в revenue (date, amount, source='yukassa', external_id=payment.id)

=== ИСТОЧНИК 3: Telethon (активность клиентов) ===
Используй Telethon с user-session (НЕ бот-токен, а api_id + api_hash + phone).
API ID: из env TELETHON_API_ID
API Hash: из env TELETHON_API_HASH
Phone: из env TELETHON_PHONE
Chat ID: {CHAT_ID}
Читай последние 200 сообщений за последние 24 часа.
Считай: unique_senders (int), total_messages (int).
Записывай в client_activity (date, unique_senders, total_messages).

=== ОБЩИЕ ТРЕБОВАНИЯ ===
- Стек: Python 3.11, FastAPI, asyncpg, APScheduler 3.x
- Upsert через INSERT ... ON CONFLICT DO UPDATE
- Все параметры из .env (используй python-dotenv)
- Логируй каждый запуск в таблицу sync_log (started_at, finished_at, source, rows_written, error)
- При ошибке одного источника — не падай, пиши error в sync_log и продолжай

Создай файлы: main.py, sources/sheets.py, sources/stripe.py, sources/yukassa.py,
sources/telethon_collector.py, models.py, requirements.txt

Конфиг из .env, пример в .env.example.

MCP-серверы для каждого источника

Claude Code использует MCP-серверы как прямые инструменты — он не «вызывает API», а читает файлы и пишет код. Но для отладки и тестирования тебе понадобятся сами MCP-серверы:

.env.example — все переменные сразу

# .env.example

# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=dashboard
DB_USER=postgres
DB_PASSWORD=your_password_here
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}

# Google Sheets
GOOGLE_CREDENTIALS_PATH=./credentials.json
GOOGLE_SHEET_ID=1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms

# Stripe
STRIPE_SECRET_KEY=sk_live_...

# Юкасса
YUKASSA_SHOP_ID=123456
YUKASSA_SECRET_KEY=live_...

# Telethon
TELETHON_API_ID=12345678
TELETHON_API_HASH=abcdef1234567890abcdef1234567890
TELETHON_PHONE=+79001234567
TELETHON_CHAT_ID=-1001234567890

# Telegram Bot (для алёртов)
TELEGRAM_BOT_TOKEN=1234567890:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TELEGRAM_CHAT_ID=123456789

# Plans (плановые показатели, можно хранить в .env или в таблице plans)
REVENUE_PLAN_DAILY=150000
EXPENSES_PLAN_MONTHLY=500000

schema.sql — фрагмент DDL базы

-- schema.sql

CREATE TABLE IF NOT EXISTS revenue (
    id          SERIAL PRIMARY KEY,
    date        DATE NOT NULL,
    amount      NUMERIC(12, 2) NOT NULL DEFAULT 0,
    client_id   VARCHAR(255),
    source      VARCHAR(50) NOT NULL,  -- 'sheets', 'stripe', 'yukassa'
    external_id VARCHAR(255),
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (date, source, external_id)
);

CREATE TABLE IF NOT EXISTS expenses (
    id          SERIAL PRIMARY KEY,
    date        DATE NOT NULL,
    amount      NUMERIC(12, 2) NOT NULL DEFAULT 0,
    category    VARCHAR(100),          -- 'marketing', 'infra', 'other'
    description TEXT,
    source      VARCHAR(50) DEFAULT 'manual',
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS client_activity (
    id              SERIAL PRIMARY KEY,
    date            DATE NOT NULL UNIQUE,
    unique_senders  INT DEFAULT 0,
    total_messages  INT DEFAULT 0,
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS plans (
    id              SERIAL PRIMARY KEY,
    month           DATE NOT NULL UNIQUE,  -- первый день месяца
    revenue_daily   NUMERIC(12, 2),        -- план выручки в день
    expenses_month  NUMERIC(12, 2),        -- лимит расходов за месяц
    notes           TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS sync_log (
    id          SERIAL PRIMARY KEY,
    started_at  TIMESTAMPTZ NOT NULL,
    finished_at TIMESTAMPTZ,
    source      VARCHAR(50),
    rows_written INT DEFAULT 0,
    status      VARCHAR(20) DEFAULT 'running',  -- 'ok', 'error', 'running'
    error       TEXT
);

-- Индексы для быстрых запросов дашборда
CREATE INDEX IF NOT EXISTS idx_revenue_date ON revenue(date DESC);
CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(date DESC);
CREATE INDEX IF NOT EXISTS idx_client_activity_date ON client_activity(date DESC);
Rate-limits и кэширование. Google Sheets API: лимит 300 запросов в минуту на проект. При обновлении каждый час — в лимит не упираешься никогда. Stripe: 100 запросов в секунду (free tier). Юкасса: 200 запросов в минуту. Telethon: flood wait при частых вызовах — оборачивай в try/except FloodWaitError as e: await asyncio.sleep(e.seconds). Кэшируй уже записанные external_id — не перечитывай то, что уже в базе. Для страховки: в sync_log пиши rows_written, если 0 несколько раз подряд — алёрт в TG.
2

Промпт 2: React-фронт

~1 час

Через час после старта у тебя на руках работающий URL на Vercel. Тёмная тема, четыре KPI-карточки с дельтами, два графика на Recharts 3 — без отдельного бэка для рендера, всё считается на FastAPI из первого промпта.

Recharts v3 вышел в июне 2025 — полная переработка state management, новые хуки useActiveTooltipLabel, accessibilityLayer включён по умолчанию. API обратно совместим с v2 в базовых компонентах, но если ты видел старые туториалы с activeIndex — этот пропс удалён.

Промпт целиком

Сгенерируй React-приложение на Next.js 15 (App Router) + TypeScript — бизнес-дашборд
с тёмной темой.

=== СТРУКТУРА UI ===
Шапка: название "{PROJECT_NAME} Dashboard" + дата последнего обновления (из /api/status).

Верхняя строка — 4 KPI-карточки (компонент KPICard):
1. «Выручка сегодня» — revenue за текущий день, delta % к вчера
2. «Выручка за месяц» — сумма revenue за текущий месяц, delta % к прошлому месяцу
3. «Расходы за месяц» — сумма expenses за текущий месяц, delta % к прошлому месяцу
4. «Активные клиенты» — unique_senders за сегодня, delta к вчера

Нижняя зона — два графика (Recharts 3):
- AreaChart выручки за последние 30 дней (данные из /api/charts/revenue)
- BarChart расходов по категориям за текущий месяц (данные из /api/charts/expenses)

=== ТЕХНИЧЕСКИЕ ТРЕБОВАНИЯ ===
Стек: Next.js 15, React 19, TypeScript 5, Recharts 3.x, Tailwind CSS v4

Тема тёмная:
- Фон: #0F172A
- Карточки: #1E293B
- Акцент: #8B5CF6 (фиолетовый)
- Текст основной: #F1F5F9
- Текст muted: #94A3B8
- Зелёный delta: #22C55E
- Красный delta: #EF4444

Auth: Vercel Edge Middleware с hash-auth.
Создай middleware.ts в корне проекта:
- Защити путь /dashboard/:path* и /api/:path*
- Читай токен из query-параметра ?token= или из cookie 'dash_token'
- Сравнивай с переменной AUTH_TOKEN из env (server-side, не NEXT_PUBLIC_)
- Если токен верный — устанавли cookie 'dash_token' на 7 дней и редиректь без ?token=
- Если токен неверный — возвращай 401 с JSON {"error": "Unauthorized"}

API эндпоинты (Next.js Route Handlers в app/api/):
- GET /api/kpi → {revenue_today, revenue_month, expenses_month, active_clients, deltas}
- GET /api/charts/revenue → [{date, amount}] за 30 дней
- GET /api/charts/expenses → [{category, amount}] текущий месяц
- GET /api/status → {last_sync_at, sync_status}

Подключение к PostgreSQL: через переменную DATABASE_URL, используй @vercel/postgres или
pg (node-postgres).

Деплой: vercel.json с настройками для Next.js App Router.
ENV: AUTH_TOKEN (secret), DATABASE_URL (postgres connection string).

=== ФАЙЛЫ К СОЗДАНИЮ ===
app/layout.tsx, app/page.tsx (редирект на /dashboard)
app/dashboard/page.tsx (основной дашборд)
app/api/kpi/route.ts
app/api/charts/revenue/route.ts
app/api/charts/expenses/route.ts
app/api/status/route.ts
middleware.ts
components/KPICard.tsx
components/RevenueChart.tsx
components/ExpensesChart.tsx
lib/db.ts
vercel.json

KPICard.tsx — карточка с дельтой и loading-скелетоном

// components/KPICard.tsx
import React from 'react'

interface KPICardProps {
  title: string
  value: string | number
  delta?: number          // процент изменения, например +12.5 или -3.2
  deltaLabel?: string     // 'к вчера' / 'к прошлому месяцу'
  prefix?: string         // '₽' / '$' / ''
  loading?: boolean
}

export function KPICard({
  title,
  value,
  delta,
  deltaLabel = 'к прошлому периоду',
  prefix = '',
  loading = false,
}: KPICardProps) {
  const deltaColor =
    delta === undefined
      ? 'text-slate-400'
      : delta >= 0
      ? 'text-emerald-400'
      : 'text-red-400'

  const deltaSign = delta !== undefined && delta > 0 ? '+' : ''

  if (loading) {
    return (
      <div className="rounded-xl bg-slate-800 p-5 animate-pulse">
        <div className="h-3 w-24 bg-slate-700 rounded mb-3" />
        <div className="h-8 w-32 bg-slate-700 rounded mb-2" />
        <div className="h-3 w-20 bg-slate-700 rounded" />
      </div>
    )
  }

  return (
    <div className="rounded-xl bg-slate-800 p-5 border border-slate-700/50 hover:border-violet-500/30 transition-colors">
      <p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">
        {title}
      </p>
      <p className="text-3xl font-bold text-slate-100 tabular-nums">
        {prefix}
        {typeof value === 'number' ? value.toLocaleString('ru-RU') : value}
      </p>
      {delta !== undefined && (
        <p className={`text-sm font-medium mt-1.5 ${deltaColor}`}>
          {deltaSign}{delta.toFixed(1)}% {deltaLabel}
        </p>
      )}
    </div>
  )
}

RevenueChart.tsx — AreaChart Recharts 3 с градиентом

// components/RevenueChart.tsx
'use client'
import {
  AreaChart, Area, XAxis, YAxis,
  CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'

interface RevenuePoint { date: string; amount: number }
interface RevenueChartProps { data: RevenuePoint[] }

// Recharts 3: accessibilityLayer=true по умолчанию
// YAxis width="auto" — новая фича v3, вычисляет ширину автоматически
// Tooltip content — используй TooltipContentProps (тип изменился в v3)

export function RevenueChart({ data }: RevenueChartProps) {
  return (
    <div className="rounded-xl bg-slate-800 p-5 border border-slate-700/50">
      <p className="text-sm font-semibold text-slate-300 mb-4">Выручка за 30 дней</p>
      <ResponsiveContainer width="100%" height={240}>
        <AreaChart data={data} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
          <defs>
            <linearGradient id="revenueGradient" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%" stopColor="#8B5CF6" stopOpacity={0.25} />
              <stop offset="95%" stopColor="#8B5CF6" stopOpacity={0} />
            </linearGradient>
          </defs>
          <CartesianGrid strokeDasharray="3 3" stroke="#334155" vertical={false} />
          <XAxis dataKey="date"
            tick={{ fill: '#64748B', fontSize: 11 }}
            axisLine={false} tickLine={false}
            interval="preserveStartEnd" />
          <YAxis width="auto"
            tick={{ fill: '#64748B', fontSize: 11 }}
            axisLine={false} tickLine={false}
            tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} />
          <Tooltip
            contentStyle={{
              background: '#1E293B', border: '1px solid #334155',
              borderRadius: 8, fontSize: 13,
            }}
            labelStyle={{ color: '#94A3B8' }}
            itemStyle={{ color: '#A78BFA' }}
            formatter={(value: number) => [
              `${value.toLocaleString('ru-RU')} ₽`, 'Выручка',
            ]} />
          <Area type="monotone" dataKey="amount"
            stroke="#8B5CF6" strokeWidth={2}
            fill="url(#revenueGradient)"
            dot={false}
            activeDot={{ r: 4, fill: '#8B5CF6' }} />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  )
}

Vercel Edge Middleware с hash-auth (целиком)

Hash-auth в 2026 — самый простой способ защитить личный дашборд без базы пользователей и OAuth. Логика: у тебя есть секретный токен в AUTH_TOKEN (например, openssl rand -hex 32). Ты открываешь URL как https://dashboard.vercel.app/dashboard?token=ваш_токен. Middleware читает токен, сравнивает, ставит cookie на 7 дней — и больше токен в URL не нужен.

Vercel Edge Middleware запускается на каждый запрос ещё до того как Next.js его обработает — идеально для auth:

// middleware.ts (корень проекта, рядом с app/)
import { NextRequest, NextResponse } from 'next/server'

const PROTECTED_PATHS = ['/dashboard', '/api/kpi', '/api/charts', '/api/status']

export function middleware(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl

  // Проверяем только защищённые пути
  const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p))
  if (!isProtected) return NextResponse.next()

  const AUTH_TOKEN = process.env.AUTH_TOKEN
  if (!AUTH_TOKEN) {
    // AUTH_TOKEN не задан — пропускаем (dev режим)
    return NextResponse.next()
  }

  // Читаем токен из query-параметра или из cookie
  const queryToken = searchParams.get('token')
  const cookieToken = request.cookies.get('dash_token')?.value

  const token = queryToken ?? cookieToken

  if (!token || token !== AUTH_TOKEN) {
    // Неверный токен — 401
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Токен верный
  const response = NextResponse.next()

  if (queryToken) {
    // Устанавливаем cookie и редиректим без ?token= в URL
    const cleanUrl = request.nextUrl.clone()
    cleanUrl.searchParams.delete('token')
    const redirect = NextResponse.redirect(cleanUrl)
    redirect.cookies.set('dash_token', AUTH_TOKEN, {
      httpOnly: true,     // недоступен из JS
      secure: true,       // только HTTPS
      sameSite: 'strict', // защита от CSRF
      maxAge: 60 * 60 * 24 * 7, // 7 дней
      path: '/',
    })
    return redirect
  }

  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
}
Security: три частые ошибки.
  1. Используешь NEXT_PUBLIC_AUTH_TOKEN — токен виден в браузерном bundle! Всегда AUTH_TOKEN без NEXT_PUBLIC_.
  2. Сравниваешь токены через === — уязвимо к timing attacks. Для продакшена используй crypto.timingSafeEqual(). Для личного дашборда — некритично.
  3. Ставишь cookie без httpOnly: true — токен доступен из JavaScript и может быть украден через XSS. Всегда httpOnly: true.
3

Промпт 3: алёрты + cron

~30 минут

Дашборд бесполезен, если в него нужно заходить руками. Третий промпт превращает его в активную систему: данные обновляются сами, а если бизнес проседает — Telegram пришлёт пуш раньше, чем ты заметишь.

Тридцать минут — потому что FastAPI-сервис уже есть из промпта 1. Claude добавляет два файла: alerts.py и обновляет scheduler.py. Антиспам через cooldown в Postgres — один и тот же алёрт не приходит чаще раза в 4 часа.

Промпт целиком

В существующий FastAPI-сервис (collector/) добавь два слоя:

=== СЛОЙ 1: АВТООБНОВЛЕНИЕ ЧЕРЕЗ CRON ===
В scheduler.py настрой APScheduler:
- job 'collect_all' — каждый час, в :00 минут
- job 'check_alerts' — каждый час, через 5 минут после collect_all (в :05)
- Хранить job state в Postgres (jobstore sqlalchemy)
- При рестарте сервиса — восстанавливать jobs

Добавь cron-файл для системного cron (на случай если APScheduler не запустится):
/etc/cron.d/dashboard-collector:
  0 * * * * {VENV_PATH}/python {APP_PATH}/collector/main.py --once --source=all
  5 * * * * {VENV_PATH}/python {APP_PATH}/collector/alerts.py --check

=== СЛОЙ 2: АЛЁРТЫ В TELEGRAM ===
Файл alerts.py, функция check_and_send_alerts():

1. Читай из Postgres:
   - revenue_today = SUM(amount) WHERE date = TODAY AND source IN ('stripe','yukassa','sheets')
   - expenses_month = SUM(amount) WHERE date >= DATE_TRUNC('month', NOW())
   - revenue_plan_today и expenses_plan_month из таблицы plans WHERE month = DATE_TRUNC('month',NOW())

2. Условия алёртов:
   АЛЁРТ_1 (revenue_low):
     если revenue_today < revenue_plan_today * 0.85:
       текст: "⚠️ Выручка {revenue_today:,.0f} ₽ — ниже плана на {gap:.0%}"

   АЛЁРТ_2 (expenses_high):
     если expenses_month > expenses_plan_month * 1.15:
       текст: "🔴 Расходы {expenses_month:,.0f} ₽ превысили план на {over:.0%}"

   АЛЁРТ_3 (sync_failed):
     если последний sync_log.status = 'error' уже 2+ часа подряд:
       текст: "🔧 Сбор данных не работает {hours}ч. Источник: {source}. Ошибка: {error_short}"

3. Антиспам: таблица alert_cooldown (alert_type, last_sent_at).
   Не отправляй алёрт если last_sent_at < NOW() - INTERVAL '4 hours'
   После отправки — UPDATE alert_cooldown SET last_sent_at = NOW()

4. Telegram sendMessage:
   URL: https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage
   Метод: POST, Content-Type: application/json
   Тело: {"chat_id": TELEGRAM_CHAT_ID, "text": "...", "parse_mode": "HTML"}

TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID из .env.

Cron-конфиг + Python-функция проверки

# /etc/cron.d/dashboard-collector
# Cron для dashboard-коллектора
# Редактировать: nano /etc/cron.d/dashboard-collector

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# Сбор данных каждый час в :00
0 * * * * root /opt/dashboard/.venv/bin/python /opt/dashboard/collector/main.py --once >> /var/log/dashboard/collect.log 2>&1

# Проверка алёртов в :05 (после сбора)
5 * * * * root /opt/dashboard/.venv/bin/python /opt/dashboard/collector/alerts.py --check >> /var/log/dashboard/alerts.log 2>&1

# Создать директорию логов: mkdir -p /var/log/dashboard
# alerts.py — Python-функция проверки + отправки

import asyncio
import httpx
import asyncpg
import os
from datetime import datetime, timedelta

TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
COOLDOWN_HOURS = 4

async def send_telegram(text: str) -> bool:
    """Отправить сообщение в Telegram Bot API."""
    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    payload = {
        "chat_id": TELEGRAM_CHAT_ID,
        "text": text,
        "parse_mode": "HTML",
        "disable_notification": False,
    }
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.post(url, json=payload)
        return resp.status_code == 200

async def is_in_cooldown(conn: asyncpg.Connection, alert_type: str) -> bool:
    """Проверить cooldown в БД."""
    row = await conn.fetchrow(
        "SELECT last_sent_at FROM alert_cooldown WHERE alert_type = $1",
        alert_type
    )
    if not row:
        return False
    return row["last_sent_at"] > datetime.utcnow() - timedelta(hours=COOLDOWN_HOURS)

async def set_cooldown(conn: asyncpg.Connection, alert_type: str):
    """Обновить timestamp отправки."""
    await conn.execute("""
        INSERT INTO alert_cooldown (alert_type, last_sent_at)
        VALUES ($1, NOW())
        ON CONFLICT (alert_type) DO UPDATE SET last_sent_at = NOW()
    """, alert_type)

async def check_and_send_alerts():
    conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
    try:
        # Читаем фактические данные
        revenue_today = await conn.fetchval(
            "SELECT COALESCE(SUM(amount), 0) FROM revenue WHERE date = CURRENT_DATE"
        )
        expenses_month = await conn.fetchval("""
            SELECT COALESCE(SUM(amount), 0) FROM expenses
            WHERE date >= DATE_TRUNC('month', NOW())
        """)
        # Читаем план
        plan = await conn.fetchrow("""
            SELECT revenue_daily, expenses_month FROM plans
            WHERE month = DATE_TRUNC('month', NOW())
        """)
        if not plan:
            return  # план не задан — не проверяем

        # АЛЁРТ 1: выручка ниже 85% плана
        if revenue_today < plan["revenue_daily"] * 0.85:
            if not await is_in_cooldown(conn, "revenue_low"):
                gap = (plan["revenue_daily"] - revenue_today) / plan["revenue_daily"]
                hours_left = 24 - datetime.utcnow().hour
                text = (
                    f"⚠️ <b>Выручка {revenue_today:,.0f} ₽</b> — ниже плана на {gap:.0%}\n"
                    f"План: {plan['revenue_daily']:,.0f} ₽\n"
                    f"Остаток дня: {hours_left}ч"
                )
                if await send_telegram(text):
                    await set_cooldown(conn, "revenue_low")

        # АЛЁРТ 2: расходы выше 115% плана
        if expenses_month > plan["expenses_month"] * 1.15:
            if not await is_in_cooldown(conn, "expenses_high"):
                over = (expenses_month - plan["expenses_month"]) / plan["expenses_month"]
                overrun = expenses_month - plan["expenses_month"]
                text = (
                    f"🔴 <b>Расходы {expenses_month:,.0f} ₽</b> превысили план на {over:.0%}\n"
                    f"Лимит: {plan['expenses_month']:,.0f} ₽\n"
                    f"Перерасход: {overrun:,.0f} ₽"
                )
                if await send_telegram(text):
                    await set_cooldown(conn, "expenses_high")
    finally:
        await conn.close()

if __name__ == "__main__":
    asyncio.run(check_and_send_alerts())

Telegram sendMessage — актуальный JSON-payload (2025–2026)

// POST https://api.telegram.org/bot{TOKEN}/sendMessage

{
  "chat_id": "123456789",          // ID чата (личка, группа, канал)
  "text": "⚠️ Выручка 87 400 ₽ — ниже плана на 15%\nПлан: 103 000 ₽",
  "parse_mode": "HTML",            // "HTML" или "MarkdownV2" (не просто "Markdown"!)
  "disable_notification": false,   // true = беззвучно (для логов ночью)
  "protect_content": false,        // true = нельзя форварднуть
  "reply_markup": {                // опционально: кнопки
    "inline_keyboard": [[
      {
        "text": "Открыть дашборд",
        "url": "https://dashboard.vercel.app/dashboard?token=ВАШ_ТОКЕН"
      }
    ]]
  }
}

// Ответ при успехе:
{
  "ok": true,
  "result": {
    "message_id": 42,
    "chat": { "id": 123456789, "type": "private" },
    "date": 1716297600,
    "text": "..."
  }
}

// Типичные ошибки:
// 400 Bad Request: chat not found — бот не написал пользователь первым
// 403 Forbidden — бот заблокирован
// 429 Too Many Requests — flood control, ждать retry_after секунд
Что если бот сломан? Три сценария fallback.
  1. Бот заблокирован / chat not found: добавь второй канал уведомлений. Достаточно email через smtplib или SMS через любой SMS-шлюз. Код: if not await send_telegram(text): send_email_fallback(text).
  2. Сервер недоступен: системный cron (см. выше) — страховка от падения APScheduler. Мониторинг самого сервера: UptimeRobot бесплатно пингует URL раз в 5 минут.
  3. База недоступна: алёрты не отправятся (нет данных). Добавь отдельный heartbeat: раз в 6 часов отправляй ✅ Dashboard работает. Последний sync: {last_sync_at}. Если heartbeat пропал — ты знаешь что что-то сломалось.
4

Структура репо + deploy

Стек целиком — что куда деплоится

Boilerplate-репо — это collector/ (Python, FastAPI, APScheduler) + ui/ (Next.js, Vercel) + infra/ (cron, nginx). Один schema.sql наверху и один .env.example — все ключи в одном месте.

Деплой разделён: коллектор едет на VPS или Railway (нужен постоянно работающий процесс), фронт — на Vercel (статика + Edge Middleware). База — Postgres на том же VPS или Vercel Postgres / Neon.

Структура репозитория (дерево)

dashboard-boilerplate/
├── .env.example                    # все переменные (см. section 1)
├── .gitignore                      # node_modules, .env, .venv, *.pyc, .next
├── README.md                       # 10 шагов от клона до первого алёрта
│
├── schema.sql                      # DDL: revenue, expenses, client_activity,
│                                   #      plans, sync_log, alert_cooldown
│
├── collector/                      # Python FastAPI сервис (сбор данных)
│   ├── main.py                     # FastAPI app + lifespan hook
│   ├── scheduler.py                # APScheduler: collect + alerts jobs
│   ├── alerts.py                   # логика алёртов + cooldown + TG
│   ├── models.py                   # Pydantic-модели для API ответов
│   ├── db.py                       # asyncpg pool + helpers
│   ├── requirements.txt            # python deps (зафиксированные версии)
│   ├── Dockerfile                  # для деплоя на VPS/Railway
│   └── sources/
│       ├── __init__.py
│       ├── sheets.py               # Google Sheets → Postgres
│       ├── stripe.py               # Stripe API → Postgres
│       ├── yukassa.py              # Юкасса API → Postgres
│       └── telethon_collector.py   # Telethon активность → Postgres
│
├── ui/                             # Next.js 15 фронтенд
│   ├── app/
│   │   ├── layout.tsx
│   │   ├── page.tsx                # редирект на /dashboard
│   │   ├── dashboard/
│   │   │   └── page.tsx            # основной дашборд
│   │   └── api/
│   │       ├── kpi/route.ts
│   │       ├── charts/
│   │       │   ├── revenue/route.ts
│   │       │   └── expenses/route.ts
│   │       └── status/route.ts
│   ├── components/
│   │   ├── KPICard.tsx             # карточка с дельтой (см. выше)
│   │   ├── RevenueChart.tsx        # AreaChart Recharts
│   │   └── ExpensesChart.tsx       # BarChart Recharts
│   ├── lib/
│   │   └── db.ts                   # pg клиент для Vercel Postgres
│   ├── middleware.ts               # Edge Middleware hash-auth
│   ├── vercel.json                 # конфиг деплоя Next.js App Router
│   ├── package.json
│   └── tsconfig.json
│
└── infra/
    ├── cron/
    │   └── dashboard-collector     # /etc/cron.d/ конфиг (section 3)
    └── nginx/
        └── dashboard.conf          # опционально: reverse proxy для collector

Deploy-команды от клона до production

# === ШАГ 1: клон и настройка ===
git clone https://github.com/YOUR_USER/dashboard-boilerplate.git
cd dashboard-boilerplate
cp .env.example .env
# Открой .env и заполни все ключи

# === ШАГ 2: база данных ===
# Создать БД (если нет)
createdb dashboard
# Применить схему
psql -d dashboard -f schema.sql
# Заполнить план на текущий месяц
psql -d dashboard -c "
  INSERT INTO plans (month, revenue_daily, expenses_month)
  VALUES (DATE_TRUNC('month', NOW()), 100000, 500000);
"

# === ШАГ 3: Python коллектор ===
cd collector
python -m venv .venv
source .venv/bin/activate       # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Первый запуск (тест одного сбора)
python main.py --once --source=all
# Запустить сервис
python main.py
# Проверить логи
psql -d dashboard -c "SELECT * FROM sync_log ORDER BY started_at DESC LIMIT 5;"

# === ШАГ 4: деплой фронта на Vercel ===
cd ../ui
npm install
# Локальный тест
npm run dev
# Открой http://localhost:3000/dashboard?token=ВАШ_AUTH_TOKEN

# Деплой на Vercel (первый раз)
npx vercel
# Следуй инструкциям: связать с проектом, выбрать директорию ui/

# Задать env переменные на Vercel
npx vercel env add AUTH_TOKEN production
npx vercel env add DATABASE_URL production

# Деплой в prod
npx vercel --prod

# === ШАГ 5: настроить cron на сервере ===
sudo cp infra/cron/dashboard-collector /etc/cron.d/
sudo chmod 644 /etc/cron.d/dashboard-collector
# Редактировать пути в файле под твой сервер
sudo systemctl restart cron

Env-переменные: что где задать

README — быстрый старт за 10 шагов

# Dashboard Boilerplate

Один экран — весь бизнес. Выручка / расходы / клиенты + алёрты.
Собрать с нуля: ~3.5 часа. Поддерживать: ~0 минут в неделю.

## Быстрый старт (10 шагов)

1. `git clone` + `cp .env.example .env`
2. Заполни все ключи в `.env`
3. `createdb dashboard && psql -d dashboard -f schema.sql`
4. Добавь план на месяц в таблицу `plans`
5. `cd collector && pip install -r requirements.txt`
6. `python main.py --once` — проверь что данные пишутся
7. `python main.py` — запусти сервис с APScheduler
8. `cd ../ui && npm install && npx vercel --prod`
9. Задай AUTH_TOKEN и DATABASE_URL в Vercel Dashboard
10. Открой `https://ВАШ-ДЕПЛОЙ.vercel.app/dashboard?token=ВАШ_ТОКЕН`

## Стек

| Компонент           | Технология          | Версия       |
|---------------------|---------------------|--------------|
| Фронтенд            | Next.js + React     | 15 / 19      |
| Графики             | Recharts            | 3.x          |
| Стили               | Tailwind CSS        | v4           |
| Бэкенд (коллектор)  | FastAPI + asyncpg   | latest       |
| База данных         | PostgreSQL          | 15+          |
| Деплой фронта       | Vercel              | free tier    |
| Деплой коллектора   | VPS / Railway       | ~$5-8/мес    |
| Scheduler           | APScheduler         | 3.x          |

## Альтернативы Vercel

- Cloudflare Pages — бесплатный tier без ограничений на bandwidth.
  Next.js через @cloudflare/next-on-pages.
- Railway — удобен если хочешь держать и коллектор и фронт в одном месте.
  Usage-based, типичный счёт $8-15/мес.
- Render — free tier есть, но засыпает через 15 мин неактивности.
Почему именно Vercel + Railway. Vercel идеален для статики и Edge-функций (middleware ходит на каждый запрос — нужен минимальный latency). Railway/VPS лучше для коллектора (нужен постоянно живой процесс с APScheduler). Render с его засыпанием через 15 минут — не подходит: алёрты потеряются.
5

12–15 KPI по типам бизнеса

Какие метрики смотреть и что игнорировать

Дашборд — это не просто «показать цифры». Важно выбрать правильные 4–6 KPI для твоего типа бизнеса. Вот шпаргалка: что смотреть, почему именно это, и что игнорировать.

В таблице plans есть поле notes (TEXT). Сохраняй там JSON с любыми кастомными планами. В API добавляй новые эндпоинты под конкретные метрики — Claude справится с промптом «добавь KPI completion_rate, данные из таблицы course_completions».

Агентство (digital / маркетинг / консалтинг)

Что игнорировать: число лидов (без конверсии бессмысленно), GMV (не агентский KPI).

SaaS / Подписка

Что игнорировать: DAU/MAU (важно для потребительских, не для B2B SaaS).

E-commerce / Интернет-магазин

Что игнорировать: просмотры страниц (это метрика трафика, не бизнеса).

Коучинг / Инфопродукты / Образование

Что игнорировать: охваты и лайки (до тех пор пока не видишь корреляцию с продажами).

Правило 4–6. На одном экране должно быть не больше 6 KPI. Больше — глаз начинает скользить, ты перестаёшь замечать движение цифр. Если кажется что нужно 10 — раздели на два дашборда: «деньги» и «операции».
6

7 граблей с боевого деплоя

Симптом → причина → фикс → prevention

Каждый из этих косяков я или видел лично, или нашёл в коде, который генерировал Claude. Все — исправляемые за 10–15 минут если знаешь симптом.

  1. Rate-limit API источников.

    Симптом: collect_all падает с 429 Too Many Requests через несколько часов работы.

    Причина: при каждом запуске читаешь последние 24 часа — но за месяц накапливается тысячи строк.

    Фикс: cursor-based pagination — запоминай last_fetched_id в sync_log и запрашивай только created > last_fetched_id. Для Stripe: charges.list(starting_after=last_id). Для Юкассы: payments?created_at.gte=last_sync_time.

    Prevention: в requirements.txt зафиксируй версии библиотек с retry-logic (stripe>=7.0.0 уже имеет auto-retry).

  2. Таймзоны — главный источник ошибок в данных.

    Симптом: выручка за «сегодня» показывает вчерашние данные или меньше реального.

    Причина: Stripe отдаёт UTC timestamps, Юкасса — UTC, а ты сравниваешь с CURRENT_DATE в Postgres который тоже UTC. Но ты в Москве (UTC+3) — и «сегодня» начинается в 03:00 UTC.

    Фикс: в schema.sql задай timezone для сессии: SET timezone = 'Europe/Moscow'. В Python: всегда datetime.now(tz=timezone.utc) и храни всё в UTC. В SQL: WHERE date = (NOW() AT TIME ZONE 'Europe/Moscow')::date.

    Prevention: добавь tzdata в requirements.txt, используй pendulum вместо datetime для timezone-aware кода.

  3. NaN и деление на ноль в дельтах.

    Симптом: KPI-карточка показывает NaN% или крашится в браузере.

    Причина: прошлый период = 0 (новый бизнес, нет исторических данных). delta = (today - yesterday) / yesterday → деление на 0.

    Фикс в TypeScript:

    const calcDelta = (current: number, previous: number): number | undefined => {
      if (!previous || previous === 0) return undefined
      return ((current - previous) / previous) * 100
    }

    Prevention: в KPICard если delta === undefined — не показывай дельту вообще, не «—%» и не «0%».

  4. Пропуски данных — дыры в графике.

    Симптом: AreaChart обрывается или показывает зигзаги вместо плавной линии.

    Причина: в некоторые дни данных нет (выходные, нет продаж). Recharts рисует линию только между существующими точками.

    Фикс: при формировании данных для графика — генерируй полный массив дат и заполняй нулями:

    # В FastAPI эндпоинте /api/charts/revenue
    all_dates = [start_date + timedelta(days=i) for i in range(30)]
    revenue_map = {row['date']: row['amount'] for row in db_rows}
    return [{'date': d.strftime('%d.%m'), 'amount': revenue_map.get(d, 0)} for d in all_dates]

    Prevention: unit-тест на эндпоинт /api/charts/revenue — длина массива всегда ровно 30.

  5. Дублирование данных при повторном запуске.

    Симптом: выручка удваивается после рестарта сервиса.

    Причина: INSERT без upsert, или upsert по неправильному ключу.

    Фикс: в schema.sql есть UNIQUE (date, source, external_id). Убедись что external_id всегда заполнен (для Stripe — charge.id, для Юкассы — payment.id). Для Google Sheets где нет external_id — используй (date, client_id, source) как уникальный ключ.

    Prevention: при разработке прогоняй collector дважды подряд и смотри что SELECT COUNT(*) не изменился.

  6. Hash-auth в URL — токен в логах сервера.

    Симптом: токен из ?token= виден в access.log Nginx/Vercel и в истории браузера.

    Причина: это особенность hash-auth — первый запрос содержит токен в URL.

    Фикс: middleware из section 2 уже делает редирект: получил токен в query → поставил cookie → редирект на чистый URL. Так токен попадает в логи только один раз.

    Prevention: в Vercel включи Log Drains filtering или храни логи только на внутреннем хранилище. Ротируй AUTH_TOKEN раз в 3 месяца.

  7. Алёрт-спам при старте сервиса.

    Симптом: получаешь 10–20 одинаковых алёртов при деплое нового сервера.

    Причина: APScheduler запускает все пропущенные jobs «в догонку» (misfire_grace_time).

    Фикс: задай misfire_grace_time=None и coalesce=True для jobs:

    scheduler.add_job(
        check_and_send_alerts,
        'interval', hours=1,
        coalesce=True,          # пропущенные задачи объединяются в одну
        misfire_grace_time=None # пропущенное время — не наверстывать
    )

    Prevention: отдельный cooldown startup_grace на 5 минут после старта — никаких алёртов в этом окне.

Регулярная проверка. Раз в неделю смотри sync_log: если rows_written = 0 подряд несколько часов — источник сломан или ключ протух. Добавь это в weekly cron-задачу — пусть пишет в TG саммари «за неделю собрано N строк, M ошибок».
7

Multi-business — если бизнесов несколько

Изоляция данных + переключение между бизнесами

Один дашборд — один бизнес. Но что если у тебя их три? Фитнес-студия, SaaS и консалтинг. Вот три подхода от простого к правильному — выбирай по тому, кому показываешь данные.

Подход 1 — Одна БД с tenant_id (просто, для старта)

Добавляешь поле tenant_id во все таблицы. Каждый источник данных при записи указывает свой tenant. В API-запросах всегда фильтруешь по tenant_id. На фронте — выпадающий список бизнесов.

Подход 2 — N отдельных баз данных (изоляция, для клиентов)

Каждый бизнес = своя Postgres база. Один коллектор поддерживает пул соединений к нескольким базам. Конфиг в YAML:

tenants:
  - id: fitness
    db_url: postgresql://user:pass@host:5432/fitness_db
    sources: [sheets, yukassa]
  - id: saas
    db_url: postgresql://user:pass@host:5432/saas_db
    sources: [stripe]
  - id: consulting
    db_url: postgresql://user:pass@host:5432/consulting_db
    sources: [sheets, telethon]

Подход 3 — N отдельных Vercel проектов (просто, но дороже)

Клонируй репо для каждого бизнеса, задеплой отдельно на Vercel. Разные AUTH_TOKEN, разные DATABASE_URL, разные URL.

Промпт для Multi-tenant setup (Подход 1)

В существующем dashboard-boilerplate добавь multi-tenant поддержку (Подход 1: одна БД).

1. Добавь поле tenant_id VARCHAR(50) NOT NULL DEFAULT 'default' в таблицы:
   revenue, expenses, client_activity, plans, sync_log, alert_cooldown

2. Создай таблицу tenants:
   CREATE TABLE tenants (
     id VARCHAR(50) PRIMARY KEY,
     name VARCHAR(255) NOT NULL,
     auth_token VARCHAR(255) NOT NULL,  -- уникальный токен для каждого бизнеса
     config JSONB DEFAULT '{}',         -- источники данных и их настройки
     created_at TIMESTAMPTZ DEFAULT NOW()
   );

3. Обнови collector/main.py:
   - Читай список тенантов из таблицы tenants при старте
   - Для каждого тенанта запускай collect_all(tenant_id=...) с его конфигом
   - В каждый INSERT добавляй tenant_id

4. Обнови middleware.ts в Next.js:
   - Читай список тенантов из API /api/tenants
   - Сравнивай token из ?token= с auth_token каждого тенанта
   - Ставь cookie: {dash_token: token, tenant_id: matched_tenant.id}
   - В API-маршрутах читай tenant_id из cookie и добавляй WHERE tenant_id = ?

5. Добавь UI для переключения:
   - Компонент TenantSwitcher — выпадающий список бизнесов
   - При смене — обновляй cookie tenant_id и рефрешь данные

Текущие данные (tenant_id = 'default') должны остаться доступны.
Тонкое место Подхода 1. Если забыл WHERE tenant_id = $1 в одном запросе — пользователь увидит чужие KPI. Защита: Row-Level Security в Postgres. Включи RLS на всех таблицах + политика USING (tenant_id = current_setting('app.current_tenant')). Тогда даже забытый WHERE не приведёт к утечке.

Что внутри boilerplate

Итог: ~3.5 часа работы вместо $5 000 подрядчику

Экономика простая: 3.5 часа работы вместо $5 000 и 3 недели ожидания подрядчика. По деньгам разрыв больше чем в 100×, по времени — в 12×. И ты держишь всё у себя — никакой завязки на чужого разработчика, никаких «он уехал в отпуск, дашборд не показывает выручку».

Реальный TCO за год: VPS для коллектора ~$60/год ($5/мес), Vercel free tier $0, Postgres на том же VPS $0. Итого ~$60/год против $5 000 единоразово + поддержка $500–1 000/год. Твоя экономия — от $5 440 в первый год.

Сравнение с no-code (Notion + Zapier + Retool): Retool от $10/мес за пользователя, Zapier при 1 000 tasks/мес — $20/мес, Notion уже есть. Итого no-code стек: ~$30–50/мес = $360–600/год. Твой стек: ~$5/мес = $60/год. И у no-code нет Telethon-интеграции, нет кастомной логики алёртов, нет контроля над кодом.

Vercel free tier — почему хватит. Лимит — 100k requests/мес. Личный дашборд с обновлением раз в час = ~720 API-запросов в месяц к FastAPI + ~100–200 запросов браузера. Лимит не пробьёшь даже если смотреть дашборд по 10 раз в день.

Забери boilerplate-репо + 3 промпта + чек-лист deploy

В Telegram-боте: готовое репо с полной структурой, три промпта точно как в статье (копипастить без изменений), .env.example с 13 переменными, schema.sql, чек-лист из 10 шагов и таблица KPI для 4 типов бизнеса в Notion.

Открыть в Telegram →
КОДОВОЕ СЛОВО · ДАШБОРД