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:
Deeman
2026-02-25 09:31:44 +01:00
parent 7af612504b
commit 5867c611f8
10 changed files with 806 additions and 155 deletions

View File

@@ -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)
# ============================================================================= # =============================================================================

View File

@@ -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

View File

@@ -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">&larr; All Leads</a> <a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; 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 %}

View File

@@ -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">&larr; All Leads</a> <a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; 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 (&euro;)</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>
@@ -59,8 +92,8 @@
<div> <div>
<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','36 Months'),('6-12mo','612 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>

View File

@@ -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 %}
&middot; {{ lead_stats.get('new', 0) }} new
&middot; {{ 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"/>

View 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 %}

View File

@@ -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 %}

View File

@@ -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">
&larr; 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 &rarr;
</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>

View File

@@ -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>

View File

@@ -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>