init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+554
@@ -0,0 +1,554 @@
|
||||
"""Streamlit CRM-приложение для работы с лидами парсера.
|
||||
|
||||
Запуск:
|
||||
streamlit run app/app.py (из папки parser_v1)
|
||||
либо двойной клик по launch_crm.bat
|
||||
|
||||
Работает над той же leads.db что и парсер — изменения видны
|
||||
с обеих сторон сразу.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
# Чтобы импортировать config из родительской папки parser_v1/
|
||||
PARENT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PARENT))
|
||||
|
||||
import config # noqa: E402
|
||||
import database # noqa: E402 (для автомиграции БД при первом запуске CRM)
|
||||
import db_layer # noqa: E402 (рядом с app.py — Streamlit добавляет эту папку в sys.path)
|
||||
|
||||
|
||||
# ─── Настройка страницы ──────────────────────────────────────────────
|
||||
st.set_page_config(
|
||||
page_title="CRM — 44AS Парсер лидов",
|
||||
page_icon="🎯",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
DB_PATH = PARENT / config.DB_PATH
|
||||
|
||||
# Автомиграция: если БД старая (без 4 CRM-колонок / outreach_events / lead_in_run) —
|
||||
# init_db догонит схему через ALTER TABLE и CREATE TABLE IF NOT EXISTS.
|
||||
# Идемпотентно — на свежей БД ничего не делает.
|
||||
database.init_db(str(DB_PATH))
|
||||
|
||||
|
||||
# ─── Лейблы для UI ──────────────────────────────────────────────────
|
||||
CHANNEL_LABELS = {
|
||||
"call": "📞 Звонок",
|
||||
"email": "📧 Email",
|
||||
"vk": "🔵 VK",
|
||||
"telegram": "✈️ Telegram",
|
||||
"whatsapp": "💬 WhatsApp",
|
||||
"sms": "📩 SMS",
|
||||
}
|
||||
REACTION_LABELS = {
|
||||
"no_answer": "❓ Не ответили",
|
||||
"callback": "📞 Перезвонить позже",
|
||||
"refused": "🚫 Отказ",
|
||||
"agreed": "✅ Согласились",
|
||||
"moved_to_tg": "✈️ Перешли в Telegram",
|
||||
"not_target": "❌ Не наша ЦА",
|
||||
"spam": "🗑️ Спам / нерелевантно",
|
||||
}
|
||||
STATUS_LABELS = {
|
||||
"inbox": "📥 Inbox",
|
||||
"triaged": "👀 Разобран",
|
||||
"in_work": "⚙️ В работе",
|
||||
"done": "✅ Готово",
|
||||
"skip": "🚫 Пропустить",
|
||||
}
|
||||
# Источники — короткое отображение для таблицы и фильтров.
|
||||
# Один лид может иметь несколько источников через запятую (после мержа дублей),
|
||||
# например 'yandex_maps,hh'.
|
||||
SOURCE_LABELS = {
|
||||
"yandex_maps": "🗺 Я.Карты",
|
||||
"hh": "💼 HH",
|
||||
"2gis": "🗺 2ГИС",
|
||||
"vk": "🔵 VK",
|
||||
"telegram": "✈️ TG",
|
||||
"avito": "🟢 Avito",
|
||||
}
|
||||
# Продукты 44AS — короткие имена для подсказки «с чем заходить» (скоринг v5).
|
||||
PRODUCT_LABELS = {
|
||||
"P1": "Text Agent",
|
||||
"P2": "AI-Office",
|
||||
"P3": "AI-Reputation",
|
||||
"P4": "AI-Consultant",
|
||||
"P10": "Smart Web",
|
||||
"P12": "AI SMM",
|
||||
}
|
||||
BAND_LABELS = {"hot": "🔥 Hot", "warm": "🟡 Warm", "cold": "⚪ Cold"}
|
||||
|
||||
|
||||
# ─── Форматтеры ─────────────────────────────────────────────────────
|
||||
def fmt_status(s):
|
||||
return STATUS_LABELS.get(s or "inbox", str(s) if s else "inbox")
|
||||
|
||||
|
||||
def fmt_channel(c):
|
||||
return CHANNEL_LABELS.get(c, str(c) if c else "—")
|
||||
|
||||
|
||||
def fmt_source(s):
|
||||
"""Я.Карты + HH (через ' + ' если у лида несколько источников после мержа)."""
|
||||
if not s:
|
||||
return "—"
|
||||
parts = [SOURCE_LABELS.get(p.strip(), p.strip()) for p in s.split(",") if p.strip()]
|
||||
return " + ".join(parts) if parts else "—"
|
||||
|
||||
|
||||
def fmt_reaction(r):
|
||||
return REACTION_LABELS.get(r, str(r) if r else "—")
|
||||
|
||||
|
||||
def fmt_band(b):
|
||||
return BAND_LABELS.get(b, "—")
|
||||
|
||||
|
||||
def _parse_pain(pp):
|
||||
"""pain_products → list[(code, severity)] по убыванию. Принимает JSON-строку или dict."""
|
||||
if not pp:
|
||||
return []
|
||||
if isinstance(pp, str):
|
||||
try:
|
||||
pp = json.loads(pp)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if isinstance(pp, dict):
|
||||
return sorted(pp.items(), key=lambda kv: -kv[1])
|
||||
return []
|
||||
|
||||
|
||||
def fmt_pain_products(pp):
|
||||
"""Короткая строка продуктов «с чем заходить» для таблицы: 'P4 · P10 · P12'."""
|
||||
items = _parse_pain(pp)
|
||||
return " · ".join(code for code, _ in items)
|
||||
|
||||
|
||||
# ─── Деталь лида ────────────────────────────────────────────────────
|
||||
def render_lead_detail(lead_id: int) -> None:
|
||||
lead = db_layer.get_lead_detail(DB_PATH, lead_id)
|
||||
if not lead:
|
||||
st.error(f"Лид #{lead_id} не найден")
|
||||
return
|
||||
|
||||
st.markdown(f"## 📋 {lead['name']}")
|
||||
st.markdown(f"**Источник:** {fmt_source(lead.get('source'))} • ID #{lead_id}")
|
||||
|
||||
# Кнопка «открыть карточку источника» (Я.Карты / HH) — source_url из парсера.
|
||||
src_url = lead.get("source_url")
|
||||
if src_url:
|
||||
src = (lead.get("source") or "").lower()
|
||||
if "yandex" in src:
|
||||
label = "🗺 Открыть в Яндекс.Картах"
|
||||
elif "hh" in src:
|
||||
label = "💼 Открыть на HH"
|
||||
else:
|
||||
label = "🔗 Открыть карточку источника"
|
||||
st.link_button(label, src_url)
|
||||
|
||||
# 3 колонки: контакты | бизнес | статус
|
||||
c1, c2, c3 = st.columns(3)
|
||||
|
||||
with c1:
|
||||
st.markdown("**📞 Контакты**")
|
||||
if lead.get("phone_primary"):
|
||||
st.code(lead["phone_primary"], language=None)
|
||||
if lead.get("email_primary"):
|
||||
st.markdown(f"📧 `{lead['email_primary']}`")
|
||||
if lead.get("website"):
|
||||
st.markdown(f"🌐 [{lead['website']}]({lead['website']})")
|
||||
if lead.get("vk_url"):
|
||||
st.markdown(f"🔵 [VK]({lead['vk_url']})")
|
||||
if lead.get("telegram_url"):
|
||||
st.markdown(f"✈️ [Telegram]({lead['telegram_url']})")
|
||||
# Доп. телефоны (подтверждённые — с Я.Карт)
|
||||
phones = lead.get("phones")
|
||||
if isinstance(phones, list) and len(phones) > 1:
|
||||
st.caption("Доп. телефоны: " + ", ".join(phones[1:]))
|
||||
# Телефоны, найденные на САЙТЕ — НЕ подтверждены (могут быть чужие).
|
||||
extra = lead.get("phones_extra")
|
||||
if isinstance(extra, list) and extra:
|
||||
st.caption("⚠️ С сайта (проверить): " + ", ".join(extra))
|
||||
|
||||
with c2:
|
||||
st.markdown("**🏢 Бизнес**")
|
||||
st.markdown(f"📂 {lead.get('category') or '—'}")
|
||||
avg = lead.get("reviews_avg") or 0
|
||||
cnt = lead.get("reviews_count") or 0
|
||||
st.markdown(f"⭐ {avg:.1f} ({cnt} отзывов)")
|
||||
if lead.get("director_name"):
|
||||
st.markdown(f"👤 {lead['director_name']}")
|
||||
if lead.get("inn"):
|
||||
st.markdown(f"🏛️ ИНН `{lead['inn']}`")
|
||||
if lead.get("registration_date"):
|
||||
st.markdown(f"📅 Зарег.: {lead['registration_date']}")
|
||||
if lead.get("employee_count") is not None:
|
||||
st.markdown(f"👥 Сотрудников: {lead['employee_count']}")
|
||||
if lead.get("revenue") is not None:
|
||||
yr = f" ({lead['finance_year']})" if lead.get("finance_year") else ""
|
||||
st.markdown(f"💰 Оборот: {int(lead['revenue']):,} ₽{yr}".replace(",", " "))
|
||||
if lead.get("address"):
|
||||
st.caption(lead["address"])
|
||||
|
||||
with c3:
|
||||
st.markdown("**🎯 Статус и оценка**")
|
||||
score_val = lead.get("score", 0) or 0
|
||||
st.markdown(f"### Score: **{score_val}** / 10 · {fmt_band(lead.get('band'))}")
|
||||
cov = lead.get("diagnostic_coverage")
|
||||
if cov is not None and cov < config.MIN_COVERAGE:
|
||||
st.warning(f"⚠️ Низкая полнота диагностики ({cov:.0%}) — лиду нужно обогащение")
|
||||
pains = _parse_pain(lead.get("pain_products"))
|
||||
if pains:
|
||||
st.markdown("**С чем заходить:**")
|
||||
for code, sev in pains:
|
||||
st.markdown(f"- **{code}** {PRODUCT_LABELS.get(code, '')} · _{sev:.1f}_")
|
||||
st.markdown(f"📍 {lead.get('city') or '—'} / {lead.get('district') or '—'}")
|
||||
st.markdown(f"📊 {fmt_status(lead.get('outreach_status'))}")
|
||||
if lead.get("last_touched_at"):
|
||||
st.markdown(f"📅 Последнее касание: `{lead['last_touched_at'][:16]}`")
|
||||
if lead.get("last_action"):
|
||||
st.markdown(
|
||||
f"🎬 {fmt_channel(lead['last_action'])} → {fmt_reaction(lead.get('last_reaction'))}"
|
||||
)
|
||||
breakdown = lead.get("score_breakdown")
|
||||
if isinstance(breakdown, dict) and breakdown.get("reasons"):
|
||||
with st.expander("Почему такой score"):
|
||||
st.caption("Найденные проблемы:")
|
||||
for r in breakdown["reasons"]:
|
||||
st.text(f"• {r}")
|
||||
themes = breakdown.get("themes") or {}
|
||||
if themes:
|
||||
st.caption("Боль по темам: " + ", ".join(f"{t}={v}" for t, v in themes.items()))
|
||||
st.caption(
|
||||
f"ICP-fit ×{breakdown.get('icp_fit')} · raw={breakdown.get('pain_raw')} "
|
||||
f"· coverage={breakdown.get('coverage')}"
|
||||
)
|
||||
|
||||
st.divider()
|
||||
|
||||
# ─── Форма записи нового касания ─────────────────────────────────
|
||||
st.markdown("### ➕ Записать касание")
|
||||
with st.form(f"touch_form_{lead_id}", clear_on_submit=True):
|
||||
fc1, fc2 = st.columns(2)
|
||||
with fc1:
|
||||
channel = st.selectbox(
|
||||
"Действие",
|
||||
options=list(CHANNEL_LABELS.keys()),
|
||||
format_func=fmt_channel,
|
||||
key=f"channel_{lead_id}",
|
||||
)
|
||||
with fc2:
|
||||
reaction = st.selectbox(
|
||||
"Реакция",
|
||||
options=list(REACTION_LABELS.keys()),
|
||||
format_func=fmt_reaction,
|
||||
key=f"reaction_{lead_id}",
|
||||
)
|
||||
|
||||
notes = st.text_area(
|
||||
"Комментарий",
|
||||
placeholder="Что узнал, что договорились…",
|
||||
key=f"notes_{lead_id}",
|
||||
)
|
||||
new_status = st.selectbox(
|
||||
"Перевести лида в статус",
|
||||
options=["in_work", "done", "skip", "triaged", "inbox"],
|
||||
format_func=fmt_status,
|
||||
index=0,
|
||||
help="После касания обычно 'в работе'. Закрываешь — 'готово'. Не наш — 'пропустить'.",
|
||||
key=f"new_status_{lead_id}",
|
||||
)
|
||||
|
||||
submit = st.form_submit_button(
|
||||
"💾 Сохранить касание",
|
||||
type="primary",
|
||||
width="stretch",
|
||||
)
|
||||
if submit:
|
||||
db_layer.record_touch(
|
||||
DB_PATH, lead_id, channel,
|
||||
reaction=reaction,
|
||||
notes=(notes.strip() or None) if notes else None,
|
||||
new_status=new_status,
|
||||
)
|
||||
st.success(f"Касание записано! Лид → {fmt_status(new_status)}")
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# ─── Свободные заметки ───────────────────────────────────────────
|
||||
st.markdown("### 📝 Заметки о лиде")
|
||||
current_comments = lead.get("comments") or ""
|
||||
new_comments = st.text_area(
|
||||
"Заметки",
|
||||
value=current_comments,
|
||||
height=100,
|
||||
key=f"comments_input_{lead_id}",
|
||||
label_visibility="collapsed",
|
||||
placeholder="Свободный текст: что важно помнить об этом лиде…",
|
||||
)
|
||||
if st.button(
|
||||
"💾 Сохранить заметку",
|
||||
key=f"save_comm_{lead_id}",
|
||||
disabled=(new_comments == current_comments),
|
||||
):
|
||||
db_layer.update_lead_comments(DB_PATH, lead_id, new_comments)
|
||||
st.success("Заметка сохранена")
|
||||
st.rerun()
|
||||
|
||||
st.divider()
|
||||
|
||||
# ─── История касаний ─────────────────────────────────────────────
|
||||
st.markdown("### 📜 История касаний")
|
||||
history = db_layer.get_outreach_history(DB_PATH, lead_id)
|
||||
if not history:
|
||||
st.info("Касаний ещё не было. Запиши первое выше ⬆️")
|
||||
else:
|
||||
for ev in history:
|
||||
ch = fmt_channel(ev.get("channel"))
|
||||
re_ = fmt_reaction(ev.get("reaction"))
|
||||
ts = (ev.get("sent_at") or "")[:16]
|
||||
title = f"{ch} → {re_} • {ts}"
|
||||
with st.expander(title, expanded=False):
|
||||
if ev.get("notes"):
|
||||
st.write(ev["notes"])
|
||||
else:
|
||||
st.caption("Без комментария")
|
||||
if ev.get("message_text"):
|
||||
st.markdown("**Сообщение:**")
|
||||
st.code(ev["message_text"])
|
||||
|
||||
# ─── 🗑 Удаление компании ────────────────────────────────────────
|
||||
st.divider()
|
||||
with st.expander("🗑 Удалить эту компанию"):
|
||||
st.warning("Удаление необратимо — лид и вся его история касаний будут стёрты.")
|
||||
confirm = st.checkbox("Да, удалить безвозвратно", key=f"del_confirm_{lead_id}")
|
||||
if st.button(
|
||||
"🗑 Удалить компанию",
|
||||
type="primary",
|
||||
disabled=not confirm,
|
||||
key=f"del_btn_{lead_id}",
|
||||
):
|
||||
db_layer.delete_lead(DB_PATH, lead_id)
|
||||
st.success("Компания удалена.")
|
||||
st.rerun()
|
||||
|
||||
|
||||
# ─── Главная страница ───────────────────────────────────────────────
|
||||
def main():
|
||||
st.title("🎯 CRM — Парсер лидов 44AS")
|
||||
|
||||
# ── Сайдбар: фильтры ────────────────────────────────────────────
|
||||
st.sidebar.title("🔍 Фильтры")
|
||||
|
||||
hot_label = f"🔥 Hot (≥{config.HOT_LEAD_THRESHOLD})"
|
||||
preset = st.sidebar.radio(
|
||||
"Быстрый фильтр",
|
||||
options=[
|
||||
"📥 Inbox",
|
||||
hot_label,
|
||||
"⚙️ В работе",
|
||||
"✅ Готовые",
|
||||
"🚫 Пропущенные",
|
||||
"📋 Все",
|
||||
],
|
||||
index=0,
|
||||
)
|
||||
preset_map = {
|
||||
"📥 Inbox": {"statuses": ["inbox"], "min_score": 0},
|
||||
hot_label: {"statuses": [], "min_score": config.HOT_LEAD_THRESHOLD},
|
||||
"⚙️ В работе": {"statuses": ["in_work", "triaged"], "min_score": 0},
|
||||
"✅ Готовые": {"statuses": ["done"], "min_score": 0},
|
||||
"🚫 Пропущенные": {"statuses": ["skip"], "min_score": 0},
|
||||
"📋 Все": {"statuses": [], "min_score": 0},
|
||||
}
|
||||
preset_cfg = preset_map[preset]
|
||||
|
||||
st.sidebar.divider()
|
||||
|
||||
sources = st.sidebar.multiselect(
|
||||
"Источник",
|
||||
options=db_layer.get_all_sources(DB_PATH),
|
||||
placeholder="Любой",
|
||||
)
|
||||
regions = st.sidebar.multiselect(
|
||||
"Регион",
|
||||
options=db_layer.get_all_regions(DB_PATH),
|
||||
placeholder="Любой",
|
||||
)
|
||||
district_search = st.sidebar.text_input("Район содержит", "")
|
||||
categories = st.sidebar.multiselect(
|
||||
"Категория",
|
||||
options=db_layer.get_all_categories(DB_PATH),
|
||||
placeholder="Любая",
|
||||
)
|
||||
statuses = st.sidebar.multiselect(
|
||||
"Статус",
|
||||
options=["inbox", "triaged", "in_work", "done", "skip"],
|
||||
default=preset_cfg["statuses"],
|
||||
format_func=fmt_status,
|
||||
)
|
||||
min_score, max_score = st.sidebar.slider(
|
||||
"Диапазон score",
|
||||
0, 10, (preset_cfg["min_score"], 10),
|
||||
)
|
||||
name_search = st.sidebar.text_input("Поиск по имени", "")
|
||||
pain_filter = st.sidebar.multiselect(
|
||||
"Боль под продукт",
|
||||
options=list(PRODUCT_LABELS.keys()),
|
||||
format_func=lambda p: f"{p} {PRODUCT_LABELS.get(p, '')}",
|
||||
placeholder="Любой",
|
||||
help="Показать лидов, у которых есть решаемая нами боль под этот продукт",
|
||||
)
|
||||
|
||||
st.sidebar.divider()
|
||||
st.sidebar.caption(f"БД: `{DB_PATH.name}`")
|
||||
if st.sidebar.button("🔄 Обновить", width="stretch"):
|
||||
st.rerun()
|
||||
|
||||
# ── Метрики наверху ─────────────────────────────────────────────
|
||||
filters = {
|
||||
"sources": sources,
|
||||
"regions": regions,
|
||||
"district_search": district_search,
|
||||
"categories": categories,
|
||||
"statuses": statuses,
|
||||
"min_score": min_score,
|
||||
"max_score": max_score,
|
||||
"name_search": name_search,
|
||||
"pain_products": pain_filter,
|
||||
}
|
||||
df = db_layer.get_leads(DB_PATH, filters)
|
||||
|
||||
m1, m2, m3, m4, m5 = st.columns(5)
|
||||
m1.metric("Всего в БД", db_layer.count_total(DB_PATH))
|
||||
m2.metric("📥 Inbox", db_layer.count_inbox(DB_PATH))
|
||||
m3.metric("⚙️ В работе", db_layer.count_in_work(DB_PATH))
|
||||
m4.metric("✅ Готовых", db_layer.count_done(DB_PATH))
|
||||
m5.metric("Под фильтр", len(df))
|
||||
|
||||
st.divider()
|
||||
|
||||
# ── ➕ Добавить компанию вручную ────────────────────────────────
|
||||
with st.expander("➕ Добавить компанию вручную"):
|
||||
cat_options = sorted(set(config.CATEGORIES) | set(db_layer.get_all_categories(DB_PATH)))
|
||||
city_options = list(config.CITIES.keys())
|
||||
with st.form("add_company_form", clear_on_submit=True):
|
||||
a1, a2, a3 = st.columns(3)
|
||||
with a1:
|
||||
f_name = st.text_input("Название *")
|
||||
f_phone = st.text_input("Телефон")
|
||||
f_email = st.text_input("Email")
|
||||
f_website = st.text_input("Сайт")
|
||||
with a2:
|
||||
f_category = st.selectbox("Категория", options=cat_options or ["—"]) # 📋 меню
|
||||
f_city = st.selectbox("Город", options=city_options) # 📋 меню
|
||||
f_district = st.text_input("Район")
|
||||
f_address = st.text_input("Адрес")
|
||||
with a3:
|
||||
f_inn = st.text_input("ИНН")
|
||||
f_director = st.text_input("Директор (ФИО)")
|
||||
f_source = st.selectbox("Источник", # 📋 меню
|
||||
options=["manual", "yandex_maps", "hh", "2gis", "vk"])
|
||||
f_status = st.selectbox("Статус", options=list(STATUS_LABELS.keys()), # 📋 меню
|
||||
format_func=fmt_status)
|
||||
add_submit = st.form_submit_button("💾 Добавить компанию", type="primary", width="stretch")
|
||||
if add_submit:
|
||||
if not f_name.strip():
|
||||
st.error("Название обязательно.")
|
||||
else:
|
||||
data = {
|
||||
"name": f_name.strip(),
|
||||
"phones": [f_phone.strip()] if f_phone.strip() else [],
|
||||
"phone_primary": f_phone.strip() or None,
|
||||
"emails": [f_email.strip()] if f_email.strip() else [],
|
||||
"email_primary": f_email.strip() or None,
|
||||
"website": f_website.strip() or None,
|
||||
"category": f_category,
|
||||
"city": f_city,
|
||||
"region": config.CITIES.get(f_city, {}).get("region", f_city),
|
||||
"district": f_district.strip() or None,
|
||||
"address": f_address.strip() or None,
|
||||
"inn": f_inn.strip() or None,
|
||||
"director_name": f_director.strip() or None,
|
||||
"source": f_source,
|
||||
"outreach_status": f_status,
|
||||
}
|
||||
try:
|
||||
new_id = db_layer.add_lead_manual(DB_PATH, data)
|
||||
st.success(f"✅ Добавлена: {f_name.strip()} (#{new_id})")
|
||||
st.rerun()
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
|
||||
# ── Таблица ─────────────────────────────────────────────────────
|
||||
if len(df) == 0:
|
||||
st.info(
|
||||
"Под текущие фильтры лидов нет. "
|
||||
"Ослабь критерии в левой панели или выбери другой быстрый фильтр сверху."
|
||||
)
|
||||
return
|
||||
|
||||
st.markdown(f"### 📋 Лиды ({len(df)})")
|
||||
st.caption("👆 Кликни на строку — внизу раскроется детальная карточка лида.")
|
||||
|
||||
# Подготавливаем display
|
||||
display = df.copy()
|
||||
display["status_view"] = display["outreach_status"].apply(fmt_status)
|
||||
display["source_view"] = display["source"].apply(fmt_source)
|
||||
display["last_action_view"] = display["last_action"].apply(fmt_channel)
|
||||
display["last_touched_short"] = (
|
||||
display["last_touched_at"].fillna("").astype(str).str.slice(0, 16)
|
||||
)
|
||||
display["band_view"] = display["band"].apply(fmt_band)
|
||||
display["pain_view"] = display["pain_products"].apply(fmt_pain_products)
|
||||
|
||||
show_cols = {
|
||||
"id": "ID",
|
||||
"name": "Имя",
|
||||
"source_view": "Источник",
|
||||
"category": "Категория",
|
||||
"city": "Город",
|
||||
"district": "Район",
|
||||
"score": "Score",
|
||||
"band_view": "Бэнд",
|
||||
"pain_view": "С чем заходить",
|
||||
"status_view": "Статус",
|
||||
"phone_primary": "Телефон",
|
||||
"email_primary": "Email",
|
||||
"last_action_view": "Последнее действие",
|
||||
"last_touched_short": "Касались",
|
||||
}
|
||||
show_df = display[list(show_cols.keys())].rename(columns=show_cols)
|
||||
# NaN/None → пустая строка, чтобы в ячейках не было слова "None"
|
||||
show_df = show_df.fillna("")
|
||||
|
||||
event = st.dataframe(
|
||||
show_df,
|
||||
width="stretch",
|
||||
hide_index=True,
|
||||
on_select="rerun",
|
||||
selection_mode="single-row",
|
||||
column_config={
|
||||
"Score": st.column_config.NumberColumn(format="%d"),
|
||||
},
|
||||
)
|
||||
|
||||
# ── Деталь выбранного лида ──────────────────────────────────────
|
||||
sel = event.selection.rows if event and event.selection else []
|
||||
if sel:
|
||||
st.divider()
|
||||
selected_lead_id = int(df.iloc[sel[0]]["id"])
|
||||
render_lead_detail(selected_lead_id)
|
||||
|
||||
|
||||
# Streamlit запускает скрипт целиком на каждое действие — без if __name__
|
||||
main()
|
||||
Reference in New Issue
Block a user