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 \
|
docker compose -f docker-compose.prod.yml exec -T caddy \
|
||||||
caddy reload --config /etc/caddy/Caddyfile || true
|
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
|
sleep 8
|
||||||
curl -fsS http://localhost:8000/health | head -c 200 || true
|
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)
|
return q.prinzip_objects_with_velocity(db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/prinzip/funnel/monthly")
|
# Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py:
|
||||||
def prinzip_funnel_monthly(
|
# /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token.
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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
|
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(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
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_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")
|
@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 (
|
return (
|
||||||
<main style={{ padding: 24, maxWidth: 1100, margin: "0 auto" }}>
|
<>
|
||||||
<h1 style={{ marginTop: 0 }}>Скрапер наш.дом.рф — ручной запуск</h1>
|
<h2 style={{ marginTop: 0, fontSize: 18 }}>
|
||||||
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
Скрапер наш.дом.рф — ручной запуск
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
|
||||||
Запуск sweep-а вне расписания (расписание: см. SCRAPE_KN_CRON env, по
|
Запуск sweep-а вне расписания (расписание: см. SCRAPE_KN_CRON env, по
|
||||||
умолчанию пн 04:15 МСК с jitter 0–30мин).
|
умолчанию пн 04:15 МСК с jitter 0–30мин).
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -722,7 +724,7 @@ export default function ScrapeAdminPage() {
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import { useState } from "react";
|
||||||
import { InsightCards } from "@/components/analytics/InsightCards";
|
import { InsightCards } from "@/components/analytics/InsightCards";
|
||||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||||
import { PrinzipDistrictsBar } from "@/components/analytics/PrinzipDistrictsBar";
|
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 { PrinzipGapBar } from "@/components/analytics/PrinzipGapBar";
|
||||||
import { PrinzipObjectsTable } from "@/components/analytics/PrinzipObjectsTable";
|
import { PrinzipObjectsTable } from "@/components/analytics/PrinzipObjectsTable";
|
||||||
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
|
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
|
||||||
|
|
@ -136,28 +134,6 @@ export default function PrinzipPage() {
|
||||||
<PrinzipDistrictsBar />
|
<PrinzipDistrictsBar />
|
||||||
</Section>
|
</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
|
<Section
|
||||||
title="ЖК PRINZIP — таблица с velocity"
|
title="ЖК PRINZIP — таблица с velocity"
|
||||||
subtitle="28 объектов по объёму квартир. Sparkline — динамика реализованных квартир по месяцам (apartments). Клик по «Drill-in» открывает страницу ЖК с галереей и POI."
|
subtitle="28 объектов по объёму квартир. Sparkline — динамика реализованных квартир по месяцам (apartments). Клик по «Drill-in» открывает страницу ЖК с галереей и POI."
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,178 @@
|
||||||
import Link from "next/link";
|
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() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<main style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
|
<main
|
||||||
<h1>GenDesign</h1>
|
style={{
|
||||||
<p>Generative Design + Site Finder — Discovery MVP</p>
|
padding: 24,
|
||||||
<ul>
|
maxWidth: 980,
|
||||||
<li>
|
margin: "0 auto",
|
||||||
<Link href="/concept">Concept (Generative Design)</Link>
|
fontFamily:
|
||||||
</li>
|
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
<li>
|
}}
|
||||||
<Link href="/site-finder">Site Finder</Link>
|
>
|
||||||
</li>
|
<header style={{ marginBottom: 24 }}>
|
||||||
<li>
|
<h1 style={{ margin: 0, fontSize: 28 }}>GenDesign</h1>
|
||||||
<Link href="/analytics">Аналитика — Свердл рынок & PRINZIP</Link>
|
<p style={{ margin: "6px 0 0", color: "#5b6066", fontSize: 14 }}>
|
||||||
</li>
|
Generative Design + Site Finder — Discovery MVP. Свердл рынок &
|
||||||
</ul>
|
PRINZIP.
|
||||||
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
|
</p>
|
||||||
<p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}>
|
</header>
|
||||||
антон ты пидр
|
|
||||||
</p>
|
{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>
|
</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,
|
ObjectSalesAggRow,
|
||||||
PipelineRow,
|
PipelineRow,
|
||||||
PrinzipDistrictRow,
|
PrinzipDistrictRow,
|
||||||
PrinzipFunnelMonthRow,
|
|
||||||
PrinzipFunnelObjectRow,
|
|
||||||
PrinzipFunnelSourceRow,
|
|
||||||
PrinzipInsights,
|
PrinzipInsights,
|
||||||
PrinzipObjectRow,
|
PrinzipObjectRow,
|
||||||
QuartirographyDealsRow,
|
QuartirographyDealsRow,
|
||||||
|
|
@ -140,33 +137,7 @@ export function usePrinzipObjects() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePrinzipFunnelMonthly(months = 24) {
|
// Funnel hooks moved to /admin/leads page (CRM data is admin-only).
|
||||||
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`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Per-object drill-in ─────────────────────────────────────────────────────
|
// ── Per-object drill-in ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue