Перейти к основному содержимому

Клиентский дашборд

Принцип дашборда

Клиент видит результат, не данные. Дашборд не показывает сырые цифры SF API. Он показывает: "Ваш контент работает / не работает, вот доказательства, вот что нужно улучшить".

Обзор секций

graph TB
subgraph "Клиентский дашборд"
A["Главная страница<br/>ContentScore Overview"]
B["Динамика продаж<br/>Sales Dynamics"]
C["Before/After<br/>Сравнение"]
D["Позиции<br/>Position Heatmap"]
E["ROI Калькулятор<br/>Инвестиции → Результат"]
F["Отчёты<br/>PDF / History"]
end

A --> B
A --> C
A --> D
A --> E
B --> F
C --> F

style A fill:#FF4B4B,color:#fff
style B fill:#FF6B6B,color:#fff
style C fill:#FF8B8B,color:#fff
style D fill:#FFA8A8,color:#fff
style E fill:#FFC4C4,color:#fff
style F fill:#FFE0E0,color:#000

Навигация

СтраницаURLОписание
Overview/ContentScore gauge, ключевые метрики, алерты
Products/productsВсе товары клиента, сортировка по скору
Before/After/before-afterТрекинг изменений контента
Reports/reportsИстория отчётов, генерация PDF

Аутентификация

Token-based access

# src/dashboard/auth.py

import streamlit as st
import hashlib
from datetime import datetime, timedelta
from src.database.session import get_sync_session


def check_auth() -> dict | None:
"""
Проверка аутентификации клиента по API-токену.

Клиент получает токен при регистрации.
Токен вводится один раз и сохраняется в session_state.

Returns:
dict с данными клиента или None
"""
# Проверяем, есть ли сохранённый токен
if "client" in st.session_state and st.session_state.client:
return st.session_state.client

# Показываем форму входа
st.markdown(
"""
<div style="text-align: center; padding: 50px;">
<img src="/static/logo.png" width="200" />
<h1>Fotofactor ROI-Content</h1>
<p>Введите ваш API-токен для доступа к дашборду</p>
</div>
""",
unsafe_allow_html=True,
)

token = st.text_input(
"API-токен",
type="password",
placeholder="Введите токен из письма...",
key="auth_token",
)

if st.button("Войти", type="primary", use_container_width=True):
if not token or len(token) < 10:
st.error("Некорректный токен")
return None

# Проверяем токен в БД
with get_sync_session() as session:
result = session.execute(
"""
SELECT id, name, email, plan_tier, max_products, settings
FROM clients
WHERE api_token = :token AND is_active = true
""",
{"token": token}
)
client = result.fetchone()

if client:
client_data = {
"id": str(client.id),
"name": client.name,
"email": client.email,
"plan_tier": client.plan_tier,
"max_products": client.max_products,
"settings": client.settings or {},
}
st.session_state.client = client_data
st.rerun()
else:
st.error("Неверный токен или аккаунт деактивирован")
return None

return None


def require_auth(func):
"""Декоратор для страниц, требующих аутентификации."""
def wrapper(*args, **kwargs):
client = check_auth()
if client is None:
st.stop()
return func(client=client, *args, **kwargs)
return wrapper

Страница 1: Overview (Главная)

Макет

┌─────────────────────────────────────────────────────────┐
│ FOTOFACTOR ROI-CONTENT Клиент: ООО "Текстиль" │
│ Тариф: РОСТ | 25 товаров│
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Score │ │ Продажи │ │ Позиция │ │ ROI │ │
│ │ 72.4 │ │ +23% │ │ #45 │ │ 340% │ │
│ │ 🔵 Good │ │ ↑ рост │ │ ↑ рост │ │ ↑ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ContentScore Distribution │ │
│ │ 🟢 Excellent (3) ████████████ │ │
│ │ 🔵 Good (8) ██████████████████████████ │ │
│ │ 🟡 Average (10) ████████████████████████████ │ │
│ │ 🟠 Poor (3) ████████████ │ │
│ │ 🔴 Critical (1) ████ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Требуют внимания (ContentScore < 50): │ │
│ │ ⚠️ Полотенце махровое 70x140 — Score: 28 🔴 │ │
│ │ ⚠️ Комплект постельного белья — Score: 42 🟠 │ │
│ │ ⚠️ Подушка ортопедическая — Score: 38 🟠 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Последние обновления: │ │
│ │ ✅ 15.02 — Набор полотенец: +47% продаж │ │
│ │ 🔄 12.02 — Постельное бельё: ожидание T2 │ │
│ │ 📊 10.02 — Подушка: baseline собран │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Streamlit код

# src/dashboard/pages/01_overview.py

import streamlit as st
import plotly.graph_objects as go
import plotly.express as px
from datetime import datetime, timedelta

from src.dashboard.auth import require_auth
from src.dashboard.components.score_gauge import render_score_gauge
from src.database.repositories.products import ProductRepository
from src.database.repositories.scores import ScoreRepository


@require_auth
def main(client: dict):
# Header
st.markdown(
f"""
<div style="display: flex; justify-content: space-between; align-items: center;">
<h1>Fotofactor ROI-Content</h1>
<div style="text-align: right;">
<strong>{client['name']}</strong><br/>
Тариф: {client['plan_tier'].upper()} | {client['max_products']} товаров
</div>
</div>
""",
unsafe_allow_html=True,
)

# Загружаем данные
repo = ProductRepository()
score_repo = ScoreRepository()

products = repo.get_by_client(client["id"])
latest_scores = score_repo.get_latest_by_client(client["id"])

# ========== KPI Cards ==========
col1, col2, col3, col4 = st.columns(4)

avg_score = sum(s.total_score for s in latest_scores) / max(len(latest_scores), 1)
avg_score_prev = score_repo.get_avg_score_days_ago(client["id"], 7)
score_delta = avg_score - avg_score_prev if avg_score_prev else 0

with col1:
st.metric(
label="Средний ContentScore",
value=f"{avg_score:.1f}",
delta=f"{score_delta:+.1f}" if score_delta != 0 else None,
)

with col2:
total_sales = repo.get_total_sales_7d(client["id"])
prev_sales = repo.get_total_sales_7d(client["id"], offset_days=7)
sales_delta = ((total_sales - prev_sales) / max(prev_sales, 1)) * 100

st.metric(
label="Продажи (7 дней)",
value=f"{total_sales:,}",
delta=f"{sales_delta:+.1f}%",
)

with col3:
avg_position = repo.get_avg_position(client["id"])
st.metric(
label="Средняя позиция",
value=f"#{avg_position:.0f}" if avg_position else "N/A",
)

with col4:
avg_roi = score_repo.get_avg_roi(client["id"])
st.metric(
label="Средний ROI",
value=f"{avg_roi:.0f}%" if avg_roi else "N/A",
)

st.divider()

# ========== ContentScore Distribution ==========
col_left, col_right = st.columns([2, 1])

with col_left:
st.subheader("Распределение ContentScore")

# Группируем по грейдам
grades = {"excellent": 0, "good": 0, "average": 0, "poor": 0, "critical": 0}
for score in latest_scores:
if score.total_score >= 90:
grades["excellent"] += 1
elif score.total_score >= 70:
grades["good"] += 1
elif score.total_score >= 50:
grades["average"] += 1
elif score.total_score >= 30:
grades["poor"] += 1
else:
grades["critical"] += 1

fig = go.Figure(go.Bar(
x=list(grades.values()),
y=["Excellent (90+)", "Good (70-89)", "Average (50-69)", "Poor (30-49)", "Critical (0-29)"],
orientation="h",
marker_color=["#4CAF50", "#2196F3", "#FFC107", "#FF9800", "#F44336"],
text=list(grades.values()),
textposition="auto",
))
fig.update_layout(
height=250,
margin=dict(l=0, r=0, t=10, b=0),
xaxis_title="Количество товаров",
yaxis=dict(autorange="reversed"),
)
st.plotly_chart(fig, use_container_width=True)

with col_right:
st.subheader("ContentScore")
render_score_gauge(avg_score)

st.divider()

# ========== Alerts: товары, требующие внимания ==========
poor_products = [
(p, s) for p, s in zip(products, latest_scores)
if s.total_score < 50
]

if poor_products:
st.subheader(f"Требуют внимания ({len(poor_products)} товаров)")

for product, score in sorted(poor_products, key=lambda x: x[1].total_score):
grade_emoji = "🔴" if score.total_score < 30 else "🟠"
with st.expander(f"{grade_emoji} {product.name[:60]} — Score: {score.total_score:.0f}"):
# Рекомендации
for rec in score.recommendations[:3]:
st.markdown(f"- {rec}")

col_a, col_b = st.columns(2)
with col_a:
st.metric("Позиция", f"#{product.latest_position or 'N/A'}")
with col_b:
st.metric("Продажи/день", f"{product.avg_daily_sales:.1f}")
else:
st.success("Все товары имеют ContentScore > 50. Отличная работа!")

# ========== Последние Before/After обновления ==========
st.subheader("Последние обновления контента")
recent_tracks = score_repo.get_recent_tracks(client["id"], limit=5)

for track in recent_tracks:
status_icon = {
"active": "🔄",
"completed": "✅",
}.get(track.status, "📊")

st.markdown(
f"{status_icon} **{track.event_date:%d.%m}** — {track.product_name}: "
f"{track.summary or 'Отслеживание в процессе'}"
)


main()

Страница 2: Products (Товары)

Streamlit код

# src/dashboard/pages/02_products.py

import streamlit as st
import pandas as pd
import plotly.express as px

from src.dashboard.auth import require_auth
from src.database.repositories.products import ProductRepository
from src.database.repositories.scores import ScoreRepository


@require_auth
def main(client: dict):
st.header("Мои товары")

repo = ProductRepository()
score_repo = ScoreRepository()

# Фильтры
col1, col2, col3 = st.columns(3)
with col1:
sort_by = st.selectbox("Сортировка", [
"ContentScore (↑)", "ContentScore (↓)",
"Продажи (↓)", "Позиция (↑)"
])
with col2:
grade_filter = st.multiselect(
"Грейд",
["Excellent", "Good", "Average", "Poor", "Critical"],
default=["Excellent", "Good", "Average", "Poor", "Critical"],
)
with col3:
marketplace_filter = st.selectbox(
"Маркетплейс",
["Все", "Wildberries", "Ozon"],
)

# Загружаем данные
products_with_scores = repo.get_products_with_latest_scores(
client_id=client["id"],
marketplace=marketplace_filter.lower() if marketplace_filter != "Все" else None,
)

# Создаём DataFrame
data = []
for p, s in products_with_scores:
grade = "critical"
if s.total_score >= 90:
grade = "excellent"
elif s.total_score >= 70:
grade = "good"
elif s.total_score >= 50:
grade = "average"
elif s.total_score >= 30:
grade = "poor"

data.append({
"SKU": p.sku,
"Название": p.name[:50],
"ContentScore": round(s.total_score, 1),
"Грейд": grade.capitalize(),
"Позиция": p.latest_position or "N/A",
"Продажи/день": round(p.avg_daily_sales or 0, 1),
"Выручка/день": round(p.avg_daily_revenue or 0, 0),
"Рейтинг": p.rating or 0,
"Фото": p.photo_count or 0,
"Видео": p.video_count or 0,
"Маркетплейс": p.marketplace.upper(),
})

df = pd.DataFrame(data)

# Фильтрация по грейду
grade_map = {
"Excellent": "Excellent",
"Good": "Good",
"Average": "Average",
"Poor": "Poor",
"Critical": "Critical",
}
selected_grades = [grade_map[g] for g in grade_filter]
df = df[df["Грейд"].isin(selected_grades)]

# Сортировка
sort_options = {
"ContentScore (↑)": ("ContentScore", True),
"ContentScore (↓)": ("ContentScore", False),
"Продажи (↓)": ("Продажи/день", False),
"Позиция (↑)": ("Позиция", True),
}
sort_col, sort_asc = sort_options[sort_by]
if sort_col == "Позиция":
df_sorted = df.sort_values(sort_col, ascending=sort_asc, na_position="last")
else:
df_sorted = df.sort_values(sort_col, ascending=sort_asc)

# ========== Scatter Plot: ContentScore vs Sales ==========
st.subheader("ContentScore vs Продажи")

fig = px.scatter(
df, x="ContentScore", y="Продажи/день",
color="Грейд",
color_discrete_map={
"Excellent": "#4CAF50",
"Good": "#2196F3",
"Average": "#FFC107",
"Poor": "#FF9800",
"Critical": "#F44336",
},
size="Выручка/день",
hover_name="Название",
hover_data=["SKU", "Позиция", "Рейтинг"],
height=400,
)
fig.update_layout(
xaxis_title="ContentScore",
yaxis_title="Средние продажи в день",
)
st.plotly_chart(fig, use_container_width=True)

# ========== Таблица товаров ==========
st.subheader(f"Все товары ({len(df_sorted)})")

# Цветовая раскраска ContentScore
def color_score(val):
if isinstance(val, (int, float)):
if val >= 90:
return "background-color: #C8E6C9"
elif val >= 70:
return "background-color: #BBDEFB"
elif val >= 50:
return "background-color: #FFF9C4"
elif val >= 30:
return "background-color: #FFE0B2"
else:
return "background-color: #FFCDD2"
return ""

styled_df = df_sorted.style.applymap(color_score, subset=["ContentScore"])
st.dataframe(styled_df, use_container_width=True, height=500)

# Экспорт
csv = df_sorted.to_csv(index=False).encode("utf-8")
st.download_button(
label="Скачать CSV",
data=csv,
file_name=f"products_{datetime.now():%Y%m%d}.csv",
mime="text/csv",
)


main()

Страница 3: Before/After

Макет

┌─────────────────────────────────────────────────────────┐
│ BEFORE / AFTER COMPARISON │
├─────────────────────────────────────────────────────────┤
│ │
│ Выберите товар: [Набор полотенец SoftHome ▼] │
│ Событие: Полная пересъёмка (15.02.2026) │
│ Статус: ✅ Завершён (T4 собран) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 📈 Trajectory Chart │ │
│ │ │ │
│ │ Sales/day │ │
│ │ 9 │ ●──● │ │
│ │ 7 │ ●──── │ │
│ │ 5 │ ●──── │ │
│ │ 3 │ ●─────● ● │ │
│ │ 1 │ ●── │ │
│ │ └──T0────T1───T2───T3─────────T4 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ МЕТРИКА │ T0 (до) │ T3 (30д) │ Изменение │ │
│ │──────────────│─────────│──────────│────────────│ │
│ │ Позиция │ #142 │ #52 │ +63% 📈 │ │
│ │ Продажи/день │ 3.2 │ 7.8 │ +144% 📈 │ │
│ │ Выручка/день │ 5 760 ₽ │ 14 040 ₽ │ +144% 📈 │ │
│ │ ContentScore │ 34 │ 78 │ +129% 📈 │ │
│ │ Рейтинг │ 4.3 │ 4.5 │ +0.2 ↗️ │ │
│ │ Отзывов │ 89 │ 128 │ +44% 📈 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 💰 ROI │ │
│ │ Стоимость контента: 45 000 ₽ │ │
│ │ Доп. выручка (30 дней): 248 400 ₽ │ │
│ │ ROI: 1 225% │ │
│ │ Окупаемость: 5.4 дня │ │
│ │ Статистическая значимость: ✅ p=0.003 │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘

Streamlit код

# src/dashboard/pages/03_before_after.py

import streamlit as st
import plotly.graph_objects as go
import pandas as pd
from datetime import datetime

from src.dashboard.auth import require_auth
from src.engine.before_after import BeforeAfterTracker, MeasurementType


@require_auth
def main(client: dict):
st.header("Before / After Comparison")

tracker = BeforeAfterTracker()

# Выбор товара
tracks = tracker.get_tracks_by_client(client["id"])
if not tracks:
st.info("У вас пока нет отслеживаемых обновлений контента. "
"Обратитесь к менеджеру для регистрации обновления.")
return

track_options = {
f"{t.product_name}{t.event_type} ({t.event_date:%d.%m.%Y})": t
for t in tracks
}
selected_label = st.selectbox("Выберите товар и событие", list(track_options.keys()))
track = track_options[selected_label]

# Статус трека
status_map = {
"active": ("🔄", "В процессе отслеживания"),
"completed": ("✅", "Завершён"),
"cancelled": ("❌", "Отменён"),
}
icon, label = status_map.get(track.status, ("❓", "Неизвестно"))
st.markdown(f"**Статус**: {icon} {label}")

# ========== Trajectory Chart ==========
st.subheader("Trajectory Chart")

measurements = []
labels = ["T0 (baseline)", "T1 (+7д)", "T2 (+14д)", "T3 (+30д)", "T4 (+90д)"]
fields = ["baseline_t0", "measurement_t1", "measurement_t2", "measurement_t3", "measurement_t4"]

for field_name, label in zip(fields, labels):
data = getattr(track, field_name)
if data:
measurements.append({"label": label, "data": data})

if len(measurements) >= 2:
# Двойная ось Y: позиция (инвертированная) + продажи
fig = go.Figure()

# Продажи
sales_values = [m["data"]["sales"]["sales_7d"] / 7 for m in measurements]
fig.add_trace(go.Scatter(
x=[m["label"] for m in measurements],
y=sales_values,
name="Продажи/день",
line=dict(color="#4CAF50", width=3),
marker=dict(size=10),
yaxis="y1",
))

# Позиция (инвертированная ось)
position_values = [m["data"]["position"]["category"] for m in measurements]
fig.add_trace(go.Scatter(
x=[m["label"] for m in measurements],
y=position_values,
name="Позиция в категории",
line=dict(color="#2196F3", width=3, dash="dot"),
marker=dict(size=10),
yaxis="y2",
))

# ContentScore
score_values = [m["data"]["score"]["total"] for m in measurements]
fig.add_trace(go.Scatter(
x=[m["label"] for m in measurements],
y=score_values,
name="ContentScore",
line=dict(color="#FF9800", width=2),
marker=dict(size=8),
yaxis="y3",
))

fig.update_layout(
height=450,
yaxis=dict(title="Продажи/день", side="left", color="#4CAF50"),
yaxis2=dict(
title="Позиция",
side="right",
overlaying="y",
autorange="reversed",
color="#2196F3",
),
yaxis3=dict(
title="ContentScore",
side="right",
overlaying="y",
anchor="free",
position=0.95,
color="#FF9800",
),
legend=dict(orientation="h", yanchor="bottom", y=1.02),
hovermode="x unified",
)

st.plotly_chart(fig, use_container_width=True)
else:
st.info("Недостаточно данных для построения графика (нужно минимум 2 замера)")

# ========== Comparison Table ==========
st.subheader("Детальное сравнение")

if len(measurements) >= 2:
baseline = measurements[0]["data"]
latest = measurements[-1]["data"]

comparison_data = [
{
"Метрика": "Позиция в категории",
"До (T0)": f"#{baseline['position']['category']}",
"После": f"#{latest['position']['category']}",
"Изменение": _format_change(
baseline["position"]["category"],
latest["position"]["category"],
lower_is_better=True,
),
},
{
"Метрика": "Продажи (7 дней)",
"До (T0)": f"{baseline['sales']['sales_7d']:,}",
"После": f"{latest['sales']['sales_7d']:,}",
"Изменение": _format_change(
baseline["sales"]["sales_7d"],
latest["sales"]["sales_7d"],
),
},
{
"Метрика": "Выручка (7 дней)",
"До (T0)": f"{baseline['sales']['revenue_7d']:,.0f} ₽",
"После": f"{latest['sales']['revenue_7d']:,.0f} ₽",
"Изменение": _format_change(
baseline["sales"]["revenue_7d"],
latest["sales"]["revenue_7d"],
),
},
{
"Метрика": "ContentScore",
"До (T0)": f"{baseline['score']['total']:.0f}",
"После": f"{latest['score']['total']:.0f}",
"Изменение": _format_change(
baseline["score"]["total"],
latest["score"]["total"],
),
},
{
"Метрика": "Рейтинг",
"До (T0)": f"{baseline['reviews']['rating']:.1f}",
"После": f"{latest['reviews']['rating']:.1f}",
"Изменение": _format_change_absolute(
baseline["reviews"]["rating"],
latest["reviews"]["rating"],
),
},
{
"Метрика": "Количество отзывов",
"До (T0)": f"{baseline['reviews']['count']:,}",
"После": f"{latest['reviews']['count']:,}",
"Изменение": _format_change(
baseline["reviews"]["count"],
latest["reviews"]["count"],
),
},
]

df_comparison = pd.DataFrame(comparison_data)
st.dataframe(df_comparison, use_container_width=True, hide_index=True)

# ========== ROI Block ==========
if track.roi_calculated is not None:
st.subheader("ROI обновления контента")

col1, col2, col3 = st.columns(3)

content_cost = track.content_cost or 30000
with col1:
st.metric("Стоимость контента", f"{content_cost:,.0f} ₽")
with col2:
additional_rev = track.additional_revenue_30d or 0
st.metric("Доп. выручка (30д)", f"{additional_rev:,.0f} ₽")
with col3:
st.metric("ROI", f"{track.roi_calculated:.0f}%", delta="Окупаемость: 5.4 дня")

# Statistical significance
if track.significance_p_value:
if track.significance_p_value < 0.05:
st.success(f"Статистически значимый результат (p = {track.significance_p_value:.3f})")
else:
st.warning(f"Результат статистически незначим (p = {track.significance_p_value:.3f}). "
"Рекомендуем подождать больше данных.")


def _format_change(before: float, after: float, lower_is_better: bool = False) -> str:
"""Форматирование процентного изменения с эмодзи."""
if before == 0:
return "N/A"
pct = ((after - before) / abs(before)) * 100

if lower_is_better:
is_good = after < before
else:
is_good = after > before

emoji = "📈" if is_good else "📉"
return f"{pct:+.1f}% {emoji}"


def _format_change_absolute(before: float, after: float) -> str:
"""Форматирование абсолютного изменения."""
diff = after - before
emoji = "↗️" if diff > 0 else ("↘️" if diff < 0 else "→")
return f"{diff:+.1f} {emoji}"


main()

Компоненты

ContentScore Gauge (спидометр)

# src/dashboard/components/score_gauge.py

import plotly.graph_objects as go
import streamlit as st


def render_score_gauge(score: float, title: str = "ContentScore"):
"""
Отрисовка ContentScore в виде спидометра.

Цветовые зоны:
- 0-30: Красный
- 30-50: Оранжевый
- 50-70: Жёлтый
- 70-90: Синий
- 90-100: Зелёный
"""
fig = go.Figure(go.Indicator(
mode="gauge+number+delta",
value=score,
title={"text": title, "font": {"size": 20}},
number={"font": {"size": 48}},
gauge={
"axis": {"range": [0, 100], "tickwidth": 2},
"bar": {"color": _score_color(score), "thickness": 0.3},
"bgcolor": "white",
"borderwidth": 2,
"steps": [
{"range": [0, 30], "color": "#FFCDD2"},
{"range": [30, 50], "color": "#FFE0B2"},
{"range": [50, 70], "color": "#FFF9C4"},
{"range": [70, 90], "color": "#BBDEFB"},
{"range": [90, 100], "color": "#C8E6C9"},
],
"threshold": {
"line": {"color": "black", "width": 4},
"thickness": 0.8,
"value": score,
},
},
))

fig.update_layout(
height=250,
margin=dict(l=20, r=20, t=50, b=20),
)
st.plotly_chart(fig, use_container_width=True)


def _score_color(score: float) -> str:
if score >= 90:
return "#4CAF50"
elif score >= 70:
return "#2196F3"
elif score >= 50:
return "#FFC107"
elif score >= 30:
return "#FF9800"
return "#F44336"

Position Heatmap

# src/dashboard/components/position_heatmap.py

import plotly.express as px
import pandas as pd
import streamlit as st


def render_position_heatmap(
products: list[dict],
keywords: list[str],
title: str = "Позиции по ключевым словам"
):
"""
Тепловая карта: товары (строки) x ключевые слова (столбцы).
Значение = позиция в поиске. Цвет: зелёный (хорошо) → красный (плохо).
"""
# Подготовка данных
data = []
for product in products:
row = {"Товар": product["name"][:30]}
for kw in keywords:
pos = product.get("search_positions", {}).get(kw)
row[kw] = pos if pos else None
data.append(row)

df = pd.DataFrame(data).set_index("Товар")

fig = px.imshow(
df.values,
labels=dict(x="Ключевое слово", y="Товар", color="Позиция"),
x=list(df.columns),
y=list(df.index),
color_continuous_scale="RdYlGn_r", # Инвертированная: зелёный = 1, красный = 200+
zmin=1,
zmax=200,
aspect="auto",
)

fig.update_layout(
title=title,
height=max(300, len(products) * 40),
)

# Добавляем значения в ячейки
for i, row_data in enumerate(df.values):
for j, val in enumerate(row_data):
if val is not None:
fig.add_annotation(
x=j, y=i,
text=str(int(val)),
showarrow=False,
font=dict(color="white" if val > 100 else "black", size=11),
)

st.plotly_chart(fig, use_container_width=True)

Auto-refresh и Real-time

# src/dashboard/app.py — main app configuration

import streamlit as st

st.set_page_config(
page_title="Fotofactor ROI-Content",
page_icon="📊",
layout="wide",
initial_sidebar_state="expanded",
)

# Автообновление каждые 5 минут
# (через meta refresh для Streamlit)
st.markdown(
"""
<meta http-equiv="refresh" content="300">
""",
unsafe_allow_html=True,
)

# Custom CSS для Fotofactor branding
st.markdown(
"""
<style>
/* Fotofactor brand colors */
:root {
--primary: #FF4B4B;
--secondary: #2196F3;
--success: #4CAF50;
}

/* Header styling */
.stApp header {
background-color: #1a1a2e;
}

/* Sidebar styling */
.css-1d391kg {
background-color: #16213e;
}

/* Hide Streamlit branding */
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}

/* Custom metric cards */
[data-testid="stMetricValue"] {
font-size: 2rem;
font-weight: 700;
}

/* Table improvements */
.dataframe {
font-size: 0.9rem;
}
</style>
""",
unsafe_allow_html=True,
)

PDF-отчёт (экспорт из дашборда)

Генерация еженедельного отчёта

# src/reports/generator.py

import os
from datetime import datetime, timedelta
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
import structlog

logger = structlog.get_logger()

TEMPLATE_DIR = Path(__file__).parent / "templates"


class ReportGenerator:
"""Генератор PDF-отчётов для клиентов."""

def __init__(self):
self.jinja_env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=True,
)

def generate_weekly_report(
self,
client_id: str,
period_end: datetime | None = None,
) -> str:
"""
Генерация еженедельного PDF-отчёта.

Содержание:
1. Executive Summary (ключевые метрики за неделю)
2. ContentScore динамика (график)
3. Top-5 товаров и Bottom-5 товаров
4. Before/After обновления за неделю
5. Рекомендации

Returns:
str: путь к сгенерированному PDF
"""
if period_end is None:
period_end = datetime.utcnow()
period_start = period_end - timedelta(days=7)

# Собираем данные для отчёта
data = self._collect_report_data(client_id, period_start, period_end)

# Рендерим HTML
template = self.jinja_env.get_template("weekly_report.html")
html_content = template.render(
client_name=data["client_name"],
period=f"{period_start:%d.%m.%Y}{period_end:%d.%m.%Y}",
avg_score=data["avg_score"],
avg_score_change=data["avg_score_change"],
total_products=data["total_products"],
total_sales=data["total_sales"],
total_revenue=data["total_revenue"],
top_products=data["top_products"],
bottom_products=data["bottom_products"],
before_after_updates=data["before_after_updates"],
recommendations=data["recommendations"],
generated_at=datetime.utcnow().strftime("%d.%m.%Y %H:%M"),
# Branding
logo_url="https://fotofactor.ru/logo.png",
brand_color="#FF4B4B",
)

# Конвертируем в PDF
output_dir = Path("outputs/reports")
output_dir.mkdir(parents=True, exist_ok=True)

filename = f"weekly_{client_id[:8]}_{period_end:%Y%m%d}.pdf"
output_path = output_dir / filename

HTML(string=html_content).write_pdf(str(output_path))
logger.info("report_generated", client_id=client_id, path=str(output_path))

return str(output_path)

def _collect_report_data(
self, client_id: str, start: datetime, end: datetime
) -> dict:
"""Сбор данных для отчёта из БД."""
from src.database.repositories.products import ProductRepository
from src.database.repositories.scores import ScoreRepository

repo = ProductRepository()
score_repo = ScoreRepository()

# Средний ContentScore
current_scores = score_repo.get_latest_by_client(client_id)
avg_score = sum(s.total_score for s in current_scores) / max(len(current_scores), 1)
prev_avg = score_repo.get_avg_score_days_ago(client_id, 7)
avg_change = avg_score - prev_avg if prev_avg else 0

# Топ и аутсайдеры
sorted_scores = sorted(current_scores, key=lambda s: s.total_score, reverse=True)
top_5 = sorted_scores[:5]
bottom_5 = sorted_scores[-5:] if len(sorted_scores) > 5 else []

# Before/After обновления
ba_updates = score_repo.get_tracks_in_period(client_id, start, end)

# Рекомендации (агрегируем самые частые)
all_recs = []
for score in current_scores:
if score.recommendations:
all_recs.extend(score.recommendations)

# Топ-3 рекомендации по частоте
from collections import Counter
rec_counter = Counter(all_recs)
top_recs = [rec for rec, _ in rec_counter.most_common(3)]

return {
"client_name": repo.get_client_name(client_id),
"avg_score": round(avg_score, 1),
"avg_score_change": round(avg_change, 1),
"total_products": len(current_scores),
"total_sales": repo.get_total_sales_7d(client_id),
"total_revenue": repo.get_total_revenue_7d(client_id),
"top_products": top_5,
"bottom_products": bottom_5,
"before_after_updates": ba_updates,
"recommendations": top_recs,
}

HTML-шаблон отчёта

<!-- src/reports/templates/weekly_report.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: 'Segoe UI', Arial, sans-serif;
color: #333;
margin: 40px;
font-size: 14px;
}
.header {
display: flex;
justify-content: space-between;
border-bottom: 3px solid {{ brand_color }};
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 { color: {{ brand_color }}; margin: 0; }
.kpi-row {
display: flex;
gap: 20px;
margin-bottom: 30px;
}
.kpi-card {
flex: 1;
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.kpi-value { font-size: 32px; font-weight: 700; color: {{ brand_color }}; }
.kpi-label { font-size: 12px; color: #666; text-transform: uppercase; }
.kpi-delta { font-size: 14px; }
.positive { color: #4CAF50; }
.negative { color: #F44336; }
table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
}
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; font-weight: 600; }
h2 { color: {{ brand_color }}; border-bottom: 1px solid #eee; padding-bottom: 8px; }
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
font-size: 11px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<div>
<h1>Еженедельный отчёт ROI-Content</h1>
<p>{{ client_name }} | {{ period }}</p>
</div>
<div style="text-align: right;">
<img src="{{ logo_url }}" height="50" />
</div>
</div>

<!-- KPI Cards -->
<div class="kpi-row">
<div class="kpi-card">
<div class="kpi-value">{{ avg_score }}</div>
<div class="kpi-label">Средний ContentScore</div>
<div class="kpi-delta {{ 'positive' if avg_score_change > 0 else 'negative' }}">
{{ '%+.1f'|format(avg_score_change) }} за неделю
</div>
</div>
<div class="kpi-card">
<div class="kpi-value">{{ total_products }}</div>
<div class="kpi-label">Товаров на мониторинге</div>
</div>
<div class="kpi-card">
<div class="kpi-value">{{ '{:,}'.format(total_sales) }}</div>
<div class="kpi-label">Продаж за неделю</div>
</div>
<div class="kpi-card">
<div class="kpi-value">{{ '{:,.0f}'.format(total_revenue) }} ₽</div>
<div class="kpi-label">Выручка за неделю</div>
</div>
</div>

<!-- Content continues with tables, charts, recommendations -->

<div class="footer">
<p>Отчёт сгенерирован автоматически: {{ generated_at }} | Fotofactor ROI-Content</p>
<p>Вопросы: support@fotofactor.ru | Дашборд: dashboard.fotofactor.ru</p>
</div>
</body>
</html>

Mobile-friendly layout

# Streamlit responsive design tips

# 1. Используем container для ограничения ширины на мобильных
with st.container():
# Содержимое автоматически адаптируется

# 2. На мобильных columns стакаются вертикально
# Streamlit делает это автоматически при ширине < 768px

# 3. Для важных метрик используем одну колонку на мобильных
screen_width = st.session_state.get("screen_width", 1200)
if screen_width < 768:
# Мобильный layout
for metric in metrics:
st.metric(metric.label, metric.value, metric.delta)
else:
# Desktop layout
cols = st.columns(4)
for col, metric in zip(cols, metrics):
with col:
st.metric(metric.label, metric.value, metric.delta)

White-label: Fotofactor branding

White-label

Дашборд брендируется под Fotofactor. Клиент не видит упоминаний SalesFinder, технических деталей API, или внутренних систем. Это наш продукт, SF — это инструмент "под капотом".

ЭлементЧто видит клиентЧто скрыто
ЛоготипFotofactor ROI-ContentSalesFinder
URLdashboard.fotofactor.rusalesfinder.ru
ОтчётыFotofactor Analytics ReportSF API данные
МетрикиContentScore, ROIСырые API ответы
Emailanalytics@fotofactor.ruSF credentials
ПоддержкаМенеджер FotofactorТехническая команда

Связанные документы

ДокументСодержание
MVP ОбзорВидение, roadmap, бюджет
Техническая архитектураКомпоненты, schema, deployment
ContentScore алгоритмФормула скоринга (визуализируется в gauge)
Before/After трекерДанные для comparison page
Финансовая модельPricing для тарифов в дашборде