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(
status: str = None, heat: str = None, country: str = None,
search: str = None, days: int = None,
page: int = 1, per_page: int = 50,
) -> list[dict]:
"""Get leads with optional filters."""
) -> tuple[list[dict], int]:
"""Get leads with optional filters. Returns (leads, total_count)."""
wheres = ["lead_type = 'quote'"]
params: list = []
@@ -395,16 +396,27 @@ async def get_leads(
if country:
wheres.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)
offset = (page - 1) * per_page
params.extend([per_page, offset])
count_row = await fetch_one(
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}
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:
@@ -426,11 +438,32 @@ async def get_lead_detail(lead_id: int) -> dict | None:
async def get_lead_stats() -> dict:
"""Get lead conversion funnel counts."""
"""Get lead conversion funnel counts + summary card metrics."""
rows = await fetch_all(
"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")
@@ -440,10 +473,15 @@ async def leads():
status = request.args.get("status", "")
heat = request.args.get("heat", "")
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"))
per_page = 50
lead_list = await get_leads(
status=status or None, heat=heat or None, country=country or None, page=page,
lead_list, total = await get_leads(
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()
@@ -461,7 +499,11 @@ async def leads():
current_status=status,
current_heat=heat,
current_country=country,
current_search=search,
current_days=days_str,
page=page,
per_page=per_page,
total=total,
)
@@ -472,12 +514,28 @@ async def lead_results():
status = request.args.get("status", "")
heat = request.args.get("heat", "")
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"))
per_page = 50
lead_list = await get_leads(
status=status or None, heat=heat or None, country=country or None, page=page,
lead_list, total = await get_leads(
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>")
@@ -528,11 +586,18 @@ async def lead_new():
contact_name = form.get("contact_name", "").strip()
contact_email = form.get("contact_email", "").strip()
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)
country = form.get("country", "")
city = form.get("city", "").strip()
location_status = form.get("location_status", "")
timeline = form.get("timeline", "")
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", "")
heat_score = form.get("heat_score", "warm")
status = form.get("status", "new")
@@ -550,14 +615,18 @@ async def lead_new():
lead_id = await execute(
"""INSERT INTO lead_requests
(lead_type, facility_type, court_count, country, location, timeline,
budget_estimate, stakeholder_type, heat_score, status,
(lead_type, facility_type, build_context, glass_type, lighting_type,
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,
credit_cost, verified_at, created_at)
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES ('quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
facility_type, court_count, country, city, timeline,
budget_estimate, stakeholder_type, heat_score, status,
facility_type, build_context, glass_type, lighting_type,
court_count, country, city, location_status, timeline,
budget_estimate, financing_status, services_needed, additional_info,
stakeholder_type, heat_score, status,
contact_name, contact_email,
form.get("contact_phone", ""), form.get("contact_company", ""),
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))
@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
# =============================================================================
@@ -895,7 +1132,8 @@ async def flag_toggle():
)
state = "enabled" if new_enabled else "disabled"
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
</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 %}">
<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

View File

@@ -2,122 +2,126 @@
{% set admin_page = "leads" %}
{% 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 %}
<header class="flex justify-between items-center mb-6">
<div>
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">&larr; All Leads</a>
<h1 class="text-2xl mt-1">Lead #{{ lead.id }}
{% 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 %}
</h1>
<div class="flex items-center gap-3 mt-1">
<h1 class="text-2xl">Lead #{{ lead.id }}</h1>
<span class="lead-heat-{{ lead.heat_score or 'cool' }}">{{ (lead.heat_score or 'cool') | upper }}</span>
</div>
</div>
<!-- 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() }}">
<select name="status" class="form-input" style="min-width:160px">
{% for s in statuses %}
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-outline btn-sm">Update</button>
</form>
</div>
<!-- Status update -->
<form method="post" action="{{ url_for('admin.lead_status', lead_id=lead.id) }}" class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="status" class="form-input" style="min-width:140px">
{% for s in statuses %}
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s }}</option>
{% endfor %}
</select>
<button type="submit" class="btn-outline btn-sm">Update</button>
</form>
</header>
<div class="grid-2" style="gap:1.5rem">
<!-- Project brief -->
<div class="card" style="padding:1.5rem">
<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">
<dt class="text-slate">Facility</dt>
<dd>{{ lead.facility_type or '-' }}</dd>
<dt class="text-slate">Courts</dt>
<dd>{{ lead.court_count or '-' }}</dd>
<dt class="text-slate">Glass</dt>
<dd>{{ lead.glass_type or '-' }}</dd>
<dt class="text-slate">Lighting</dt>
<dd>{{ lead.lighting_type or '-' }}</dd>
<dt class="text-slate">Build Context</dt>
<dd>{{ lead.build_context or '-' }}</dd>
<dt class="text-slate">Location</dt>
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
<dt class="text-slate">Timeline</dt>
<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 style="display:grid;grid-template-columns:160px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">Facility</dt> <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> <dd>{{ lead.court_count or '-' }}</dd>
<dt class="text-slate">Glass</dt> <dd>{{ lead.glass_type or '-' }}</dd>
<dt class="text-slate">Lighting</dt> <dd>{{ lead.lighting_type or '-' }}</dd>
<dt class="text-slate">Location</dt> <dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
<dt class="text-slate">Phase</dt> <dd>{{ lead.location_status or '-' }}</dd>
<dt class="text-slate">Timeline</dt> <dd>{{ lead.timeline or '-' }}</dd>
<dt class="text-slate">Budget</dt> <dd>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</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>
</div>
<!-- Contact info -->
<!-- Contact + forward -->
<div>
<div class="card mb-4" style="padding:1.5rem">
<h2 class="text-lg mb-4">Contact</h2>
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
<dt class="text-slate">Name</dt>
<dd>{{ lead.contact_name or '-' }}</dd>
<dt class="text-slate">Email</dt>
<dd>{{ lead.contact_email or '-' }}</dd>
<dt class="text-slate">Phone</dt>
<dd>{{ lead.contact_phone or '-' }}</dd>
<dt class="text-slate">Company</dt>
<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>
<dt class="text-slate">Name</dt> <dd>{{ lead.contact_name or '-' }}</dd>
<dt class="text-slate">Email</dt> <dd>{{ lead.contact_email or '-' }}</dd>
<dt class="text-slate">Phone</dt> <dd>{{ lead.contact_phone or '-' }}</dd>
<dt class="text-slate">Company</dt> <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>
</div>
<!-- Forward to supplier -->
<!-- Forward to supplier (HTMX) -->
<div class="card" style="padding:1.5rem">
<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() }}">
<select name="supplier_id" class="form-input mb-3" style="width:100%">
<option value="">Select supplier...</option>
{% for s in suppliers %}
<option value="{{ s.id }}">{{ s.name }} ({{ s.country_code }}, {{ s.category }})</option>
<option value="">Select supplier</option>
{# Lead's country first, then alphabetical #}
{% 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 %}
</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>
</div>
</div>
</div>
<!-- Forward history -->
{% if lead.forwards %}
<section class="mt-6">
<h2 class="text-lg mb-3">Forward History</h2>
<div class="card">
<table class="table">
<thead>
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Sent</th></tr>
</thead>
<tbody>
{% 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>
<h2 class="text-lg mb-3">Forward History
<span class="text-sm text-slate font-normal">({{ lead.forwards | length }} total)</span>
</h2>
<div class="card" style="padding:1rem">
<div id="forward-history">
{% include "admin/partials/lead_forward_history.html" with context %}
</div>
</div>
</section>
{% endif %}
{% endblock %}

View File

@@ -3,7 +3,7 @@
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
{% 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>
<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', '') }}">
</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 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Phone</label>
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
@@ -31,6 +30,14 @@
<label class="form-label">Company</label>
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
</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>
<hr style="margin:1.5rem 0">
@@ -40,18 +47,44 @@
<div>
<label class="form-label">Facility Type *</label>
<select name="facility_type" class="form-input" required>
<option value="indoor">Indoor</option>
<option value="outdoor">Outdoor</option>
<option value="both">Both</option>
{% for v in ['indoor','outdoor','both'] %}
<option value="{{ v }}" {{ 'selected' if data.get('facility_type', 'indoor') == v }}>{{ v | title }}</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>
</div>
<div>
<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') }}">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Budget (&euro;)</label>
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
<label class="form-label">Glass Type</label>
<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>
@@ -59,8 +92,8 @@
<div>
<label class="form-label">Country *</label>
<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')] %}
<option value="{{ code }}">{{ name }}</option>
{% 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 }}" {{ 'selected' if data.get('country') == code }}>{{ name }}</option>
{% endfor %}
</select>
</div>
@@ -72,41 +105,68 @@
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
<div>
<label class="form-label">Timeline *</label>
<select name="timeline" class="form-input" required>
<option value="asap">ASAP</option>
<option value="3-6mo">3-6 Months</option>
<option value="6-12mo">6-12 Months</option>
<option value="12+mo">12+ Months</option>
<label class="form-label">Location Status</label>
<select name="location_status" class="form-input">
<option value=""></option>
{% for v in ['secured','searching','evaluating'] %}
<option value="{{ v }}" {{ 'selected' if data.get('location_status') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Stakeholder Type *</label>
<select name="stakeholder_type" class="form-input" required>
<option value="entrepreneur">Entrepreneur</option>
<option value="tennis_club">Tennis Club</option>
<option value="municipality">Municipality</option>
<option value="developer">Developer</option>
<option value="operator">Operator</option>
<option value="architect">Architect</option>
<label class="form-label">Timeline *</label>
<select name="timeline" class="form-input" required>
{% for v, label in [('asap','ASAP'),('3-6mo','36 Months'),('6-12mo','612 Months'),('12+mo','12+ Months')] %}
<option value="{{ v }}" {{ 'selected' if data.get('timeline') == v }}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<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>
<label class="form-label">Heat Score</label>
<select name="heat_score" class="form-input">
<option value="hot">Hot</option>
<option value="warm" selected>Warm</option>
<option value="cool">Cool</option>
{% for v in ['hot','warm','cool'] %}
<option value="{{ v }}" {{ 'selected' if data.get('heat_score', 'warm') == v }}>{{ v | title }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-input">
{% for s in statuses %}
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s }}</option>
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s | replace('_', ' ') }}</option>
{% endfor %}
</select>
</div>

View File

@@ -1,25 +1,68 @@
{% extends "admin/base_admin.html" %}
{% 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 %}
<header class="flex justify-between items-center mb-8">
<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl">Lead Management</h1>
<p class="text-sm text-slate mt-1">
{{ 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 class="flex gap-2">
<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>
<h1 class="text-2xl">Leads</h1>
<p class="text-sm text-slate mt-1">{{ total }} leads found</p>
</div>
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
</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 -->
<div class="card mb-6" style="padding:1rem 1.25rem;">
<form class="flex flex-wrap gap-3 items-end"
@@ -29,6 +72,12 @@
hx-indicator="#leads-loading">
<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>
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
<select name="status" class="form-input" style="min-width:140px">
@@ -59,6 +108,17 @@
</select>
</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">
<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"/>

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 %}
<div class="card">
<table class="table">
@@ -18,23 +43,15 @@
{% for lead in leads %}
<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>
{% 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>{{ heat_badge(lead.heat_score) }}</td>
<td>
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
</td>
<td>{{ lead.country or '-' }}</td>
<td>{{ lead.court_count or '-' }}</td>
<td>{{ lead.budget_estimate or '-' }}</td>
<td><span class="badge">{{ lead.status }}</span></td>
<td>{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}</td>
<td>{{ status_badge(lead.status) }}</td>
<td>{{ lead.unlock_count or 0 }}</td>
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
</tr>
@@ -42,6 +59,39 @@
</tbody>
</table>
</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 %}
<div class="card text-center" style="padding:2rem">
<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>