Дашборд бизнеса за 3 промпта
Один экран — весь бизнес. Выручка / расходы / клиенты / задачи + алёрты. 3 промпта, ~3.5 часа. Готовый boilerplate-репо — клонируй и подставляй ключи.
Семь вещей, которые ты получаешь из гайда
- Промпт 1 (ПОЛНЫЙ, копипастить сразу) — сбор данных Google Sheets + Stripe/Юкасса + Telethon → Postgres каждый час
- Промпт 2 (ПОЛНЫЙ) — React-дашборд тёмная тема, 4 карточки KPI, 2 графика Recharts 3, Vercel hash-auth
- Промпт 3 (ПОЛНЫЙ) — алёрты в Telegram с антиспамом, cron-конфиг и TG-payload
- MCP-конфиги — Google Sheets / Stripe / Юкасса / Telethon: откуда взять каждый сервер, что в .env
- Схема Postgres (schema.sql) и README-фрагмент boilerplate-репо
- 12–15 KPI для разных бизнесов — агентство / SaaS / e-commerce / коучинг
- Multi-tenant вариант — как подключить несколько бизнесов без смешивания данных
Промпт 1: сбор данных
Раньше, чтобы увидеть как работает бизнес, нужно было открыть четыре вкладки: 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-серверы:
- Google Sheets MCP. Источник:
@isaacflath/mcp-google-sheets. Установка:npm install -g @isaacflath/mcp-google-sheets. Требует:GOOGLE_CREDENTIALS(JSON-строка из service account),GOOGLE_SHEET_ID. Документация: developers.google.com/sheets/api. - Stripe MCP. Источник:
stripe/agent-toolkit(официальный от Stripe). Установка:npm install -g @stripe/agent-toolkit. Требует:STRIPE_SECRET_KEYиз env. Документация: github.com/stripe/agent-toolkit. - Postgres MCP. Источник:
@modelcontextprotocol/server-postgres(официальный). Установка:npm install -g @modelcontextprotocol/server-postgres. Требует:DATABASE_URL=postgresql://user:pass@host:5432/dbname. - Telethon / Telegram Reader. Нет официального MCP-сервера. Используй напрямую — Claude пишет Python-код с Telethon. Требует:
TELETHON_API_ID,TELETHON_API_HASH,TELETHON_PHONE. Первый запуск требует ручного ввода кода (2FA в терминале). - Юкасса. MCP-сервера нет. Claude пишет httpx-клиент напрямую с Basic Auth.
.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);
try/except FloodWaitError as e: await asyncio.sleep(e.seconds). Кэшируй уже записанные external_id — не перечитывай то, что уже в базе. Для страховки: в sync_log пиши rows_written, если 0 несколько раз подряд — алёрт в TG.Промпт 2: React-фронт
Через час после старта у тебя на руках работающий 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*'],
}
- Используешь
NEXT_PUBLIC_AUTH_TOKEN— токен виден в браузерном bundle! ВсегдаAUTH_TOKENбезNEXT_PUBLIC_. - Сравниваешь токены через
===— уязвимо к timing attacks. Для продакшена используйcrypto.timingSafeEqual(). Для личного дашборда — некритично. - Ставишь cookie без
httpOnly: true— токен доступен из JavaScript и может быть украден через XSS. ВсегдаhttpOnly: true.
Промпт 3: алёрты + cron
Дашборд бесполезен, если в него нужно заходить руками. Третий промпт превращает его в активную систему: данные обновляются сами, а если бизнес проседает — 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 секунд
- Бот заблокирован / chat not found: добавь второй канал уведомлений. Достаточно email через
smtplibили SMS через любой SMS-шлюз. Код:if not await send_telegram(text): send_email_fallback(text). - Сервер недоступен: системный cron (см. выше) — страховка от падения APScheduler. Мониторинг самого сервера: UptimeRobot бесплатно пингует URL раз в 5 минут.
- База недоступна: алёрты не отправятся (нет данных). Добавь отдельный heartbeat: раз в 6 часов отправляй
✅ Dashboard работает. Последний sync: {last_sync_at}. Если heartbeat пропал — ты знаешь что что-то сломалось.
Структура репо + 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-переменные: что где задать
DATABASE_URL— в.envлокально +vercel env addна Vercel. Connection string PostgreSQL.AUTH_TOKEN— только Vercel env, не в .env коллектора. Токен для hash-auth дашборда.STRIPE_SECRET_KEY—.envколлектора. Формат:sk_live_....YUKASSA_SHOP_ID/YUKASSA_SECRET_KEY—.envколлектора. ID магазина и секретный ключ из ЛК Юкассы.GOOGLE_CREDENTIALS_PATH/GOOGLE_SHEET_ID—.envколлектора. Путь доcredentials.jsonservice account.TELETHON_API_ID/TELETHON_API_HASH—.envколлектора. С my.telegram.org.TELETHON_PHONE/TELETHON_CHAT_ID— твой номер + ID группы/канала клиентов.TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID—.envколлектора. От @BotFather + куда слать алёрты.
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 мин неактивности.
12–15 KPI по типам бизнеса
Дашборд — это не просто «показать цифры». Важно выбрать правильные 4–6 KPI для твоего типа бизнеса. Вот шпаргалка: что смотреть, почему именно это, и что игнорировать.
В таблице plans есть поле notes (TEXT). Сохраняй там JSON с любыми кастомными планами. В API добавляй новые эндпоинты под конкретные метрики — Claude справится с промптом «добавь KPI completion_rate, данные из таблицы course_completions».
Агентство (digital / маркетинг / консалтинг)
- Выручка за месяц —
SUM(revenue) MTD. Базовая метрика, отвечает на «вообще делаем ли деньги». - Revenue per employee — Выручка / кол-во сотрудников. Показывает эффективность команды; если падает — растёшь в людях быстрее чем в деньгах.
- Utilization rate — Оплачиваемые часы / доступные часы. Ключ к марже агентства: ниже 60% — убытки, выше 80% — выгораешь.
- Average project value — Выручка / кол-во проектов. Если растёт — ты продаёшь дороже, а не работаешь больше.
- Клиентский NRR — Выручка от старых клиентов / выручка прошлого месяца. Уходят ли клиенты?
Что игнорировать: число лидов (без конверсии бессмысленно), GMV (не агентский KPI).
SaaS / Подписка
- MRR (Monthly Recurring Revenue) —
SUM(active_subscriptions × monthly_price). Единственная метрика роста SaaS. - MRR Churn — Потерянный MRR / MRR прошлого месяца. Если >2% в месяц — есть проблема с продуктом или ICP.
- New MRR + Expansion MRR — Темп роста за счёт новых клиентов и апгрейдов отдельно. Хорошее соотношение — 60/40.
- CAC (Customer Acquisition Cost) — Расходы на маркетинг / новые клиенты. Окупаемость привлечения.
- LTV / CAC ratio — Должно быть >3. Меньше — бизнес убыточен на единицу клиента.
Что игнорировать: DAU/MAU (важно для потребительских, не для B2B SaaS).
E-commerce / Интернет-магазин
- GMV (Gross Merchandise Value) —
SUM(заказы)за период. Оборот, верхнеуровневая метрика. - Выручка (нетто) —
GMV - возвраты - скидки. Реальные деньги, которые остаются. - Conversion rate — Заказы / посетители. Насколько хорошо конвертируешь трафик.
- Average Order Value (AOV) —
GMV / кол-во заказов. Растёт — ты допродаёшь. - Возвраты % — Возвраты / заказы. >5% — проблема с качеством или описанием.
- COGS (Cost of Goods Sold) — Закупочная стоимость проданного. Маржа = Выручка – COGS.
Что игнорировать: просмотры страниц (это метрика трафика, не бизнеса).
Коучинг / Инфопродукты / Образование
- Выручка за месяц —
SUM(revenue). Базовая метрика. - Кол-во новых студентов —
COUNT(new_clients). Темп набора. - Completion rate — Завершивших / записавшихся. <30% — продукт надо улучшить.
- Средний чек — Выручка / кол-во продаж. Растёт — повышай цену.
- Revenue per студент (LTV) — Купил ли ещё раз? Главная метрика инфобизнеса.
- Активность в группе —
unique_senders / total_messages. Вовлечённость = индикатор удержания.
Что игнорировать: охваты и лайки (до тех пор пока не видишь корреляцию с продажами).
7 граблей с боевого деплоя
Каждый из этих косяков я или видел лично, или нашёл в коде, который генерировал Claude. Все — исправляемые за 10–15 минут если знаешь симптом.
-
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). -
Таймзоны — главный источник ошибок в данных.
Симптом: выручка за «сегодня» показывает вчерашние данные или меньше реального.
Причина: 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 кода. -
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%». -
Пропуски данных — дыры в графике.
Симптом: 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. -
Дублирование данных при повторном запуске.
Симптом: выручка удваивается после рестарта сервиса.
Причина: 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(*)не изменился. -
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 месяца. -
Алёрт-спам при старте сервиса.
Симптом: получаешь 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 ошибок».Multi-business — если бизнесов несколько
Один дашборд — один бизнес. Но что если у тебя их три? Фитнес-студия, SaaS и консалтинг. Вот три подхода от простого к правильному — выбирай по тому, кому показываешь данные.
Подход 1 — Одна БД с tenant_id (просто, для старта)
Добавляешь поле tenant_id во все таблицы. Каждый источник данных при записи указывает свой tenant. В API-запросах всегда фильтруешь по tenant_id. На фронте — выпадающий список бизнесов.
- Плюсы: одна БД, один деплой, простая поддержка, минимум денег.
- Минусы: при ошибке в запросе можно увидеть чужие данные (если забыл
WHERE tenant_id). Нет физической изоляции. - Кому подходит: личное использование, 2–5 бизнесов твоих собственных.
Подход 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]
- Плюсы: полная изоляция, можно давать клиентам прямой доступ к их базе, бекапы независимые.
- Минусы: дороже (N баз), сложнее обслуживать миграции (нужен скрипт-раннер на все базы).
- Кому подходит: когда у тебя клиенты и нужно гарантировать что их данные физически изолированы.
Подход 3 — N отдельных Vercel проектов (просто, но дороже)
Клонируй репо для каждого бизнеса, задеплой отдельно на Vercel. Разные AUTH_TOKEN, разные DATABASE_URL, разные URL.
- Плюсы: полная изоляция, отдельный URL для каждого бизнеса, независимые деплои.
- Минусы: поддерживать несколько репо — боль при обновлении (нужен monorepo или скрипт sync). N коллекторов = N VPS.
- Кому подходит: когда бизнесы реально разные (e-commerce + SaaS) и метрики разные.
Промпт для 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') должны остаться доступны.
WHERE tenant_id = $1 в одном запросе — пользователь увидит чужие KPI. Защита: Row-Level Security в Postgres. Включи RLS на всех таблицах + политика USING (tenant_id = current_setting('app.current_tenant')). Тогда даже забытый WHERE не приведёт к утечке.Что внутри boilerplate
collector/— FastAPI-сервис с 4 источниками (Sheets/Stripe/Юкасса/Telethon), APScheduler, логирование в sync_logui/— Next.js 15 + Recharts 3 + Tailwind v4, hash-auth через Edge Middleware, готовые KPICard и RevenueChart компонентыschema.sql— полная схема БД: 6 таблиц, индексы, поддержка multi-tenant.env.example— 13 переменных, все задокументированыinfra/cron/— системный cron-конфиг как fallback для APSchedulerREADME.md— 10 шагов отgit cloneдо первого алёрта в Telegram- Поддержка 3 деплой-платформ: Vercel (фронт), Railway/VPS (коллектор), Cloudflare Pages (альтернатива)
Экономика простая: 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-интеграции, нет кастомной логики алёртов, нет контроля над кодом.
Забери boilerplate-репо + 3 промпта + чек-лист deploy
В Telegram-боте: готовое репо с полной структурой, три промпта точно как в статье (копипастить без изменений), .env.example с 13 переменными, schema.sql, чек-лист из 10 шагов и таблица KPI для 4 типов бизнеса в Notion.
Открыть в Telegram →