add anal4

This commit is contained in:
lekss361 2026-04-27 19:45:47 +03:00
parent 522ecca5c4
commit 5056438fcf
13 changed files with 1261 additions and 266 deletions

View file

@ -101,6 +101,11 @@ jobs:
docker compose -f docker-compose.prod.yml exec -T caddy \
caddy reload --config /etc/caddy/Caddyfile || true
docker image prune -f
# Clean up disk: dangling layers + tagged images older than 72h that
# nothing is using anymore. Plus build cache (we never build on prod
# but old buildkit caches may linger from earlier compose builds).
docker image prune -af --filter "until=72h" || true
docker builder prune -af || true
sleep 8
curl -fsS http://localhost:8000/health | head -c 200 || true

View file

@ -0,0 +1,253 @@
"""Admin endpoint for browsing PRINZIP CRM leads.
GET /api/v1/admin/leads list with filters + pagination
GET /api/v1/admin/leads/stats KPI summary (total/conversion/revenue)
Auth: same X-Admin-Token as /admin/scrape (settings.scrape_admin_token).
PII compliance: only phone_last4 and email_hash exposed; raw phone/email
were never persisted (см. data/sql/52_import_prinzip_crm.py).
"""
from __future__ import annotations
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import get_db
from app.services import analytics_queries as q
router = APIRouter()
def _check_token(x_admin_token: str | None) -> None:
if not settings.scrape_admin_token:
raise HTTPException(
status_code=503,
detail="admin disabled — set SCRAPE_ADMIN_TOKEN",
)
if x_admin_token != settings.scrape_admin_token:
raise HTTPException(status_code=401, detail="invalid admin token")
@router.get("/")
def list_leads(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
status: Annotated[str | None, Query()] = None,
source: Annotated[str | None, Query()] = None,
converted: Annotated[bool | None, Query()] = None,
obj_id: Annotated[int | None, Query()] = None,
search: Annotated[str | None, Query(min_length=2, max_length=64)] = None,
date_from: Annotated[str | None, Query(description="YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(description="YYYY-MM-DD")] = None,
sort: Annotated[Literal["created_desc", "created_asc"], Query()] = "created_desc",
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> dict[str, Any]:
_check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": limit, "off": offset}
if status:
where.append("l.status = :status")
params["status"] = status
if source:
where.append("l.source = :source")
params["source"] = source
if converted is not None:
where.append("l.converted = :converted")
params["converted"] = converted
if obj_id is not None:
where.append("l.obj_interest = :obj_id")
params["obj_id"] = obj_id
if date_from:
where.append("l.created_at >= :date_from")
params["date_from"] = date_from
if date_to:
where.append("l.created_at < (:date_to::date + INTERVAL '1 day')")
params["date_to"] = date_to
if search:
where.append("(l.name ILIKE :search OR l.phone_last4 = :exact_search)")
params["search"] = f"%{search}%"
params["exact_search"] = search
where_sql = "WHERE " + " AND ".join(where) if where else ""
order_sql = "ORDER BY l.created_at DESC" if sort == "created_desc" else "ORDER BY l.created_at"
rows = (
db.execute(
text(
f"""
SELECT l.lead_id, l.created_at, l.source, l.channel, l.name,
l.phone_last4, l.email_hash, l.obj_interest, l.rooms,
l.budget_min, l.budget_max, l.status, l.converted, l.deal_id,
o.comm_name AS obj_name,
d.deal_price, d.closed_at AS deal_closed_at
FROM prinzip_leads l
LEFT JOIN domrf_kn_objects o
ON o.obj_id = l.obj_interest
AND o.snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_objects)
LEFT JOIN prinzip_deals d ON d.deal_id = l.deal_id
{where_sql}
{order_sql}
LIMIT :lim OFFSET :off
"""
),
params,
)
.mappings()
.all()
)
total = db.execute(
text(f"SELECT COUNT(*) FROM prinzip_leads l {where_sql}"),
{k: v for k, v in params.items() if k not in ("lim", "off")},
).scalar_one()
return {
"total": int(total or 0),
"limit": limit,
"offset": offset,
"rows": [
{
"lead_id": r["lead_id"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"source": r["source"],
"channel": r["channel"],
"name": r["name"],
"phone_last4": r["phone_last4"],
"email_hash": r["email_hash"],
"obj_interest": r["obj_interest"],
"obj_name": r["obj_name"],
"rooms": r["rooms"],
"budget_min": float(r["budget_min"]) if r["budget_min"] is not None else None,
"budget_max": float(r["budget_max"]) if r["budget_max"] is not None else None,
"status": r["status"],
"converted": r["converted"],
"deal_id": r["deal_id"],
"deal_price": float(r["deal_price"]) if r["deal_price"] is not None else None,
"deal_closed_at": (
r["deal_closed_at"].isoformat() if r["deal_closed_at"] else None
),
}
for r in rows
],
}
@router.get("/stats")
def leads_stats(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> dict[str, Any]:
"""KPI summary за последние N месяцев."""
_check_token(x_admin_token)
row = (
db.execute(
text(
"""
WITH window_leads AS (
SELECT *
FROM prinzip_leads
WHERE created_at >= NOW() - (:m || ' months')::interval
)
SELECT
(SELECT COUNT(*) FROM prinzip_leads) AS leads_total,
COUNT(*) FILTER (WHERE TRUE) AS leads_window,
COUNT(*) FILTER (WHERE converted) AS converted_window,
ROUND(
100.0 * COUNT(*) FILTER (WHERE converted) / NULLIF(COUNT(*), 0),
2
) AS conv_pct_window,
(SELECT COUNT(DISTINCT source) FROM prinzip_leads) AS sources_total,
(SELECT SUM(deal_price) FROM prinzip_deals) AS revenue_total,
(SELECT COUNT(*) FROM prinzip_deals) AS deals_total
FROM window_leads
"""
),
{"m": months},
)
.mappings()
.first()
)
if not row:
return {
"leads_total": 0,
"leads_window": 0,
"converted_window": 0,
"conv_pct_window": None,
"sources_total": 0,
"revenue_total": None,
"deals_total": 0,
}
return {
"leads_total": row["leads_total"] or 0,
"leads_window": row["leads_window"] or 0,
"converted_window": row["converted_window"] or 0,
"conv_pct_window": (
float(row["conv_pct_window"]) if row["conv_pct_window"] is not None else None
),
"sources_total": row["sources_total"] or 0,
"revenue_total": (
float(row["revenue_total"]) if row["revenue_total"] is not None else None
),
"deals_total": row["deals_total"] or 0,
"window_months": months,
}
@router.get("/funnel/monthly")
def funnel_monthly(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (по source)."""
_check_token(x_admin_token)
return q.prinzip_funnel_monthly(db, months=months)
@router.get("/funnel/by-source")
def funnel_by_source(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
_check_token(x_admin_token)
return q.prinzip_funnel_by_source(db, months=months)
@router.get("/funnel/by-object")
def funnel_by_object(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
_check_token(x_admin_token)
return q.prinzip_funnel_by_object(db)
@router.get("/sources")
def list_sources(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> list[str]:
"""Distinct list of source values for filter dropdown."""
_check_token(x_admin_token)
rows = db.execute(
text(
"""
SELECT source, COUNT(*) AS n
FROM prinzip_leads
WHERE source IS NOT NULL AND source <> ''
GROUP BY source
ORDER BY n DESC
LIMIT 50
"""
)
).all()
return [r[0] for r in rows]

View file

@ -178,27 +178,5 @@ def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, A
return q.prinzip_objects_with_velocity(db)
@router.get("/prinzip/funnel/monthly")
def prinzip_funnel_monthly(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 24,
) -> list[dict[str, Any]]:
"""Воронка по месяцам: leads → engaged → converted (с разбивкой по source)."""
return q.prinzip_funnel_monthly(db, months=months)
@router.get("/prinzip/funnel/by-source")
def prinzip_funnel_by_source(
db: Annotated[Session, Depends(get_db)],
months: Annotated[int, Query(ge=1, le=120)] = 12,
) -> list[dict[str, Any]]:
"""Splitting по source: кто конвертит лучше за последние N месяцев."""
return q.prinzip_funnel_by_source(db, months=months)
@router.get("/prinzip/funnel/by-object")
def prinzip_funnel_by_object(
db: Annotated[Session, Depends(get_db)],
) -> list[dict[str, Any]]:
"""Воронка для каждого ЖК PRINZIP: leads / deals / revenue."""
return q.prinzip_funnel_by_object(db)
# Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py:
# /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token.

View file

@ -5,7 +5,7 @@ import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import admin_scrape, analytics, concepts, parcels
from app.api.v1 import admin_leads, admin_scrape, analytics, concepts, parcels
from app.core.config import settings
@ -34,6 +34,7 @@ app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"]
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
app.include_router(admin_scrape.router, prefix="/api/v1/admin/scrape", tags=["admin"])
app.include_router(admin_leads.router, prefix="/api/v1/admin/leads", tags=["admin"])
@app.get("/health")

View file

@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{ href: "/admin/scrape", label: "Скрапер" },
{ href: "/admin/leads", label: "Лиды PRINZIP" },
];
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<div style={{ background: "#f6f7f9", minHeight: "100vh" }}>
<div
style={{ maxWidth: 1280, margin: "0 auto", padding: "20px 24px 40px" }}
>
<header style={{ marginBottom: 16 }}>
<Link
href="/"
style={{ fontSize: 13, color: "#5b6066", textDecoration: "none" }}
>
GenDesign
</Link>
<h1 style={{ margin: "4px 0 12px", fontSize: 22 }}>Админка</h1>
<nav
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid #e6e8ec",
}}
>
{TABS.map((t) => {
const active =
pathname === t.href || pathname?.startsWith(t.href + "/");
return (
<Link
key={t.href}
href={t.href}
style={{
padding: "10px 14px",
borderBottom: active
? "2px solid #1d4ed8"
: "2px solid transparent",
color: active ? "#1d4ed8" : "#374151",
fontWeight: active ? 600 : 500,
textDecoration: "none",
fontSize: 14,
}}
>
{t.label}
</Link>
);
})}
</nav>
</header>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,754 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false });
interface FunnelMonthRow {
month: string | null;
source: string;
leads: number;
engaged: number;
converted: number;
conv_pct: number | null;
}
interface FunnelSourceRow {
source: string;
leads: number;
engaged: number;
converted: number;
conv_pct: number | null;
}
interface LeadRow {
lead_id: number;
created_at: string | null;
source: string | null;
channel: string | null;
name: string | null;
phone_last4: string | null;
email_hash: string | null;
obj_interest: number | null;
obj_name: string | null;
rooms: number | null;
budget_min: number | null;
budget_max: number | null;
status: string | null;
converted: boolean | null;
deal_id: number | null;
deal_price: number | null;
deal_closed_at: string | null;
}
interface LeadsResponse {
total: number;
limit: number;
offset: number;
rows: LeadRow[];
}
interface LeadsStats {
leads_total: number;
leads_window: number;
converted_window: number;
conv_pct_window: number | null;
sources_total: number;
revenue_total: number | null;
deals_total: number;
window_months: number;
}
export default function AdminLeadsPage() {
const [token] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [statusFilter, setStatusFilter] = useState<string>("");
const [sourceFilter, setSourceFilter] = useState<string>("");
const [convertedFilter, setConvertedFilter] = useState<string>("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const limit = 50;
const stats = useQuery({
queryKey: ["admin-leads-stats", token],
queryFn: () =>
apiFetch<LeadsStats>("/api/v1/admin/leads/stats?months=12", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
});
const funnelBySource = useQuery({
queryKey: ["admin-leads-funnel-source", token],
queryFn: () =>
apiFetch<FunnelSourceRow[]>(
"/api/v1/admin/leads/funnel/by-source?months=12",
{
headers: { "X-Admin-Token": token },
},
),
enabled: !!token,
});
const funnelMonthly = useQuery({
queryKey: ["admin-leads-funnel-monthly", token],
queryFn: () =>
apiFetch<FunnelMonthRow[]>(
"/api/v1/admin/leads/funnel/monthly?months=24",
{
headers: { "X-Admin-Token": token },
},
),
enabled: !!token,
});
const sankeyOption = useMemo(() => {
const rows = funnelBySource.data ?? [];
if (rows.length === 0) {
return {
title: {
text: "Нет CRM-данных (импортируй prinzip_crm.sqlite)",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontSize: 13, fontWeight: 400 },
},
};
}
const nodes = [
{ name: "Все заявки" },
{ name: "Engaged" },
{ name: "Converted (deals)" },
...rows.map((r) => ({ name: `📥 ${r.source}` })),
];
const links = [
...rows.map((r) => ({
source: `📥 ${r.source}`,
target: "Все заявки",
value: r.leads,
})),
{
source: "Все заявки",
target: "Engaged",
value: rows.reduce((s, r) => s + r.engaged, 0),
},
{
source: "Engaged",
target: "Converted (deals)",
value: rows.reduce((s, r) => s + r.converted, 0),
},
];
return {
tooltip: { trigger: "item" },
series: [
{
type: "sankey",
data: nodes,
links,
label: { fontSize: 12 },
lineStyle: { color: "gradient", curveness: 0.5 },
emphasis: { focus: "adjacency" },
},
],
};
}, [funnelBySource.data]);
const monthlyOption = useMemo(() => {
const rows = funnelMonthly.data ?? [];
if (rows.length === 0) {
return {
title: {
text: "Нет CRM-данных",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontSize: 13, fontWeight: 400 },
},
};
}
const byMonth: Record<
string,
{ leads: number; engaged: number; converted: number }
> = {};
for (const r of rows) {
const m = (r.month ?? "").slice(0, 7);
if (!byMonth[m]) byMonth[m] = { leads: 0, engaged: 0, converted: 0 };
byMonth[m].leads += r.leads;
byMonth[m].engaged += r.engaged;
byMonth[m].converted += r.converted;
}
const months = Object.keys(byMonth).sort();
return {
tooltip: { trigger: "axis" },
legend: { data: ["Заявки", "Engaged", "Сделки", "Conversion %"] },
grid: { left: 56, right: 64, top: 40, bottom: 36 },
xAxis: { type: "category", data: months },
yAxis: [
{ type: "value", name: "шт", position: "left" },
{
type: "value",
name: "%",
position: "right",
axisLabel: { formatter: "{value}%" },
},
],
series: [
{
name: "Заявки",
type: "bar",
data: months.map((m) => byMonth[m].leads),
itemStyle: { color: "#9ca3af" },
},
{
name: "Engaged",
type: "bar",
data: months.map((m) => byMonth[m].engaged),
itemStyle: { color: "#3b82f6" },
},
{
name: "Сделки",
type: "bar",
data: months.map((m) => byMonth[m].converted),
itemStyle: { color: "#0a7a3a" },
},
{
name: "Conversion %",
type: "line",
yAxisIndex: 1,
smooth: true,
data: months.map((m) =>
byMonth[m].leads > 0
? Math.round((byMonth[m].converted / byMonth[m].leads) * 1000) /
10
: null,
),
lineStyle: { color: "#c2410c", width: 2 },
itemStyle: { color: "#c2410c" },
},
],
};
}, [funnelMonthly.data]);
const sources = useQuery({
queryKey: ["admin-leads-sources", token],
queryFn: () =>
apiFetch<string[]>("/api/v1/admin/leads/sources", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
});
const leads = useQuery({
queryKey: [
"admin-leads",
token,
statusFilter,
sourceFilter,
convertedFilter,
search,
page,
],
queryFn: () => {
const qs = new URLSearchParams();
qs.set("limit", String(limit));
qs.set("offset", String(page * limit));
if (statusFilter) qs.set("status", statusFilter);
if (sourceFilter) qs.set("source", sourceFilter);
if (convertedFilter !== "") qs.set("converted", convertedFilter);
if (search) qs.set("search", search);
return apiFetch<LeadsResponse>(`/api/v1/admin/leads/?${qs.toString()}`, {
headers: { "X-Admin-Token": token },
});
},
enabled: !!token,
});
const fmtMoney = (v: number | null) =>
v == null
? "—"
: v >= 1e6
? `${(v / 1e6).toFixed(2)} М`
: v.toLocaleString("ru");
if (!token) {
return (
<p style={{ color: "#9a6700", fontSize: 13 }}>
Введите Admin Token на странице{" "}
<Link href="/admin/scrape" style={{ color: "#1d4ed8" }}>
Скрапер
</Link>{" "}
он сохранится для всех админ-страниц.
</p>
);
}
if (stats.isError || leads.isError) {
const err = String(stats.error ?? leads.error ?? "");
return (
<div
style={{
padding: 16,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
}}
>
Ошибка: {err}
<div style={{ marginTop: 8, fontSize: 12 }}>
Если БД пустая это нормально. Импортируй CRM:{" "}
<code>python data/sql/52_import_prinzip_crm.py</code>
</div>
</div>
);
}
return (
<>
<h2 style={{ marginTop: 0, fontSize: 18 }}>Лиды PRINZIP CRM воронка</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Заявки и сделки из <code>analytics.prinzipevent.ru</code>. PII
(телефон/email) хешированы при импорте для UI только последние 4 цифры
телефона.
</p>
{/* Funnel charts */}
<section
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<div style={cardStyle}>
<h3 style={{ margin: "0 0 8px", fontSize: 14, fontWeight: 600 }}>
Воронка CRM (по source)
</h3>
<p style={{ margin: "0 0 8px", fontSize: 11, color: "#73767e" }}>
Sankey leads engaged converted из prinzip_leads.
</p>
<div style={{ height: 320 }}>
<ReactECharts
option={sankeyOption}
style={{ height: "100%", width: "100%" }}
notMerge
/>
</div>
</div>
<div style={cardStyle}>
<h3 style={{ margin: "0 0 8px", fontSize: 14, fontWeight: 600 }}>
Конверсия по месяцам
</h3>
<p style={{ margin: "0 0 8px", fontSize: 11, color: "#73767e" }}>
Заявки + Engaged + Сделки + Conversion %.
</p>
<div style={{ height: 320 }}>
<ReactECharts
option={monthlyOption}
style={{ height: "100%", width: "100%" }}
notMerge
/>
</div>
</div>
</section>
{/* KPI cards */}
<section
style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 16 }}
>
<Card label="Всего заявок" value={stats.data?.leads_total ?? "—"} />
<Card
label="За 12 мес"
value={stats.data?.leads_window ?? "—"}
hint={`${stats.data?.converted_window ?? 0} конверт.`}
/>
<Card
label="Conversion (12 мес)"
value={
stats.data?.conv_pct_window != null
? `${stats.data.conv_pct_window}%`
: "—"
}
/>
<Card
label="Revenue (всего)"
value={fmtMoney(stats.data?.revenue_total ?? null)}
hint={`${stats.data?.deals_total ?? 0} сделок`}
/>
<Card label="Источников" value={stats.data?.sources_total ?? "—"} />
</section>
{/* Filters */}
<section style={cardStyle}>
<div
style={{
display: "flex",
gap: 12,
flexWrap: "wrap",
alignItems: "flex-end",
}}
>
<Filter label="Поиск (имя или 4 посл. цифры)">
<input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(0);
}}
placeholder="Иван / 1234"
style={inputStyle}
/>
</Filter>
<Filter label="Статус">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
style={inputStyle}
>
<option value="">Все</option>
<option value="new">new</option>
<option value="contacted">contacted</option>
<option value="qualified">qualified</option>
<option value="lost">lost</option>
<option value="converted">converted</option>
</select>
</Filter>
<Filter label="Source">
<select
value={sourceFilter}
onChange={(e) => {
setSourceFilter(e.target.value);
setPage(0);
}}
style={inputStyle}
>
<option value="">Все</option>
{(sources.data ?? []).map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</Filter>
<Filter label="Converted">
<select
value={convertedFilter}
onChange={(e) => {
setConvertedFilter(e.target.value);
setPage(0);
}}
style={inputStyle}
>
<option value="">Все</option>
<option value="true">Да (есть сделка)</option>
<option value="false">Нет</option>
</select>
</Filter>
</div>
</section>
{/* Table */}
<section style={cardStyle}>
{leads.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p>
) : (leads.data?.rows.length ?? 0) === 0 ? (
<p style={{ color: "#5b6066" }}>
Лидов не найдено. Если БД пустая импортируй данные{" "}
<code>data/sql/52_import_prinzip_crm.py</code>.
</p>
) : (
<>
<div
style={{
marginBottom: 12,
fontSize: 12,
color: "#5b6066",
display: "flex",
justifyContent: "space-between",
}}
>
<span>
Показано {leads.data?.rows.length ?? 0} из{" "}
<strong>{leads.data?.total.toLocaleString("ru") ?? 0}</strong>
</span>
<span>
Стр. {page + 1} из{" "}
{Math.max(1, Math.ceil((leads.data?.total ?? 0) / limit))}
</span>
</div>
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"Когда",
"Имя",
"Тел.",
"Source",
"ЖК",
"К-т",
"Бюджет",
"Статус",
"Сделка",
].map((h) => (
<th key={h} style={th}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(leads.data?.rows ?? []).map((r, i) => (
<tr
key={r.lead_id}
style={{
borderBottom: "1px solid #eef0f3",
background: i % 2 ? "#fafbfc" : "#fff",
}}
>
<td style={td}>
{r.created_at?.slice(0, 10) ?? "—"}
<div style={{ fontSize: 11, color: "#73767e" }}>
{r.created_at?.slice(11, 16) ?? ""}
</div>
</td>
<td style={td}>{r.name ?? "—"}</td>
<td style={{ ...td, fontFamily: "monospace" }}>
{r.phone_last4 ? `***${r.phone_last4}` : "—"}
</td>
<td style={td}>
<span
style={{
background: "#e0e7ff",
color: "#3730a3",
padding: "2px 6px",
borderRadius: 3,
fontSize: 11,
}}
>
{r.source ?? "—"}
</span>
</td>
<td style={td}>
{r.obj_interest ? (
<Link
href={`/analytics/objects/${r.obj_interest}`}
style={{
color: "#1d4ed8",
textDecoration: "none",
fontSize: 12,
}}
>
{r.obj_name ?? `obj ${r.obj_interest}`}
</Link>
) : (
"—"
)}
</td>
<td style={td}>{r.rooms ?? "—"}</td>
<td style={{ ...td, fontSize: 12 }}>
{r.budget_min || r.budget_max
? `${fmtMoney(r.budget_min)}${fmtMoney(r.budget_max)}`
: "—"}
</td>
<td style={td}>
<StatusBadge status={r.status} />
</td>
<td style={td}>
{r.converted ? (
<div>
<span style={{ color: "#0a7a3a", fontWeight: 600 }}>
</span>{" "}
{fmtMoney(r.deal_price)}
<div style={{ fontSize: 11, color: "#73767e" }}>
{r.deal_closed_at?.slice(0, 10) ?? ""}
</div>
</div>
) : (
<span style={{ color: "#9ca3af" }}></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div
style={{
display: "flex",
gap: 8,
marginTop: 16,
justifyContent: "center",
alignItems: "center",
}}
>
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
style={pageBtn}
>
Назад
</button>
<span style={{ fontSize: 13, color: "#5b6066" }}>
{page * limit + 1}
{Math.min((page + 1) * limit, leads.data?.total ?? 0)}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * limit >= (leads.data?.total ?? 0)}
style={pageBtn}
>
Дальше
</button>
</div>
</>
)}
</section>
</>
);
}
function Card({
label,
value,
hint,
}: {
label: string;
value: string | number;
hint?: string;
}) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "16px 18px",
flex: 1,
minWidth: 160,
}}
>
<div
style={{
fontSize: 12,
color: "#5b6066",
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{label}
</div>
<div style={{ marginTop: 6, fontSize: 24, fontWeight: 600 }}>{value}</div>
{hint ? (
<div style={{ fontSize: 12, color: "#73767e", marginTop: 4 }}>
{hint}
</div>
) : null}
</div>
);
}
function Filter({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span
style={{
fontSize: 11,
color: "#5b6066",
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{label}
</span>
{children}
</label>
);
}
function StatusBadge({ status }: { status: string | null }) {
const colors: Record<string, { bg: string; fg: string }> = {
new: { bg: "#e0e7ff", fg: "#3730a3" },
contacted: { bg: "#fef3c7", fg: "#92400e" },
qualified: { bg: "#dbeafe", fg: "#1e40af" },
lost: { bg: "#fee2e2", fg: "#991b1b" },
converted: { bg: "#dcfce7", fg: "#065f46" },
};
const c = colors[status ?? ""] ?? { bg: "#f3f4f6", fg: "#374151" };
return (
<span
style={{
background: c.bg,
color: c.fg,
fontSize: 11,
padding: "3px 8px",
borderRadius: 3,
fontWeight: 600,
}}
>
{status ?? "—"}
</span>
);
}
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
marginTop: 16,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
minWidth: 160,
boxSizing: "border-box" as const,
};
const th = {
padding: "10px 12px",
textAlign: "left" as const,
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
color: "#374151",
};
const td = {
padding: "10px 12px",
verticalAlign: "top" as const,
};
const pageBtn = {
padding: "8px 14px",
background: "#f3f4f6",
color: "#1f2937",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
};

View file

@ -155,9 +155,11 @@ export default function ScrapeAdminPage() {
};
return (
<main style={{ padding: 24, maxWidth: 1100, margin: "0 auto" }}>
<h1 style={{ marginTop: 0 }}>Скрапер наш.дом.рф ручной запуск</h1>
<p style={{ color: "#5b6066", fontSize: 13 }}>
<>
<h2 style={{ marginTop: 0, fontSize: 18 }}>
Скрапер наш.дом.рф ручной запуск
</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Запуск sweep-а вне расписания (расписание: см. SCRAPE_KN_CRON env, по
умолчанию пн 04:15 МСК с jitter 030мин).
</p>
@ -722,7 +724,7 @@ export default function ScrapeAdminPage() {
</table>
)}
</section>
</main>
</>
);
}

View file

@ -5,8 +5,6 @@ import { useState } from "react";
import { InsightCards } from "@/components/analytics/InsightCards";
import { KpiCard } from "@/components/analytics/KpiCard";
import { PrinzipDistrictsBar } from "@/components/analytics/PrinzipDistrictsBar";
import { PrinzipFunnelMonthly } from "@/components/analytics/PrinzipFunnelMonthly";
import { PrinzipFunnelSankey } from "@/components/analytics/PrinzipFunnelSankey";
import { PrinzipGapBar } from "@/components/analytics/PrinzipGapBar";
import { PrinzipObjectsTable } from "@/components/analytics/PrinzipObjectsTable";
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
@ -136,28 +134,6 @@ export default function PrinzipPage() {
<PrinzipDistrictsBar />
</Section>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<Section
title="Воронка CRM (по source)"
subtitle="Sankey leads → engaged → converted из prinzip_leads. Импорт SQLite — см. data/sql/52_import_prinzip_crm.py."
>
<PrinzipFunnelSankey months={12} />
</Section>
<Section
title="Конверсия по месяцам"
subtitle="Заявки + Engaged + Сделки (бары) + Conversion % (линия)."
>
<PrinzipFunnelMonthly months={24} />
</Section>
</div>
<Section
title="ЖК PRINZIP — таблица с velocity"
subtitle="28 объектов по объёму квартир. Sparkline — динамика реализованных квартир по месяцам (apartments). Клик по «Drill-in» открывает страницу ЖК с галереей и POI."

View file

@ -1,25 +1,178 @@
import Link from "next/link";
interface Route {
href: string;
label: string;
desc: string;
}
const SECTIONS: { title: string; routes: Route[] }[] = [
{
title: "Аналитика",
routes: [
{
href: "/analytics",
label: "Свердл рынок",
desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.",
},
{
href: "/analytics/prinzip",
label: "PRINZIP drill-down",
desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.",
},
{
href: "/analytics/developers",
label: "Топ девелоперов",
desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.",
},
{
href: "/analytics/objects/60160",
label: "Карточка ЖК (пример: Парк Победы)",
desc: "KPI · sale-chart · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
},
],
},
{
title: "Админка (Token-protected)",
routes: [
{
href: "/admin/scrape",
label: "Скрапер наш.дом.рф",
desc: "Ручной запуск · очередь Celery · журнал прогонов и ошибок · revoke task.",
},
{
href: "/admin/leads",
label: "Лиды PRINZIP CRM",
desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.",
},
],
},
{
title: "Generative",
routes: [
{
href: "/concept",
label: "Concept (Stage 1)",
desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.",
},
{
href: "/site-finder",
label: "Site Finder (Stage 2)",
desc: "AI-подбор инвестиционных земельных участков. WIP.",
},
],
},
];
export default function HomePage() {
return (
<main style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
<h1>GenDesign</h1>
<p>Generative Design + Site Finder Discovery MVP</p>
<ul>
<li>
<Link href="/concept">Concept (Generative Design)</Link>
</li>
<li>
<Link href="/site-finder">Site Finder</Link>
</li>
<li>
<Link href="/analytics">Аналитика Свердл рынок &amp; PRINZIP</Link>
</li>
</ul>
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
<p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}>
антон ты пидр
</p>
<main
style={{
padding: 24,
maxWidth: 980,
margin: "0 auto",
fontFamily:
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
}}
>
<header style={{ marginBottom: 24 }}>
<h1 style={{ margin: 0, fontSize: 28 }}>GenDesign</h1>
<p style={{ margin: "6px 0 0", color: "#5b6066", fontSize: 14 }}>
Generative Design + Site Finder Discovery MVP. Свердл рынок &amp;
PRINZIP.
</p>
</header>
{SECTIONS.map((section) => (
<section key={section.title} style={{ marginTop: 24 }}>
<h2
style={{
fontSize: 13,
textTransform: "uppercase",
letterSpacing: 0.6,
color: "#5b6066",
margin: "0 0 8px",
}}
>
{section.title}
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 12,
}}
>
{section.routes.map((r) => (
<Link
key={r.href}
href={r.href}
style={{
display: "block",
padding: "14px 16px",
border: "1px solid #e6e8ec",
borderRadius: 12,
background: "#fff",
textDecoration: "none",
color: "#1f2937",
transition: "all 0.15s",
}}
>
<div
style={{
fontFamily: "monospace",
fontSize: 12,
color: "#1d4ed8",
marginBottom: 4,
}}
>
{r.href}
</div>
<div style={{ fontWeight: 600, fontSize: 15 }}>{r.label}</div>
<div
style={{
fontSize: 12,
color: "#5b6066",
marginTop: 6,
lineHeight: 1.4,
}}
>
{r.desc}
</div>
</Link>
))}
</div>
</section>
))}
<footer
style={{
marginTop: 48,
paddingTop: 20,
borderTop: "1px solid #e6e8ec",
fontSize: 12,
color: "#73767e",
}}
>
<a
href="/api/v1/docs"
style={{ color: "#1d4ed8", textDecoration: "none", marginRight: 16 }}
>
OpenAPI / Swagger
</a>
<a
href="/health"
style={{ color: "#1d4ed8", textDecoration: "none", marginRight: 16 }}
>
/health
</a>
<a
href="https://github.com/lekss361/-gendesign"
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
GitHub
</a>
</footer>
</main>
);
}

View file

@ -1,96 +0,0 @@
"use client";
import { useMemo } from "react";
import { usePrinzipFunnelMonthly } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipFunnelMonthly({ months = 24 }: { months?: number }) {
const { data, isLoading } = usePrinzipFunnelMonthly(months);
const option = useMemo(() => {
const rows = data ?? [];
if (rows.length === 0) {
return {
title: {
text: "Нет данных CRM",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontWeight: 400, fontSize: 13 },
},
xAxis: { show: false },
yAxis: { show: false },
};
}
// Aggregate by month (sum across sources).
const byMonth: Record<
string,
{ leads: number; engaged: number; converted: number }
> = {};
for (const r of rows) {
const m = (r.month ?? "").slice(0, 7);
if (!byMonth[m]) byMonth[m] = { leads: 0, engaged: 0, converted: 0 };
byMonth[m].leads += r.leads;
byMonth[m].engaged += r.engaged;
byMonth[m].converted += r.converted;
}
const months = Object.keys(byMonth).sort();
const leads = months.map((m) => byMonth[m].leads);
const engaged = months.map((m) => byMonth[m].engaged);
const converted = months.map((m) => byMonth[m].converted);
const conv = months.map((m) =>
byMonth[m].leads > 0
? Math.round((byMonth[m].converted / byMonth[m].leads) * 1000) / 10
: null,
);
return {
tooltip: { trigger: "axis" },
legend: { data: ["Заявки", "Engaged", "Сделки", "Conversion %"] },
grid: { left: 56, right: 64, top: 40, bottom: 36 },
xAxis: { type: "category", data: months },
yAxis: [
{ type: "value", name: "шт", position: "left" },
{
type: "value",
name: "%",
position: "right",
axisLabel: { formatter: "{value}%" },
},
],
series: [
{
name: "Заявки",
type: "bar",
data: leads,
itemStyle: { color: "#9ca3af" },
},
{
name: "Engaged",
type: "bar",
data: engaged,
itemStyle: { color: "#3b82f6" },
},
{
name: "Сделки",
type: "bar",
data: converted,
itemStyle: { color: "#0a7a3a" },
},
{
name: "Conversion %",
type: "line",
yAxisIndex: 1,
smooth: true,
data: conv,
lineStyle: { color: "#c2410c", width: 2 },
itemStyle: { color: "#c2410c" },
},
],
};
}, [data, months]);
return (
<ChartShell option={option} loading={isLoading} height={320} notMerge />
);
}

View file

@ -1,67 +0,0 @@
"use client";
import { useMemo } from "react";
import { usePrinzipFunnelBySource } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipFunnelSankey({ months = 12 }: { months?: number }) {
const { data, isLoading } = usePrinzipFunnelBySource(months);
const option = useMemo(() => {
const rows = data ?? [];
if (rows.length === 0) {
return {
title: {
text: "Нет данных CRM (импортируй prinzip_crm.sqlite)",
left: "center",
top: "middle",
textStyle: { color: "#9ca3af", fontWeight: 400, fontSize: 13 },
},
xAxis: { show: false },
yAxis: { show: false },
};
}
const nodes = [
{ name: "Все заявки" },
{ name: "Engaged" },
{ name: "Converted (deals)" },
...rows.map((r) => ({ name: `📥 ${r.source}` })),
];
const links = [
...rows.map((r) => ({
source: `📥 ${r.source}`,
target: "Все заявки",
value: r.leads,
})),
{
source: "Все заявки",
target: "Engaged",
value: rows.reduce((s, r) => s + r.engaged, 0),
},
{
source: "Engaged",
target: "Converted (deals)",
value: rows.reduce((s, r) => s + r.converted, 0),
},
];
return {
tooltip: { trigger: "item" },
series: [
{
type: "sankey",
data: nodes,
links,
label: { fontSize: 12 },
lineStyle: { color: "gradient", curveness: 0.5 },
emphasis: { focus: "adjacency" },
},
],
};
}, [data]);
return (
<ChartShell option={option} loading={isLoading} height={360} notMerge />
);
}

View file

@ -17,9 +17,6 @@ import type {
ObjectSalesAggRow,
PipelineRow,
PrinzipDistrictRow,
PrinzipFunnelMonthRow,
PrinzipFunnelObjectRow,
PrinzipFunnelSourceRow,
PrinzipInsights,
PrinzipObjectRow,
QuartirographyDealsRow,
@ -140,33 +137,7 @@ export function usePrinzipObjects() {
});
}
export function usePrinzipFunnelMonthly(months = 24) {
return useQuery({
queryKey: ["analytics", "prinzip-funnel-monthly", months],
queryFn: () =>
apiFetch<PrinzipFunnelMonthRow[]>(
`${BASE}/prinzip/funnel/monthly?months=${months}`,
),
});
}
export function usePrinzipFunnelBySource(months = 12) {
return useQuery({
queryKey: ["analytics", "prinzip-funnel-by-source", months],
queryFn: () =>
apiFetch<PrinzipFunnelSourceRow[]>(
`${BASE}/prinzip/funnel/by-source?months=${months}`,
),
});
}
export function usePrinzipFunnelByObject() {
return useQuery({
queryKey: ["analytics", "prinzip-funnel-by-object"],
queryFn: () =>
apiFetch<PrinzipFunnelObjectRow[]>(`${BASE}/prinzip/funnel/by-object`),
});
}
// Funnel hooks moved to /admin/leads page (CRM data is admin-only).
// ── Per-object drill-in ─────────────────────────────────────────────────────

File diff suppressed because one or more lines are too long