feat(admin): marketplace dashboard + HTMX lead management improvements
Admin marketplace (/admin/marketplace): - Lead funnel cards: total / verified-new / unlocked / won / conversion rate - Credit economy: issued / consumed / outstanding / 30-day burn - Supplier engagement: active count / avg unlocks / response rate - Feature flag toggles (lead_unlock, supplier_signup) with next= redirect - Live activity stream (HTMX partial): last 50 lead / unlock / credit events Admin leads list (/admin/leads): - Summary cards: total / new+unverified / hot pipeline credits / forward rate - Search filter (name, email, company) with HTMX live update - Period pills: Today / 7d / 30d / All - get_leads() now returns (rows, total_count); get_lead_stats() includes _total, _new_unverified, _hot_pipeline, _forward_rate Admin lead detail (/admin/leads/<id>): - Inline HTMX status change returning updated status badge partial - Inline HTMX forward form returning updated forward history partial (replaces full-page reload on every status/forward action) - Forward history table shows supplier, status, credit_cost, sent_at Quote form extended with optional fields: - build_context, glass_type, lighting_type, location_status, financing_status, services_needed, additional_info (captured in lead detail view but not required for heat scoring) Sidebar nav: "Marketplace" tab added between Leads and Suppliers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -380,9 +380,10 @@ HEAT_OPTIONS = ["hot", "warm", "cool"]
|
|||||||
|
|
||||||
async def get_leads(
|
async def get_leads(
|
||||||
status: str = None, heat: str = None, country: str = None,
|
status: str = None, heat: str = None, country: str = None,
|
||||||
|
search: str = None, days: int = None,
|
||||||
page: int = 1, per_page: int = 50,
|
page: int = 1, per_page: int = 50,
|
||||||
) -> list[dict]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Get leads with optional filters."""
|
"""Get leads with optional filters. Returns (leads, total_count)."""
|
||||||
wheres = ["lead_type = 'quote'"]
|
wheres = ["lead_type = 'quote'"]
|
||||||
params: list = []
|
params: list = []
|
||||||
|
|
||||||
@@ -395,16 +396,27 @@ async def get_leads(
|
|||||||
if country:
|
if country:
|
||||||
wheres.append("country = ?")
|
wheres.append("country = ?")
|
||||||
params.append(country)
|
params.append(country)
|
||||||
|
if search:
|
||||||
|
term = f"%{search}%"
|
||||||
|
wheres.append("(contact_name LIKE ? OR contact_email LIKE ? OR contact_company LIKE ?)")
|
||||||
|
params.extend([term, term, term])
|
||||||
|
if days:
|
||||||
|
wheres.append("created_at >= datetime('now', ?)")
|
||||||
|
params.append(f"-{days} days")
|
||||||
|
|
||||||
where = " AND ".join(wheres)
|
where = " AND ".join(wheres)
|
||||||
offset = (page - 1) * per_page
|
count_row = await fetch_one(
|
||||||
params.extend([per_page, offset])
|
f"SELECT COUNT(*) as cnt FROM lead_requests WHERE {where}", tuple(params)
|
||||||
|
)
|
||||||
|
total = count_row["cnt"] if count_row else 0
|
||||||
|
|
||||||
return await fetch_all(
|
offset = (page - 1) * per_page
|
||||||
|
rows = await fetch_all(
|
||||||
f"""SELECT * FROM lead_requests WHERE {where}
|
f"""SELECT * FROM lead_requests WHERE {where}
|
||||||
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
ORDER BY created_at DESC LIMIT ? OFFSET ?""",
|
||||||
tuple(params),
|
tuple(params) + (per_page, offset),
|
||||||
)
|
)
|
||||||
|
return rows, total
|
||||||
|
|
||||||
|
|
||||||
async def get_lead_detail(lead_id: int) -> dict | None:
|
async def get_lead_detail(lead_id: int) -> dict | None:
|
||||||
@@ -426,11 +438,32 @@ async def get_lead_detail(lead_id: int) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
async def get_lead_stats() -> dict:
|
async def get_lead_stats() -> dict:
|
||||||
"""Get lead conversion funnel counts."""
|
"""Get lead conversion funnel counts + summary card metrics."""
|
||||||
rows = await fetch_all(
|
rows = await fetch_all(
|
||||||
"SELECT status, COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' GROUP BY status"
|
"SELECT status, COUNT(*) as cnt FROM lead_requests WHERE lead_type = 'quote' GROUP BY status"
|
||||||
)
|
)
|
||||||
return {r["status"]: r["cnt"] for r in rows}
|
by_status = {r["status"]: r["cnt"] for r in rows}
|
||||||
|
|
||||||
|
# Summary card aggregates
|
||||||
|
agg = await fetch_one(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status IN ('new', 'pending_verification') THEN 1 ELSE 0 END) as new_unverified,
|
||||||
|
SUM(CASE WHEN heat_score = 'hot' AND status = 'new' THEN credit_cost ELSE 0 END) as hot_pipeline,
|
||||||
|
SUM(CASE WHEN status = 'forwarded' THEN 1 ELSE 0 END) as forwarded
|
||||||
|
FROM lead_requests WHERE lead_type = 'quote'"""
|
||||||
|
)
|
||||||
|
total = agg["total"] or 0
|
||||||
|
forwarded = agg["forwarded"] or 0
|
||||||
|
forward_rate = round((forwarded / total) * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
**by_status,
|
||||||
|
"_total": total,
|
||||||
|
"_new_unverified": agg["new_unverified"] or 0,
|
||||||
|
"_hot_pipeline": agg["hot_pipeline"] or 0,
|
||||||
|
"_forward_rate": forward_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/leads")
|
@bp.route("/leads")
|
||||||
@@ -440,10 +473,15 @@ async def leads():
|
|||||||
status = request.args.get("status", "")
|
status = request.args.get("status", "")
|
||||||
heat = request.args.get("heat", "")
|
heat = request.args.get("heat", "")
|
||||||
country = request.args.get("country", "")
|
country = request.args.get("country", "")
|
||||||
|
search = request.args.get("search", "")
|
||||||
|
days_str = request.args.get("days", "")
|
||||||
|
days = int(days_str) if days_str.isdigit() else None
|
||||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
lead_list = await get_leads(
|
lead_list, total = await get_leads(
|
||||||
status=status or None, heat=heat or None, country=country or None, page=page,
|
status=status or None, heat=heat or None, country=country or None,
|
||||||
|
search=search or None, days=days, page=page, per_page=per_page,
|
||||||
)
|
)
|
||||||
lead_stats = await get_lead_stats()
|
lead_stats = await get_lead_stats()
|
||||||
|
|
||||||
@@ -461,7 +499,11 @@ async def leads():
|
|||||||
current_status=status,
|
current_status=status,
|
||||||
current_heat=heat,
|
current_heat=heat,
|
||||||
current_country=country,
|
current_country=country,
|
||||||
|
current_search=search,
|
||||||
|
current_days=days_str,
|
||||||
page=page,
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -472,12 +514,28 @@ async def lead_results():
|
|||||||
status = request.args.get("status", "")
|
status = request.args.get("status", "")
|
||||||
heat = request.args.get("heat", "")
|
heat = request.args.get("heat", "")
|
||||||
country = request.args.get("country", "")
|
country = request.args.get("country", "")
|
||||||
|
search = request.args.get("search", "")
|
||||||
|
days_str = request.args.get("days", "")
|
||||||
|
days = int(days_str) if days_str.isdigit() else None
|
||||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||||
|
per_page = 50
|
||||||
|
|
||||||
lead_list = await get_leads(
|
lead_list, total = await get_leads(
|
||||||
status=status or None, heat=heat or None, country=country or None, page=page,
|
status=status or None, heat=heat or None, country=country or None,
|
||||||
|
search=search or None, days=days, page=page, per_page=per_page,
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/lead_results.html",
|
||||||
|
leads=lead_list,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
total=total,
|
||||||
|
current_status=status,
|
||||||
|
current_heat=heat,
|
||||||
|
current_country=country,
|
||||||
|
current_search=search,
|
||||||
|
current_days=days_str,
|
||||||
)
|
)
|
||||||
return await render_template("admin/partials/lead_results.html", leads=lead_list)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/leads/<int:lead_id>")
|
@bp.route("/leads/<int:lead_id>")
|
||||||
@@ -528,11 +586,18 @@ async def lead_new():
|
|||||||
contact_name = form.get("contact_name", "").strip()
|
contact_name = form.get("contact_name", "").strip()
|
||||||
contact_email = form.get("contact_email", "").strip()
|
contact_email = form.get("contact_email", "").strip()
|
||||||
facility_type = form.get("facility_type", "indoor")
|
facility_type = form.get("facility_type", "indoor")
|
||||||
|
build_context = form.get("build_context", "")
|
||||||
|
glass_type = form.get("glass_type", "")
|
||||||
|
lighting_type = form.get("lighting_type", "")
|
||||||
court_count = int(form.get("court_count", 6) or 6)
|
court_count = int(form.get("court_count", 6) or 6)
|
||||||
country = form.get("country", "")
|
country = form.get("country", "")
|
||||||
city = form.get("city", "").strip()
|
city = form.get("city", "").strip()
|
||||||
|
location_status = form.get("location_status", "")
|
||||||
timeline = form.get("timeline", "")
|
timeline = form.get("timeline", "")
|
||||||
budget_estimate = int(form.get("budget_estimate", 0) or 0)
|
budget_estimate = int(form.get("budget_estimate", 0) or 0)
|
||||||
|
financing_status = form.get("financing_status", "")
|
||||||
|
services_needed = form.get("services_needed", "").strip()
|
||||||
|
additional_info = form.get("additional_info", "").strip()
|
||||||
stakeholder_type = form.get("stakeholder_type", "")
|
stakeholder_type = form.get("stakeholder_type", "")
|
||||||
heat_score = form.get("heat_score", "warm")
|
heat_score = form.get("heat_score", "warm")
|
||||||
status = form.get("status", "new")
|
status = form.get("status", "new")
|
||||||
@@ -550,14 +615,18 @@ async def lead_new():
|
|||||||
|
|
||||||
lead_id = await execute(
|
lead_id = await execute(
|
||||||
"""INSERT INTO lead_requests
|
"""INSERT INTO lead_requests
|
||||||
(lead_type, facility_type, court_count, country, location, timeline,
|
(lead_type, facility_type, build_context, glass_type, lighting_type,
|
||||||
budget_estimate, stakeholder_type, heat_score, status,
|
court_count, country, location, location_status, timeline,
|
||||||
|
budget_estimate, financing_status, services_needed, additional_info,
|
||||||
|
stakeholder_type, heat_score, status,
|
||||||
contact_name, contact_email, contact_phone, contact_company,
|
contact_name, contact_email, contact_phone, contact_company,
|
||||||
credit_cost, verified_at, created_at)
|
credit_cost, verified_at, created_at)
|
||||||
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(
|
(
|
||||||
facility_type, court_count, country, city, timeline,
|
facility_type, build_context, glass_type, lighting_type,
|
||||||
budget_estimate, stakeholder_type, heat_score, status,
|
court_count, country, city, location_status, timeline,
|
||||||
|
budget_estimate, financing_status, services_needed, additional_info,
|
||||||
|
stakeholder_type, heat_score, status,
|
||||||
contact_name, contact_email,
|
contact_name, contact_email,
|
||||||
form.get("contact_phone", ""), form.get("contact_company", ""),
|
form.get("contact_phone", ""), form.get("contact_company", ""),
|
||||||
credit_cost, verified_at, now,
|
credit_cost, verified_at, now,
|
||||||
@@ -612,6 +681,174 @@ async def lead_forward(lead_id: int):
|
|||||||
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
return redirect(url_for("admin.lead_detail", lead_id=lead_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/<int:lead_id>/status-htmx", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def lead_status_htmx(lead_id: int):
|
||||||
|
"""HTMX: Update lead status, return updated status badge partial."""
|
||||||
|
form = await request.form
|
||||||
|
new_status = form.get("status", "")
|
||||||
|
if new_status not in LEAD_STATUSES:
|
||||||
|
return Response("Invalid status", status=422)
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
"UPDATE lead_requests SET status = ? WHERE id = ?", (new_status, lead_id)
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/lead_status_badge.html", status=new_status, lead_id=lead_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/leads/<int:lead_id>/forward-htmx", methods=["POST"])
|
||||||
|
@role_required("admin")
|
||||||
|
@csrf_protect
|
||||||
|
async def lead_forward_htmx(lead_id: int):
|
||||||
|
"""HTMX: Forward lead to supplier, return updated forward history partial."""
|
||||||
|
form = await request.form
|
||||||
|
supplier_id_str = form.get("supplier_id", "")
|
||||||
|
if not supplier_id_str.isdigit():
|
||||||
|
return Response("Select a supplier.", status=422)
|
||||||
|
supplier_id = int(supplier_id_str)
|
||||||
|
|
||||||
|
existing = await fetch_one(
|
||||||
|
"SELECT 1 FROM lead_forwards WHERE lead_id = ? AND supplier_id = ?",
|
||||||
|
(lead_id, supplier_id),
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return Response("Already forwarded to this supplier.", status=422)
|
||||||
|
|
||||||
|
now = utcnow_iso()
|
||||||
|
await execute(
|
||||||
|
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, status, created_at)
|
||||||
|
VALUES (?, ?, 0, 'sent', ?)""",
|
||||||
|
(lead_id, supplier_id, now),
|
||||||
|
)
|
||||||
|
await execute(
|
||||||
|
"UPDATE lead_requests SET unlock_count = unlock_count + 1, status = 'forwarded' WHERE id = ?",
|
||||||
|
(lead_id,),
|
||||||
|
)
|
||||||
|
from ..worker import enqueue
|
||||||
|
await enqueue("send_lead_forward_email", {"lead_id": lead_id, "supplier_id": supplier_id})
|
||||||
|
|
||||||
|
lead = await get_lead_detail(lead_id)
|
||||||
|
return await render_template(
|
||||||
|
"admin/partials/lead_forward_history.html",
|
||||||
|
forwards=lead["forwards"] if lead else [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/marketplace")
|
||||||
|
@role_required("admin")
|
||||||
|
async def marketplace_dashboard():
|
||||||
|
"""Marketplace health dashboard."""
|
||||||
|
# Lead funnel
|
||||||
|
funnel = await fetch_one(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status = 'new' AND verified_at IS NOT NULL THEN 1 ELSE 0 END) as verified_new,
|
||||||
|
SUM(CASE WHEN status = 'forwarded' THEN 1 ELSE 0 END) as forwarded_count,
|
||||||
|
SUM(CASE WHEN status = 'closed_won' THEN 1 ELSE 0 END) as won_count
|
||||||
|
FROM lead_requests WHERE lead_type = 'quote'"""
|
||||||
|
)
|
||||||
|
total = funnel["total"] or 0
|
||||||
|
won = funnel["won_count"] or 0
|
||||||
|
conversion_rate = round((won / total) * 100, 1) if total > 0 else 0
|
||||||
|
unlocked_count = (await fetch_one(
|
||||||
|
"SELECT COUNT(DISTINCT lead_id) as cnt FROM lead_forwards"
|
||||||
|
) or {}).get("cnt", 0)
|
||||||
|
|
||||||
|
# Credit economy
|
||||||
|
credit_agg = await fetch_one(
|
||||||
|
"""SELECT
|
||||||
|
SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END) as total_issued,
|
||||||
|
SUM(CASE WHEN event_type = 'lead_unlock' THEN ABS(delta) ELSE 0 END) as total_consumed,
|
||||||
|
SUM(CASE WHEN event_type = 'lead_unlock'
|
||||||
|
AND created_at >= datetime('now', '-30 days')
|
||||||
|
THEN ABS(delta) ELSE 0 END) as monthly_burn
|
||||||
|
FROM credit_ledger"""
|
||||||
|
)
|
||||||
|
outstanding = (await fetch_one(
|
||||||
|
"SELECT SUM(credit_balance) as bal FROM suppliers WHERE tier != 'free'"
|
||||||
|
) or {}).get("bal", 0) or 0
|
||||||
|
|
||||||
|
# Supplier engagement
|
||||||
|
supplier_agg = await fetch_one(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) as active_count,
|
||||||
|
ROUND(AVG(unlock_count), 1) as avg_unlocks
|
||||||
|
FROM (
|
||||||
|
SELECT s.id, COUNT(lf.id) as unlock_count
|
||||||
|
FROM suppliers s
|
||||||
|
LEFT JOIN lead_forwards lf ON lf.supplier_id = s.id
|
||||||
|
WHERE s.tier != 'free' AND s.credit_balance > 0
|
||||||
|
GROUP BY s.id
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
response_agg = await fetch_one(
|
||||||
|
"""SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status != 'sent' THEN 1 ELSE 0 END) as responded
|
||||||
|
FROM lead_forwards"""
|
||||||
|
)
|
||||||
|
resp_total = (response_agg or {}).get("total", 0) or 0
|
||||||
|
resp_responded = (response_agg or {}).get("responded", 0) or 0
|
||||||
|
response_rate = round((resp_responded / resp_total) * 100) if resp_total > 0 else 0
|
||||||
|
|
||||||
|
# Feature flags
|
||||||
|
flags = await fetch_all(
|
||||||
|
"SELECT name, enabled FROM feature_flags WHERE name IN ('lead_unlock', 'supplier_signup')"
|
||||||
|
)
|
||||||
|
flag_map = {f["name"]: bool(f["enabled"]) for f in flags}
|
||||||
|
|
||||||
|
return await render_template(
|
||||||
|
"admin/marketplace.html",
|
||||||
|
funnel={
|
||||||
|
"total": total,
|
||||||
|
"verified_new": funnel["verified_new"] or 0,
|
||||||
|
"unlocked": unlocked_count,
|
||||||
|
"won": won,
|
||||||
|
"conversion_rate": conversion_rate,
|
||||||
|
},
|
||||||
|
credits={
|
||||||
|
"issued": (credit_agg or {}).get("total_issued", 0) or 0,
|
||||||
|
"consumed": (credit_agg or {}).get("total_consumed", 0) or 0,
|
||||||
|
"outstanding": outstanding,
|
||||||
|
"monthly_burn": (credit_agg or {}).get("monthly_burn", 0) or 0,
|
||||||
|
},
|
||||||
|
suppliers={
|
||||||
|
"active": (supplier_agg or {}).get("active_count", 0) or 0,
|
||||||
|
"avg_unlocks": (supplier_agg or {}).get("avg_unlocks", 0) or 0,
|
||||||
|
"response_rate": response_rate,
|
||||||
|
},
|
||||||
|
flags=flag_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/marketplace/activity")
|
||||||
|
@role_required("admin")
|
||||||
|
async def marketplace_activity():
|
||||||
|
"""HTMX: Recent marketplace activity stream."""
|
||||||
|
rows = await fetch_all(
|
||||||
|
"""SELECT 'lead' as event_type, id as ref_id,
|
||||||
|
contact_name as actor, status as detail,
|
||||||
|
country as extra, created_at
|
||||||
|
FROM lead_requests WHERE lead_type = 'quote'
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'unlock' as event_type, lf.id as ref_id,
|
||||||
|
s.name as actor, lf.status as detail,
|
||||||
|
CAST(lf.credit_cost AS TEXT) as extra, lf.created_at
|
||||||
|
FROM lead_forwards lf
|
||||||
|
JOIN suppliers s ON s.id = lf.supplier_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'credit' as event_type, id as ref_id,
|
||||||
|
CAST(supplier_id AS TEXT) as actor, event_type as detail,
|
||||||
|
CAST(delta AS TEXT) as extra, created_at
|
||||||
|
FROM credit_ledger
|
||||||
|
ORDER BY created_at DESC LIMIT 50"""
|
||||||
|
)
|
||||||
|
return await render_template("admin/partials/marketplace_activity.html", events=rows)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Supplier Management
|
# Supplier Management
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -895,7 +1132,8 @@ async def flag_toggle():
|
|||||||
)
|
)
|
||||||
state = "enabled" if new_enabled else "disabled"
|
state = "enabled" if new_enabled else "disabled"
|
||||||
await flash(f"Flag '{flag_name}' {state}.", "success")
|
await flash(f"Flag '{flag_name}' {state}.", "success")
|
||||||
return redirect(url_for("admin.flags"))
|
next_url = form.get("next", "") or url_for("admin.flags")
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -63,7 +63,11 @@
|
|||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="admin-sidebar__section">Leads</div>
|
<div class="admin-sidebar__section">Marketplace</div>
|
||||||
|
<a href="{{ url_for('admin.marketplace_dashboard') }}" class="{% if admin_page == 'marketplace' %}active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
|
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||||
Leads
|
Leads
|
||||||
|
|||||||
@@ -2,122 +2,126 @@
|
|||||||
{% set admin_page = "leads" %}
|
{% set admin_page = "leads" %}
|
||||||
{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.lead-heat-hot { background:#FEE2E2; color:#991B1B; font-weight:600; font-size:0.75rem; padding:3px 10px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
|
||||||
|
.lead-heat-warm { background:#FEF3C7; color:#92400E; font-weight:600; font-size:0.75rem; padding:3px 10px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
|
||||||
|
.lead-heat-cool { background:#DBEAFE; color:#1E40AF; font-weight:600; font-size:0.75rem; padding:3px 10px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
|
||||||
|
|
||||||
|
.lead-status-new { background:#DBEAFE; color:#1E40AF; }
|
||||||
|
.lead-status-pending_verification { background:#FEF3C7; color:#92400E; }
|
||||||
|
.lead-status-contacted { background:#EDE9FE; color:#5B21B6; }
|
||||||
|
.lead-status-forwarded { background:#D1FAE5; color:#065F46; }
|
||||||
|
.lead-status-closed_won { background:#D1FAE5; color:#14532D; }
|
||||||
|
.lead-status-closed_lost { background:#F1F5F9; color:#475569; }
|
||||||
|
.lead-status-badge {
|
||||||
|
font-size:0.75rem; font-weight:600; padding:3px 10px; border-radius:9999px;
|
||||||
|
display:inline-block; white-space:nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-6">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||||
<h1 class="text-2xl mt-1">Lead #{{ lead.id }}
|
<div class="flex items-center gap-3 mt-1">
|
||||||
{% if lead.heat_score == 'hot' %}<span class="badge-danger">HOT</span>
|
<h1 class="text-2xl">Lead #{{ lead.id }}</h1>
|
||||||
{% elif lead.heat_score == 'warm' %}<span class="badge-warning">WARM</span>
|
<span class="lead-heat-{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
|
||||||
{% else %}<span class="badge">COOL</span>{% endif %}
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Status update -->
|
</div>
|
||||||
<form method="post" action="{{ url_for('admin.lead_status', lead_id=lead.id) }}" class="flex items-center gap-2">
|
<!-- Inline HTMX status update -->
|
||||||
|
<div id="lead-status-section">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="lead-status-badge lead-status-{{ lead.status | replace(' ', '_') }}" style="font-size:0.875rem;padding:4px 12px;">
|
||||||
|
{{ lead.status | replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form hx-post="{{ url_for('admin.lead_status_htmx', lead_id=lead.id) }}"
|
||||||
|
hx-target="#lead-status-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="flex items-center gap-2">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<select name="status" class="form-input" style="min-width:140px">
|
<select name="status" class="form-input" style="min-width:160px">
|
||||||
{% for s in statuses %}
|
{% for s in statuses %}
|
||||||
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s }}</option>
|
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s | replace('_', ' ') }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn-outline btn-sm">Update</button>
|
<button type="submit" class="btn-outline btn-sm">Update</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid-2" style="gap:1.5rem">
|
<div class="grid-2" style="gap:1.5rem">
|
||||||
<!-- Project brief -->
|
<!-- Project brief -->
|
||||||
<div class="card" style="padding:1.5rem">
|
<div class="card" style="padding:1.5rem">
|
||||||
<h2 class="text-lg mb-4">Project Brief</h2>
|
<h2 class="text-lg mb-4">Project Brief</h2>
|
||||||
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:0.8125rem">
|
<dl style="display:grid;grid-template-columns:160px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">Facility</dt>
|
<dt class="text-slate">Facility</dt> <dd>{{ lead.facility_type or '-' }}</dd>
|
||||||
<dd>{{ lead.facility_type or '-' }}</dd>
|
<dt class="text-slate">Build Context</dt> <dd>{{ lead.build_context or '-' }}</dd>
|
||||||
<dt class="text-slate">Courts</dt>
|
<dt class="text-slate">Courts</dt> <dd>{{ lead.court_count or '-' }}</dd>
|
||||||
<dd>{{ lead.court_count or '-' }}</dd>
|
<dt class="text-slate">Glass</dt> <dd>{{ lead.glass_type or '-' }}</dd>
|
||||||
<dt class="text-slate">Glass</dt>
|
<dt class="text-slate">Lighting</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
|
||||||
<dd>{{ lead.glass_type or '-' }}</dd>
|
<dt class="text-slate">Location</dt> <dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
||||||
<dt class="text-slate">Lighting</dt>
|
<dt class="text-slate">Phase</dt> <dd>{{ lead.location_status or '-' }}</dd>
|
||||||
<dd>{{ lead.lighting_type or '-' }}</dd>
|
<dt class="text-slate">Timeline</dt> <dd>{{ lead.timeline or '-' }}</dd>
|
||||||
<dt class="text-slate">Build Context</dt>
|
<dt class="text-slate">Budget</dt> <dd>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</dd>
|
||||||
<dd>{{ lead.build_context or '-' }}</dd>
|
<dt class="text-slate">Financing</dt> <dd>{{ lead.financing_status or '-' }}</dd>
|
||||||
<dt class="text-slate">Location</dt>
|
<dt class="text-slate">Services</dt> <dd>{{ lead.services_needed or '-' }}</dd>
|
||||||
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
<dt class="text-slate">Additional Info</dt><dd>{{ lead.additional_info or '-' }}</dd>
|
||||||
<dt class="text-slate">Timeline</dt>
|
<dt class="text-slate">Credit Cost</dt> <dd>{{ lead.credit_cost or '-' }} credits</dd>
|
||||||
<dd>{{ lead.timeline or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Phase</dt>
|
|
||||||
<dd>{{ lead.location_status or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Budget</dt>
|
|
||||||
<dd>{{ lead.budget_estimate or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Financing</dt>
|
|
||||||
<dd>{{ lead.financing_status or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Services</dt>
|
|
||||||
<dd>{{ lead.services_needed or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Additional Info</dt>
|
|
||||||
<dd>{{ lead.additional_info or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Credit Cost</dt>
|
|
||||||
<dd>{{ lead.credit_cost or '-' }} credits</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact info -->
|
<!-- Contact + forward -->
|
||||||
<div>
|
<div>
|
||||||
<div class="card mb-4" style="padding:1.5rem">
|
<div class="card mb-4" style="padding:1.5rem">
|
||||||
<h2 class="text-lg mb-4">Contact</h2>
|
<h2 class="text-lg mb-4">Contact</h2>
|
||||||
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||||
<dt class="text-slate">Name</dt>
|
<dt class="text-slate">Name</dt> <dd>{{ lead.contact_name or '-' }}</dd>
|
||||||
<dd>{{ lead.contact_name or '-' }}</dd>
|
<dt class="text-slate">Email</dt> <dd>{{ lead.contact_email or '-' }}</dd>
|
||||||
<dt class="text-slate">Email</dt>
|
<dt class="text-slate">Phone</dt> <dd>{{ lead.contact_phone or '-' }}</dd>
|
||||||
<dd>{{ lead.contact_email or '-' }}</dd>
|
<dt class="text-slate">Company</dt> <dd>{{ lead.contact_company or '-' }}</dd>
|
||||||
<dt class="text-slate">Phone</dt>
|
<dt class="text-slate">Role</dt> <dd>{{ lead.stakeholder_type or '-' }}</dd>
|
||||||
<dd>{{ lead.contact_phone or '-' }}</dd>
|
<dt class="text-slate">Created</dt> <dd class="mono">{{ lead.created_at or '-' }}</dd>
|
||||||
<dt class="text-slate">Company</dt>
|
<dt class="text-slate">Verified</dt> <dd class="mono">{{ lead.verified_at or 'Not verified' }}</dd>
|
||||||
<dd>{{ lead.contact_company or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Role</dt>
|
|
||||||
<dd>{{ lead.stakeholder_type or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Created</dt>
|
|
||||||
<dd class="mono">{{ lead.created_at or '-' }}</dd>
|
|
||||||
<dt class="text-slate">Verified</dt>
|
|
||||||
<dd class="mono">{{ lead.verified_at or 'Not verified' }}</dd>
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward to supplier -->
|
<!-- Forward to supplier (HTMX) -->
|
||||||
<div class="card" style="padding:1.5rem">
|
<div class="card" style="padding:1.5rem">
|
||||||
<h2 class="text-lg mb-4">Forward to Supplier</h2>
|
<h2 class="text-lg mb-4">Forward to Supplier</h2>
|
||||||
<form method="post" action="{{ url_for('admin.lead_forward', lead_id=lead.id) }}">
|
<form hx-post="{{ url_for('admin.lead_forward_htmx', lead_id=lead.id) }}"
|
||||||
|
hx-target="#forward-history"
|
||||||
|
hx-swap="innerHTML">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<select name="supplier_id" class="form-input mb-3" style="width:100%">
|
<select name="supplier_id" class="form-input mb-3" style="width:100%">
|
||||||
<option value="">Select supplier...</option>
|
<option value="">Select supplier…</option>
|
||||||
{% for s in suppliers %}
|
{# Lead's country first, then alphabetical #}
|
||||||
<option value="{{ s.id }}">{{ s.name }} ({{ s.country_code }}, {{ s.category }})</option>
|
{% set lead_country = lead.country or '' %}
|
||||||
|
{% for s in suppliers | sort(attribute='name') | sort(attribute='country_code', reverse=(lead_country == '')) %}
|
||||||
|
<option value="{{ s.id }}"
|
||||||
|
{% if s.country_code == lead_country %}style="font-weight:600"{% endif %}>
|
||||||
|
{% if s.country_code == lead_country %}★ {% endif %}{{ s.name }} ({{ s.country_code }}, {{ s.category }})
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn" style="width:100%">Forward Lead</button>
|
<button type="submit" class="btn" style="width:100%"
|
||||||
|
hx-disabled-elt="this">Forward Lead</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Forward history -->
|
<!-- Forward history -->
|
||||||
{% if lead.forwards %}
|
|
||||||
<section class="mt-6">
|
<section class="mt-6">
|
||||||
<h2 class="text-lg mb-3">Forward History</h2>
|
<h2 class="text-lg mb-3">Forward History
|
||||||
<div class="card">
|
<span class="text-sm text-slate font-normal">({{ lead.forwards | length }} total)</span>
|
||||||
<table class="table">
|
</h2>
|
||||||
<thead>
|
<div class="card" style="padding:1rem">
|
||||||
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Sent</th></tr>
|
<div id="forward-history">
|
||||||
</thead>
|
{% include "admin/partials/lead_forward_history.html" with context %}
|
||||||
<tbody>
|
</div>
|
||||||
{% for f in lead.forwards %}
|
|
||||||
<tr>
|
|
||||||
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=f.supplier_id) }}">{{ f.supplier_name }}</a></td>
|
|
||||||
<td>{{ f.credit_cost }}</td>
|
|
||||||
<td><span class="badge">{{ f.status }}</span></td>
|
|
||||||
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<div style="max-width:640px">
|
<div style="max-width:720px">
|
||||||
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||||
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
|
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
|
||||||
|
|
||||||
@@ -21,8 +21,7 @@
|
|||||||
<input type="email" name="contact_email" class="form-input" required value="{{ data.get('contact_email', '') }}">
|
<input type="email" name="contact_email" class="form-input" required value="{{ data.get('contact_email', '') }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Phone</label>
|
<label class="form-label">Phone</label>
|
||||||
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
|
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
|
||||||
@@ -31,6 +30,14 @@
|
|||||||
<label class="form-label">Company</label>
|
<label class="form-label">Company</label>
|
||||||
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
|
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select name="stakeholder_type" class="form-input">
|
||||||
|
{% for v in ['entrepreneur','tennis_club','municipality','developer','operator','architect'] %}
|
||||||
|
<option value="{{ v }}" {{ 'selected' if data.get('stakeholder_type') == v }}>{{ v | replace('_', ' ') | title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="margin:1.5rem 0">
|
<hr style="margin:1.5rem 0">
|
||||||
@@ -40,18 +47,44 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="form-label">Facility Type *</label>
|
<label class="form-label">Facility Type *</label>
|
||||||
<select name="facility_type" class="form-input" required>
|
<select name="facility_type" class="form-input" required>
|
||||||
<option value="indoor">Indoor</option>
|
{% for v in ['indoor','outdoor','both'] %}
|
||||||
<option value="outdoor">Outdoor</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('facility_type', 'indoor') == v }}>{{ v | title }}</option>
|
||||||
<option value="both">Both</option>
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Build Context</label>
|
||||||
|
<select name="build_context" class="form-input">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for v in ['new_build','conversion','expansion'] %}
|
||||||
|
<option value="{{ v }}" {{ 'selected' if data.get('build_context') == v }}>{{ v | replace('_', ' ') | title }}</option>
|
||||||
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Courts</label>
|
<label class="form-label">Courts</label>
|
||||||
<input type="number" name="court_count" class="form-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
|
<input type="number" name="court_count" class="form-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Budget (€)</label>
|
<label class="form-label">Glass Type</label>
|
||||||
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
|
<select name="glass_type" class="form-input">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for v in ['tempered','standard'] %}
|
||||||
|
<option value="{{ v }}" {{ 'selected' if data.get('glass_type') == v }}>{{ v | title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Lighting</label>
|
||||||
|
<select name="lighting_type" class="form-input">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for v in ['led','standard','none'] %}
|
||||||
|
<option value="{{ v }}" {{ 'selected' if data.get('lighting_type') == v }}>{{ v | title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +93,7 @@
|
|||||||
<label class="form-label">Country *</label>
|
<label class="form-label">Country *</label>
|
||||||
<select name="country" class="form-input" required>
|
<select name="country" class="form-input" required>
|
||||||
{% for code, name in [('DE','Germany'),('ES','Spain'),('IT','Italy'),('FR','France'),('NL','Netherlands'),('SE','Sweden'),('GB','United Kingdom'),('PT','Portugal'),('AE','UAE'),('SA','Saudi Arabia')] %}
|
{% for code, name in [('DE','Germany'),('ES','Spain'),('IT','Italy'),('FR','France'),('NL','Netherlands'),('SE','Sweden'),('GB','United Kingdom'),('PT','Portugal'),('AE','UAE'),('SA','Saudi Arabia')] %}
|
||||||
<option value="{{ code }}">{{ name }}</option>
|
<option value="{{ code }}" {{ 'selected' if data.get('country') == code }}>{{ name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,41 +105,68 @@
|
|||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Timeline *</label>
|
<label class="form-label">Location Status</label>
|
||||||
<select name="timeline" class="form-input" required>
|
<select name="location_status" class="form-input">
|
||||||
<option value="asap">ASAP</option>
|
<option value="">—</option>
|
||||||
<option value="3-6mo">3-6 Months</option>
|
{% for v in ['secured','searching','evaluating'] %}
|
||||||
<option value="6-12mo">6-12 Months</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('location_status') == v }}>{{ v | title }}</option>
|
||||||
<option value="12+mo">12+ Months</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Stakeholder Type *</label>
|
<label class="form-label">Timeline *</label>
|
||||||
<select name="stakeholder_type" class="form-input" required>
|
<select name="timeline" class="form-input" required>
|
||||||
<option value="entrepreneur">Entrepreneur</option>
|
{% for v, label in [('asap','ASAP'),('3-6mo','3–6 Months'),('6-12mo','6–12 Months'),('12+mo','12+ Months')] %}
|
||||||
<option value="tennis_club">Tennis Club</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('timeline') == v }}>{{ label }}</option>
|
||||||
<option value="municipality">Municipality</option>
|
{% endfor %}
|
||||||
<option value="developer">Developer</option>
|
|
||||||
<option value="operator">Operator</option>
|
|
||||||
<option value="architect">Architect</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Budget (€)</label>
|
||||||
|
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Financing Status</label>
|
||||||
|
<select name="financing_status" class="form-input">
|
||||||
|
<option value="">—</option>
|
||||||
|
{% for v in ['secured','in_progress','not_started'] %}
|
||||||
|
<option value="{{ v }}" {{ 'selected' if data.get('financing_status') == v }}>{{ v | replace('_', ' ') | title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<label class="form-label">Services Needed</label>
|
||||||
|
<input type="text" name="services_needed" class="form-input" placeholder="e.g. construction, consulting, equipment"
|
||||||
|
value="{{ data.get('services_needed', '') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:1rem">
|
||||||
|
<label class="form-label">Additional Info</label>
|
||||||
|
<textarea name="additional_info" class="form-input" rows="3" style="width:100%">{{ data.get('additional_info', '') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin:1.5rem 0">
|
||||||
|
|
||||||
|
<h2 class="text-lg mb-3">Classification</h2>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem">
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Heat Score</label>
|
<label class="form-label">Heat Score</label>
|
||||||
<select name="heat_score" class="form-input">
|
<select name="heat_score" class="form-input">
|
||||||
<option value="hot">Hot</option>
|
{% for v in ['hot','warm','cool'] %}
|
||||||
<option value="warm" selected>Warm</option>
|
<option value="{{ v }}" {{ 'selected' if data.get('heat_score', 'warm') == v }}>{{ v | title }}</option>
|
||||||
<option value="cool">Cool</option>
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Status</label>
|
<label class="form-label">Status</label>
|
||||||
<select name="status" class="form-input">
|
<select name="status" class="form-input">
|
||||||
{% for s in statuses %}
|
{% for s in statuses %}
|
||||||
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s }}</option>
|
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s | replace('_', ' ') }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,68 @@
|
|||||||
{% extends "admin/base_admin.html" %}
|
{% extends "admin/base_admin.html" %}
|
||||||
{% set admin_page = "leads" %}
|
{% set admin_page = "leads" %}
|
||||||
{% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Leads - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.lead-heat-hot { background:#FEE2E2; color:#991B1B; font-weight:600; font-size:0.6875rem; padding:2px 8px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
|
||||||
|
.lead-heat-warm { background:#FEF3C7; color:#92400E; font-weight:600; font-size:0.6875rem; padding:2px 8px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
|
||||||
|
.lead-heat-cool { background:#DBEAFE; color:#1E40AF; font-weight:600; font-size:0.6875rem; padding:2px 8px; border-radius:9999px; display:inline-flex; align-items:center; gap:3px; }
|
||||||
|
|
||||||
|
.lead-status-new { background:#DBEAFE; color:#1E40AF; }
|
||||||
|
.lead-status-pending_verification { background:#FEF3C7; color:#92400E; }
|
||||||
|
.lead-status-contacted { background:#EDE9FE; color:#5B21B6; }
|
||||||
|
.lead-status-forwarded { background:#D1FAE5; color:#065F46; }
|
||||||
|
.lead-status-closed_won { background:#D1FAE5; color:#14532D; }
|
||||||
|
.lead-status-closed_lost { background:#F1F5F9; color:#475569; }
|
||||||
|
.lead-status-badge {
|
||||||
|
font-size:0.6875rem; font-weight:600; padding:2px 8px; border-radius:9999px;
|
||||||
|
display:inline-block; white-space:nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-pills { display:flex; gap:4px; }
|
||||||
|
.date-pills label {
|
||||||
|
cursor:pointer; font-size:0.75rem; padding:4px 10px; border-radius:9999px;
|
||||||
|
border:1px solid #CBD5E1; color:#475569; transition:all 0.1s;
|
||||||
|
}
|
||||||
|
.date-pills input[type=radio] { display:none; }
|
||||||
|
.date-pills input[type=radio]:checked + label {
|
||||||
|
background:#1D4ED8; border-color:#1D4ED8; color:#fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block admin_content %}
|
{% block admin_content %}
|
||||||
<header class="flex justify-between items-center mb-8">
|
<header class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl">Lead Management</h1>
|
<h1 class="text-2xl">Leads</h1>
|
||||||
<p class="text-sm text-slate mt-1">
|
<p class="text-sm text-slate mt-1">{{ total }} leads found</p>
|
||||||
{{ leads | length }} leads shown
|
|
||||||
{% if lead_stats %}
|
|
||||||
· {{ lead_stats.get('new', 0) }} new
|
|
||||||
· {{ lead_stats.get('forwarded', 0) }} forwarded
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
|
||||||
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
|
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
|
||||||
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="grid-4 mb-6">
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Total Leads</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ lead_stats._total }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">New / Unverified</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ lead_stats._new_unverified }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">awaiting action</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Hot Pipeline</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ lead_stats._hot_pipeline }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">credits (hot leads)</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Forward Rate</p>
|
||||||
|
<p class="text-3xl font-bold {% if lead_stats._forward_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ lead_stats._forward_rate }}%</p>
|
||||||
|
<p class="text-xs text-slate mt-1">{{ lead_stats.get('forwarded', 0) }} forwarded</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
||||||
<form class="flex flex-wrap gap-3 items-end"
|
<form class="flex flex-wrap gap-3 items-end"
|
||||||
@@ -29,6 +72,12 @@
|
|||||||
hx-indicator="#leads-loading">
|
hx-indicator="#leads-loading">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div style="flex:1;min-width:180px">
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||||
|
<input type="text" name="search" class="form-input" placeholder="Name, email, company…"
|
||||||
|
value="{{ current_search }}" style="width:100%">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||||
<select name="status" class="form-input" style="min-width:140px">
|
<select name="status" class="form-input" style="min-width:140px">
|
||||||
@@ -59,6 +108,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-semibold text-slate block mb-1">Period</label>
|
||||||
|
<div class="date-pills">
|
||||||
|
{% for label, val in [('Today', '1'), ('7d', '7'), ('30d', '30'), ('All', '')] %}
|
||||||
|
<input type="radio" name="days" id="days-{{ val }}" value="{{ val }}"
|
||||||
|
{% if current_days == val %}checked{% endif %}>
|
||||||
|
<label for="days-{{ val }}">{{ label }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<svg id="leads-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
<svg id="leads-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
|
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|||||||
142
web/src/padelnomics/admin/templates/admin/marketplace.html
Normal file
142
web/src/padelnomics/admin/templates/admin/marketplace.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{% extends "admin/base_admin.html" %}
|
||||||
|
{% set admin_page = "marketplace" %}
|
||||||
|
{% block title %}Marketplace — Admin — {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_head %}
|
||||||
|
<style>
|
||||||
|
.mkt-flag-toggle {
|
||||||
|
display:inline-flex; align-items:center; gap:8px;
|
||||||
|
padding:6px 14px; border:1px solid #CBD5E1; border-radius:8px;
|
||||||
|
font-size:0.8125rem; cursor:pointer; transition:all 0.15s;
|
||||||
|
}
|
||||||
|
.mkt-flag-toggle.enabled { background:#D1FAE5; border-color:#6EE7B7; color:#065F46; }
|
||||||
|
.mkt-flag-toggle.disabled { background:#F1F5F9; border-color:#CBD5E1; color:#64748B; }
|
||||||
|
.mkt-flag-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
|
.mkt-flag-toggle.enabled .mkt-flag-dot { background:#10B981; }
|
||||||
|
.mkt-flag-toggle.disabled .mkt-flag-dot { background:#94A3B8; }
|
||||||
|
|
||||||
|
.activity-dot {
|
||||||
|
width:8px; height:8px; border-radius:50%; flex-shrink:0; margin-top:5px;
|
||||||
|
}
|
||||||
|
.activity-dot-lead { background:#3B82F6; }
|
||||||
|
.activity-dot-unlock { background:#10B981; }
|
||||||
|
.activity-dot-credit { background:#F59E0B; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<header class="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl">Marketplace</h1>
|
||||||
|
<p class="text-slate text-sm mt-1">Lead funnel, credit economy, and supplier engagement</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('admin.leads') }}" class="btn-outline btn-sm">All Leads</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Lead Funnel -->
|
||||||
|
<h2 class="text-sm font-bold text-slate mb-3" style="text-transform:uppercase;letter-spacing:.06em">Lead Funnel</h2>
|
||||||
|
<div class="grid-4 mb-8">
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Total Leads</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ funnel.total }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Verified New</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ funnel.verified_new }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">ready to unlock</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Unlocked</p>
|
||||||
|
<p class="text-3xl font-bold text-navy">{{ funnel.unlocked }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">by suppliers</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<p class="card-header">Conversion</p>
|
||||||
|
<p class="text-3xl font-bold {% if funnel.conversion_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ funnel.conversion_rate }}%</p>
|
||||||
|
<p class="text-xs text-slate mt-1">{{ funnel.won }} won</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credit Economy + Supplier Engagement side-by-side -->
|
||||||
|
<div class="grid-2 mb-8" style="gap:1.5rem">
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Credit Economy</h2>
|
||||||
|
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Issued (all time)</p>
|
||||||
|
<p class="text-2xl font-bold text-navy">{{ "{:,}".format(credits.issued | int) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Consumed (all time)</p>
|
||||||
|
<p class="text-2xl font-bold text-navy">{{ "{:,}".format(credits.consumed | int) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Outstanding balance</p>
|
||||||
|
<p class="text-2xl font-bold text-navy">{{ "{:,}".format(credits.outstanding | int) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Monthly burn (30d)</p>
|
||||||
|
<p class="text-2xl font-bold {% if credits.monthly_burn > 0 %}text-navy{% else %}text-slate{% endif %}">{{ "{:,}".format(credits.monthly_burn | int) }}</p>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Supplier Engagement</h2>
|
||||||
|
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Active suppliers</p>
|
||||||
|
<p class="text-2xl font-bold text-navy">{{ suppliers.active }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">growth/pro w/ credits</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Avg unlocks / supplier</p>
|
||||||
|
<p class="text-2xl font-bold text-navy">{{ suppliers.avg_unlocks }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Response rate</p>
|
||||||
|
<p class="text-2xl font-bold {% if suppliers.response_rate > 0 %}text-navy{% else %}text-slate{% endif %}">{{ suppliers.response_rate }}%</p>
|
||||||
|
<p class="text-xs text-slate mt-1">replied or updated status</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate">Leads pipeline</p>
|
||||||
|
<p class="text-2xl font-bold text-navy">{{ funnel.verified_new }}</p>
|
||||||
|
<p class="text-xs text-slate mt-1">available to unlock</p>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Flag Toggles -->
|
||||||
|
<div class="card mb-8" style="padding:1.5rem">
|
||||||
|
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Feature Flags</h2>
|
||||||
|
<div class="flex gap-4 flex-wrap">
|
||||||
|
{% for flag_name, flag_label in [('lead_unlock', 'Lead Unlock (self-service)'), ('supplier_signup', 'Supplier Signup')] %}
|
||||||
|
{% set is_on = flags.get(flag_name, false) %}
|
||||||
|
<form method="post" action="{{ url_for('admin.flag_toggle') }}" class="m-0">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="name" value="{{ flag_name }}">
|
||||||
|
<input type="hidden" name="next" value="{{ url_for('admin.marketplace_dashboard') }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="mkt-flag-toggle {% if is_on %}enabled{% else %}disabled{% endif %}">
|
||||||
|
<span class="mkt-flag-dot"></span>
|
||||||
|
{{ flag_label }}
|
||||||
|
<span class="text-xs">{{ 'ON' if is_on else 'OFF' }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
<a href="{{ url_for('admin.flags') }}" class="text-xs text-slate" style="align-self:center;">All flags →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Stream (HTMX lazy-loaded) -->
|
||||||
|
<div id="activity-panel"
|
||||||
|
hx-get="{{ url_for('admin.marketplace_activity') }}"
|
||||||
|
hx-trigger="load delay:300ms"
|
||||||
|
hx-target="#activity-panel"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="card" style="padding:1.5rem">
|
||||||
|
<p class="text-slate text-sm">Loading activity stream…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{# HTMX swap target: forward history table after a successful forward #}
|
||||||
|
{% if forwards %}
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Response</th><th>Sent</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for f in forwards %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=f.supplier_id) }}">{{ f.supplier_name }}</a></td>
|
||||||
|
<td>{{ f.credit_cost }}</td>
|
||||||
|
<td>
|
||||||
|
{% set fwd_status = f.status or 'sent' %}
|
||||||
|
{% if fwd_status == 'won' %}
|
||||||
|
<span class="badge-success">Won</span>
|
||||||
|
{% elif fwd_status == 'lost' %}
|
||||||
|
<span class="badge">Lost</span>
|
||||||
|
{% elif fwd_status in ('contacted', 'quoted') %}
|
||||||
|
<span class="badge-warning">{{ fwd_status | capitalize }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">{{ fwd_status | capitalize }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if f.supplier_note %}
|
||||||
|
<span class="text-xs text-slate" title="{{ f.supplier_note }}">{{ f.supplier_note | truncate(40) }}</span>
|
||||||
|
{% elif f.status_updated_at %}
|
||||||
|
<span class="text-xs text-slate mono">{{ (f.status_updated_at or '')[:10] }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-slate">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-sm">{{ (f.created_at or '')[:16] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No forwards yet.</p>
|
||||||
|
{% endif %}
|
||||||
@@ -1,3 +1,28 @@
|
|||||||
|
{% set page = page | default(1) %}
|
||||||
|
{% set per_page = per_page | default(50) %}
|
||||||
|
{% set total = total | default(0) %}
|
||||||
|
{% set start = (page - 1) * per_page + 1 %}
|
||||||
|
{% set end = [page * per_page, total] | min %}
|
||||||
|
{% set has_prev = page > 1 %}
|
||||||
|
{% set has_next = (page * per_page) < total %}
|
||||||
|
|
||||||
|
{% macro heat_badge(score) %}
|
||||||
|
<span class="lead-heat-{{ score or 'cool' }}">{{ (score or 'cool') | upper }}</span>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro status_badge(status) %}
|
||||||
|
<span class="lead-status-badge lead-status-{{ status | replace(' ', '_') }}">{{ status | replace('_', ' ') }}</span>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# Hidden inputs carry current filters for pagination hx-include #}
|
||||||
|
<div id="lead-filter-state" style="display:none">
|
||||||
|
<input type="hidden" name="status" value="{{ current_status | default('') }}">
|
||||||
|
<input type="hidden" name="heat" value="{{ current_heat | default('') }}">
|
||||||
|
<input type="hidden" name="country" value="{{ current_country | default('') }}">
|
||||||
|
<input type="hidden" name="search" value="{{ current_search | default('') }}">
|
||||||
|
<input type="hidden" name="days" value="{{ current_days | default('') }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if leads %}
|
{% if leads %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -18,23 +43,15 @@
|
|||||||
{% for lead in leads %}
|
{% for lead in leads %}
|
||||||
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
|
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
|
||||||
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
||||||
<td>
|
<td>{{ heat_badge(lead.heat_score) }}</td>
|
||||||
{% if lead.heat_score == 'hot' %}
|
|
||||||
<span class="badge-danger">HOT</span>
|
|
||||||
{% elif lead.heat_score == 'warm' %}
|
|
||||||
<span class="badge-warning">WARM</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge">COOL</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
|
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
|
||||||
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
|
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ lead.country or '-' }}</td>
|
<td>{{ lead.country or '-' }}</td>
|
||||||
<td>{{ lead.court_count or '-' }}</td>
|
<td>{{ lead.court_count or '-' }}</td>
|
||||||
<td>{{ lead.budget_estimate or '-' }}</td>
|
<td>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</td>
|
||||||
<td><span class="badge">{{ lead.status }}</span></td>
|
<td>{{ status_badge(lead.status) }}</td>
|
||||||
<td>{{ lead.unlock_count or 0 }}</td>
|
<td>{{ lead.unlock_count or 0 }}</td>
|
||||||
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
|
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -42,6 +59,39 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total > per_page %}
|
||||||
|
<div class="flex justify-between items-center mt-4" style="font-size:0.8125rem; color:#64748B;">
|
||||||
|
<span>Showing {{ start }}–{{ end }} of {{ total }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if has_prev %}
|
||||||
|
<button class="btn-outline btn-sm"
|
||||||
|
hx-get="{{ url_for('admin.lead_results') }}"
|
||||||
|
hx-vals='{"page": "{{ page - 1 }}"}'
|
||||||
|
hx-include="#lead-filter-state"
|
||||||
|
hx-target="#lead-results"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<span>Page {{ page }}</span>
|
||||||
|
{% if has_next %}
|
||||||
|
<button class="btn-outline btn-sm"
|
||||||
|
hx-get="{{ url_for('admin.lead_results') }}"
|
||||||
|
hx-vals='{"page": "{{ page + 1 }}"}'
|
||||||
|
hx-include="#lead-filter-state"
|
||||||
|
hx-target="#lead-results"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-slate mt-3">Showing all {{ total }} results</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card text-center" style="padding:2rem">
|
<div class="card text-center" style="padding:2rem">
|
||||||
<p class="text-slate">No leads match the current filters.</p>
|
<p class="text-slate">No leads match the current filters.</p>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{# HTMX swap target: returns updated status badge + inline form #}
|
||||||
|
<div id="lead-status-section">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<span class="lead-status-badge lead-status-{{ status | replace(' ', '_') }}" style="font-size:0.875rem;padding:4px 12px;">
|
||||||
|
{{ status | replace('_', ' ') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-slate">updated</span>
|
||||||
|
</div>
|
||||||
|
<form hx-post="{{ url_for('admin.lead_status_htmx', lead_id=lead_id) }}"
|
||||||
|
hx-target="#lead-status-section"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="flex items-center gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<select name="status" class="form-input" style="min-width:160px">
|
||||||
|
{% for s in ['new','pending_verification','contacted','forwarded','closed_won','closed_lost'] %}
|
||||||
|
<option value="{{ s }}" {% if s == status %}selected{% endif %}>{{ s | replace('_', ' ') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-outline btn-sm">Update</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<div id="activity-panel" class="card" style="padding:1.5rem">
|
||||||
|
<h2 class="text-sm font-bold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em">Recent Activity</h2>
|
||||||
|
{% if events %}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
{% for ev in events %}
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<span class="activity-dot activity-dot-{{ ev.event_type }}"></span>
|
||||||
|
<div style="flex:1;font-size:0.8125rem">
|
||||||
|
{% if ev.event_type == 'lead' %}
|
||||||
|
<span class="font-semibold">New lead</span>
|
||||||
|
{% if ev.actor %} from {{ ev.actor }}{% endif %}
|
||||||
|
{% if ev.extra %} — {{ ev.extra }}{% endif %}
|
||||||
|
{% if ev.detail %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
|
||||||
|
{% elif ev.event_type == 'unlock' %}
|
||||||
|
<span class="font-semibold">{{ ev.actor }}</span> unlocked a lead
|
||||||
|
{% if ev.extra %} — {{ ev.extra }} credits{% endif %}
|
||||||
|
{% if ev.detail and ev.detail != 'sent' %} <span class="text-slate">({{ ev.detail }})</span>{% endif %}
|
||||||
|
{% elif ev.event_type == 'credit' %}
|
||||||
|
Credit event
|
||||||
|
{% if ev.extra and ev.extra | int > 0 %}<span class="text-slate">+{{ ev.extra }}</span>
|
||||||
|
{% elif ev.extra %}<span class="text-slate">{{ ev.extra }}</span>{% endif %}
|
||||||
|
{% if ev.detail %} <span class="text-xs text-slate">({{ ev.detail }})</span>{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="mono text-xs text-slate" style="flex-shrink:0">{{ (ev.created_at or '')[:10] }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-slate text-sm">No activity yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user