add anal4
This commit is contained in:
parent
522ecca5c4
commit
5056438fcf
13 changed files with 1261 additions and 266 deletions
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
253
backend/app/api/v1/admin_leads.py
Normal file
253
backend/app/api/v1/admin_leads.py
Normal 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]
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
65
frontend/src/app/admin/layout.tsx
Normal file
65
frontend/src/app/admin/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
754
frontend/src/app/admin/leads/page.tsx
Normal file
754
frontend/src/app/admin/leads/page.tsx
Normal 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",
|
||||
};
|
||||
|
|
@ -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 0–30мин).
|
||||
</p>
|
||||
|
|
@ -722,7 +724,7 @@ export default function ScrapeAdminPage() {
|
|||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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">Аналитика — Свердл рынок & 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. Свердл рынок &
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue