diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index 464d983..64e103c 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -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/") @@ -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//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//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) # ============================================================================= diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index 687335e..06883f3 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -63,7 +63,11 @@ Dashboard -
Leads
+
Marketplace
+ + + Dashboard + Leads diff --git a/web/src/padelnomics/admin/templates/admin/lead_detail.html b/web/src/padelnomics/admin/templates/admin/lead_detail.html index b376dff..423a1fe 100644 --- a/web/src/padelnomics/admin/templates/admin/lead_detail.html +++ b/web/src/padelnomics/admin/templates/admin/lead_detail.html @@ -2,122 +2,126 @@ {% set admin_page = "leads" %} {% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %} +{% block admin_head %} + +{% endblock %} + {% block admin_content %}
← All Leads -

Lead #{{ lead.id }} - {% if lead.heat_score == 'hot' %}HOT - {% elif lead.heat_score == 'warm' %}WARM - {% else %}COOL{% endif %} -

+
+

Lead #{{ lead.id }}

+ {{ (lead.heat_score or 'cool') | upper }} +
+
+ +
+
+ + {{ lead.status | replace('_', ' ') }} + +
+
+ + + +
- -
- - - -

Project Brief

-
-
Facility
-
{{ lead.facility_type or '-' }}
-
Courts
-
{{ lead.court_count or '-' }}
-
Glass
-
{{ lead.glass_type or '-' }}
-
Lighting
-
{{ lead.lighting_type or '-' }}
-
Build Context
-
{{ lead.build_context or '-' }}
-
Location
-
{{ lead.location or '-' }}, {{ lead.country or '-' }}
-
Timeline
-
{{ lead.timeline or '-' }}
-
Phase
-
{{ lead.location_status or '-' }}
-
Budget
-
{{ lead.budget_estimate or '-' }}
-
Financing
-
{{ lead.financing_status or '-' }}
-
Services
-
{{ lead.services_needed or '-' }}
-
Additional Info
-
{{ lead.additional_info or '-' }}
-
Credit Cost
-
{{ lead.credit_cost or '-' }} credits
+
+
Facility
{{ lead.facility_type or '-' }}
+
Build Context
{{ lead.build_context or '-' }}
+
Courts
{{ lead.court_count or '-' }}
+
Glass
{{ lead.glass_type or '-' }}
+
Lighting
{{ lead.lighting_type or '-' }}
+
Location
{{ lead.location or '-' }}, {{ lead.country or '-' }}
+
Phase
{{ lead.location_status or '-' }}
+
Timeline
{{ lead.timeline or '-' }}
+
Budget
{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}
+
Financing
{{ lead.financing_status or '-' }}
+
Services
{{ lead.services_needed or '-' }}
+
Additional Info
{{ lead.additional_info or '-' }}
+
Credit Cost
{{ lead.credit_cost or '-' }} credits
- +

Contact

-
Name
-
{{ lead.contact_name or '-' }}
-
Email
-
{{ lead.contact_email or '-' }}
-
Phone
-
{{ lead.contact_phone or '-' }}
-
Company
-
{{ lead.contact_company or '-' }}
-
Role
-
{{ lead.stakeholder_type or '-' }}
-
Created
-
{{ lead.created_at or '-' }}
-
Verified
-
{{ lead.verified_at or 'Not verified' }}
+
Name
{{ lead.contact_name or '-' }}
+
Email
{{ lead.contact_email or '-' }}
+
Phone
{{ lead.contact_phone or '-' }}
+
Company
{{ lead.contact_company or '-' }}
+
Role
{{ lead.stakeholder_type or '-' }}
+
Created
{{ lead.created_at or '-' }}
+
Verified
{{ lead.verified_at or 'Not verified' }}
- +

Forward to Supplier

-
+ - +
- {% if lead.forwards %}
-

Forward History

-
- - - - - - {% for f in lead.forwards %} - - - - - - - {% endfor %} - -
SupplierCreditsStatusSent
{{ f.supplier_name }}{{ f.credit_cost }}{{ f.status }}{{ f.created_at[:16] if f.created_at else '-' }}
+

Forward History + ({{ lead.forwards | length }} total) +

+
+
+ {% include "admin/partials/lead_forward_history.html" with context %} +
- {% endif %} {% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/lead_form.html b/web/src/padelnomics/admin/templates/admin/lead_form.html index 857b965..cc6a2e0 100644 --- a/web/src/padelnomics/admin/templates/admin/lead_form.html +++ b/web/src/padelnomics/admin/templates/admin/lead_form.html @@ -3,7 +3,7 @@ {% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %} {% block admin_content %} -
+
← All Leads

Create Lead

@@ -21,8 +21,7 @@
- -
+
@@ -31,6 +30,14 @@
+
+ + +

@@ -40,18 +47,44 @@
+
+
+ +
+
+ +
- - + + +
+
+ +
@@ -59,8 +92,8 @@
@@ -72,41 +105,68 @@
- - + + {% for v in ['secured','searching','evaluating'] %} + + {% endfor %}
- - + {% for v, label in [('asap','ASAP'),('3-6mo','3–6 Months'),('6-12mo','6–12 Months'),('12+mo','12+ Months')] %} + + {% endfor %}
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +

Classification

+
diff --git a/web/src/padelnomics/admin/templates/admin/leads.html b/web/src/padelnomics/admin/templates/admin/leads.html index 3a3b2ad..988b3a7 100644 --- a/web/src/padelnomics/admin/templates/admin/leads.html +++ b/web/src/padelnomics/admin/templates/admin/leads.html @@ -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 %} + +{% endblock %} {% block admin_content %} -
+
-

Lead Management

-

- {{ leads | length }} leads shown - {% if lead_stats %} - · {{ lead_stats.get('new', 0) }} new - · {{ lead_stats.get('forwarded', 0) }} forwarded - {% endif %} -

-
-
- + New Lead - Back to Dashboard +

Leads

+

{{ total }} leads found

+ + New Lead
+ +
+
+

Total Leads

+

{{ lead_stats._total }}

+
+
+

New / Unverified

+

{{ lead_stats._new_unverified }}

+

awaiting action

+
+
+

Hot Pipeline

+

{{ lead_stats._hot_pipeline }}

+

credits (hot leads)

+
+
+

Forward Rate

+

{{ lead_stats._forward_rate }}%

+

{{ lead_stats.get('forwarded', 0) }} forwarded

+
+
+
+
+ + +
+
+
+ +
+ {% for label, val in [('Today', '1'), ('7d', '7'), ('30d', '30'), ('All', '')] %} + + + {% endfor %} +
+
+
+

Marketplace

+

Lead funnel, credit economy, and supplier engagement

+
+ All Leads +
+ + +

Lead Funnel

+
+
+

Total Leads

+

{{ funnel.total }}

+
+
+

Verified New

+

{{ funnel.verified_new }}

+

ready to unlock

+
+
+

Unlocked

+

{{ funnel.unlocked }}

+

by suppliers

+
+
+

Conversion

+

{{ funnel.conversion_rate }}%

+

{{ funnel.won }} won

+
+
+ + +
+
+

Credit Economy

+
+
+

Issued (all time)

+

{{ "{:,}".format(credits.issued | int) }}

+
+
+

Consumed (all time)

+

{{ "{:,}".format(credits.consumed | int) }}

+
+
+

Outstanding balance

+

{{ "{:,}".format(credits.outstanding | int) }}

+
+
+

Monthly burn (30d)

+

{{ "{:,}".format(credits.monthly_burn | int) }}

+
+
+
+ +
+

Supplier Engagement

+
+
+

Active suppliers

+

{{ suppliers.active }}

+

growth/pro w/ credits

+
+
+

Avg unlocks / supplier

+

{{ suppliers.avg_unlocks }}

+
+
+

Response rate

+

{{ suppliers.response_rate }}%

+

replied or updated status

+
+
+

Leads pipeline

+

{{ funnel.verified_new }}

+

available to unlock

+
+
+
+
+ + +
+

Feature Flags

+
+ {% for flag_name, flag_label in [('lead_unlock', 'Lead Unlock (self-service)'), ('supplier_signup', 'Supplier Signup')] %} + {% set is_on = flags.get(flag_name, false) %} + + + + + + + {% endfor %} + All flags → +
+
+ + +
+
+

Loading activity stream…

+
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/partials/lead_forward_history.html b/web/src/padelnomics/admin/templates/admin/partials/lead_forward_history.html new file mode 100644 index 0000000..e17be97 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/partials/lead_forward_history.html @@ -0,0 +1,40 @@ +{# HTMX swap target: forward history table after a successful forward #} +{% if forwards %} + + + + + + {% for f in forwards %} + + + + + + + + {% endfor %} + +
SupplierCreditsStatusResponseSent
{{ f.supplier_name }}{{ f.credit_cost }} + {% set fwd_status = f.status or 'sent' %} + {% if fwd_status == 'won' %} + Won + {% elif fwd_status == 'lost' %} + Lost + {% elif fwd_status in ('contacted', 'quoted') %} + {{ fwd_status | capitalize }} + {% else %} + {{ fwd_status | capitalize }} + {% endif %} + + {% if f.supplier_note %} + {{ f.supplier_note | truncate(40) }} + {% elif f.status_updated_at %} + {{ (f.status_updated_at or '')[:10] }} + {% else %} + + {% endif %} + {{ (f.created_at or '')[:16] }}
+{% else %} +

No forwards yet.

+{% endif %} diff --git a/web/src/padelnomics/admin/templates/admin/partials/lead_results.html b/web/src/padelnomics/admin/templates/admin/partials/lead_results.html index cab3969..c3ccbd3 100644 --- a/web/src/padelnomics/admin/templates/admin/partials/lead_results.html +++ b/web/src/padelnomics/admin/templates/admin/partials/lead_results.html @@ -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) %} + {{ (score or 'cool') | upper }} +{% endmacro %} + +{% macro status_badge(status) %} + {{ status | replace('_', ' ') }} +{% endmacro %} + +{# Hidden inputs carry current filters for pagination hx-include #} + + {% if leads %}
@@ -18,23 +43,15 @@ {% for lead in leads %} - + - - + + @@ -42,6 +59,39 @@
#{{ lead.id }} - {% if lead.heat_score == 'hot' %} - HOT - {% elif lead.heat_score == 'warm' %} - WARM - {% else %} - COOL - {% endif %} - {{ heat_badge(lead.heat_score) }} {{ lead.contact_name or '-' }}
{{ lead.contact_email or '-' }}
{{ lead.country or '-' }} {{ lead.court_count or '-' }}{{ lead.budget_estimate or '-' }}{{ lead.status }}{% if lead.budget_estimate %}€{{ "{:,}".format(lead.budget_estimate | int) }}{% else %}-{% endif %}{{ status_badge(lead.status) }} {{ lead.unlock_count or 0 }} {{ lead.created_at[:10] if lead.created_at else '-' }}
+ + +{% if total > per_page %} +
+ Showing {{ start }}–{{ end }} of {{ total }} +
+ {% if has_prev %} + + {% endif %} + Page {{ page }} + {% if has_next %} + + {% endif %} +
+
+{% else %} +

Showing all {{ total }} results

+{% endif %} + {% else %}

No leads match the current filters.

diff --git a/web/src/padelnomics/admin/templates/admin/partials/lead_status_badge.html b/web/src/padelnomics/admin/templates/admin/partials/lead_status_badge.html new file mode 100644 index 0000000..58f8ca7 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/partials/lead_status_badge.html @@ -0,0 +1,21 @@ +{# HTMX swap target: returns updated status badge + inline form #} +
+
+ + {{ status | replace('_', ' ') }} + + updated +
+
+ + + +
+
diff --git a/web/src/padelnomics/admin/templates/admin/partials/marketplace_activity.html b/web/src/padelnomics/admin/templates/admin/partials/marketplace_activity.html new file mode 100644 index 0000000..09e7d06 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/partials/marketplace_activity.html @@ -0,0 +1,32 @@ +
+

Recent Activity

+ {% if events %} +
+ {% for ev in events %} +
+ +
+ {% if ev.event_type == 'lead' %} + New lead + {% if ev.actor %} from {{ ev.actor }}{% endif %} + {% if ev.extra %} — {{ ev.extra }}{% endif %} + {% if ev.detail %} ({{ ev.detail }}){% endif %} + {% elif ev.event_type == 'unlock' %} + {{ ev.actor }} unlocked a lead + {% if ev.extra %} — {{ ev.extra }} credits{% endif %} + {% if ev.detail and ev.detail != 'sent' %} ({{ ev.detail }}){% endif %} + {% elif ev.event_type == 'credit' %} + Credit event + {% if ev.extra and ev.extra | int > 0 %}+{{ ev.extra }} + {% elif ev.extra %}{{ ev.extra }}{% endif %} + {% if ev.detail %} ({{ ev.detail }}){% endif %} + {% endif %} +
+ {{ (ev.created_at or '')[:10] }} +
+ {% endfor %} +
+ {% else %} +

No activity yet.

+ {% endif %} +